diff --git a/.env.example b/.env.example index 6b8639a74..bdb3e536d 100644 --- a/.env.example +++ b/.env.example @@ -8,19 +8,17 @@ ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 # 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 -# to the MongoDB container instance or Mongo Cloud +# Postgres creds +POSTGRES_PASSWORD=infisical +POSTGRES_USER=infisical +POSTGRES_DB=infisical + # Required -MONGO_URL=mongodb://root:example@mongo:27017/?authSource=admin +DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} # Redis REDIS_URL=redis://redis:6379 -# Optional credentials for MongoDB container instance and Mongo-Express -MONGO_USERNAME=root -MONGO_PASSWORD=example - # Website URL # Required SITE_URL=http://localhost:8080 diff --git a/.env.migration.example b/.env.migration.example new file mode 100644 index 000000000..4d1c8f9ef --- /dev/null +++ b/.env.migration.example @@ -0,0 +1 @@ +DB_CONNECTION_URI= diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 000000000..bce047f77 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,4 @@ +REDIS_URL=redis://localhost:6379 +DB_CONNECTION_URI=postgres://infisical:infisical@localhost/infisical?sslmode=disable +AUTH_SECRET=4bnfe4e407b8921c104518903515b218 +ENCRYPTION_KEY=4bnfe4e407b8921c104518903515b218 \ No newline at end of file diff --git a/.github/resources/changelog-generator.py b/.github/resources/changelog-generator.py new file mode 100644 index 000000000..7dd8140ee --- /dev/null +++ b/.github/resources/changelog-generator.py @@ -0,0 +1,190 @@ +# inspired by https://www.photoroom.com/inside-photoroom/how-we-automated-our-changelog-thanks-to-chatgpt +import os +import requests +import re +from openai import OpenAI +import subprocess +from datetime import datetime + +import uuid + +# Constants +REPO_OWNER = "infisical" +REPO_NAME = "infisical" +TOKEN = os.environ["GITHUB_TOKEN"] +SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"] +OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] +SLACK_MSG_COLOR = "#36a64f" + +headers = { + "Authorization": f"Bearer {TOKEN}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + + +def set_multiline_output(name, value): + with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: + delimiter = uuid.uuid1() + print(f'{name}<<{delimiter}', file=fh) + print(value, file=fh) + print(delimiter, file=fh) + +def post_changelog_to_slack(changelog, tag): + slack_payload = { + "text": "Hey team, it's changelog time! :wave:", + "attachments": [ + { + "color": SLACK_MSG_COLOR, + "title": f"πŸ—“οΈInfisical Changelog - {tag}", + "text": changelog, + } + ], + } + + response = requests.post(SLACK_WEBHOOK_URL, json=slack_payload) + + if response.status_code != 200: + raise Exception("Failed to post changelog to Slack.") + +def find_previous_release_tag(release_tag:str): + previous_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0", f"{release_tag}^"]).decode("utf-8").strip() + while not(previous_tag.startswith("infisical/")): + previous_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0", f"{previous_tag}^"]).decode("utf-8").strip() + return previous_tag + +def get_tag_creation_date(tag_name): + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/git/refs/tags/{tag_name}" + response = requests.get(url, headers=headers) + response.raise_for_status() + commit_sha = response.json()['object']['sha'] + + commit_url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/commits/{commit_sha}" + commit_response = requests.get(commit_url, headers=headers) + commit_response.raise_for_status() + creation_date = commit_response.json()['commit']['author']['date'] + + return datetime.strptime(creation_date, '%Y-%m-%dT%H:%M:%SZ') + + +def fetch_prs_between_tags(previous_tag_date:datetime, release_tag_date:datetime): + # Use GitHub API to fetch PRs merged between the commits + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/pulls?state=closed&merged=true" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + raise Exception("Error fetching PRs from GitHub API!") + + prs = [] + for pr in response.json(): + # the idea is as tags happen recently we get last 100 closed PRs and then filter by tag creation date + if pr["merged_at"] and datetime.strptime(pr["merged_at"],'%Y-%m-%dT%H:%M:%SZ') < release_tag_date and datetime.strptime(pr["merged_at"],'%Y-%m-%dT%H:%M:%SZ') > previous_tag_date: + prs.append(pr) + + return prs + + +def extract_commit_details_from_prs(prs): + commit_details = [] + for pr in prs: + commit_message = pr["title"] + commit_url = pr["html_url"] + pr_number = pr["number"] + branch_name = pr["head"]["ref"] + issue_numbers = re.findall(r"(www-\d+|web-\d+)", branch_name) + + # If no issue numbers are found, add the PR details without issue numbers and URLs + if not issue_numbers: + commit_details.append( + { + "message": commit_message, + "pr_number": pr_number, + "pr_url": commit_url, + "issue_number": None, + "issue_url": None, + } + ) + continue + + for issue in issue_numbers: + commit_details.append( + { + "message": commit_message, + "pr_number": pr_number, + "pr_url": commit_url, + "issue_number": issue, + } + ) + + return commit_details + +# Function to generate changelog using OpenAI +def generate_changelog_with_openai(commit_details): + commit_messages = [] + for details in commit_details: + base_message = f"{details['pr_url']} - {details['message']}" + # Add the issue URL if available + # if details["issue_url"]: + # base_message += f" (Linear Issue: {details['issue_url']})" + commit_messages.append(base_message) + + commit_list = "\n".join(commit_messages) + prompt = """ +Generate a changelog for Infisical, opensource secretops +The changelog should: +1. Be Informative: Using the provided list of GitHub commits, break them down into categories such as Features, Fixes & Improvements, and Technical Updates. Summarize each commit concisely, ensuring the key points are highlighted. +2. Have a Professional yet Friendly tone: The tone should be balanced, not too corporate or too informal. +3. Celebratory Introduction and Conclusion: Start the changelog with a celebratory note acknowledging the team's hard work and progress. End with a shoutout to the team and wishes for a pleasant weekend. +4. Formatting: you cannot use Markdown formatting, and you can only use emojis for the introductory paragraph or the conclusion paragraph, nowhere else. +5. Links: the syntax to create links is the following: ``. +6. Linear Links: note that the Linear link is optional, include it only if provided. +7. Do not wrap your answer in a codeblock. Just output the text, nothing else +Here's a good example to follow, please try to match the formatting as closely as possible, only changing the content of the changelog and have some liberty with the introduction. Notice the importance of the formatting of a changelog item: +- : We optimize our ci to strip comments and minify production builds. ()) +And here's an example of the full changelog: + +*Features* +β€’ : We optimize our ci to strip comments and minify production builds. () +*Fixes & Improvements* +β€’ : We optimize our ci to strip comments and minify production builds. () +*Technical Updates* +β€’ : We optimize our ci to strip comments and minify production builds. () + +Stay tuned for more exciting updates coming soon! +And here are the commits: +{} + """.format( + commit_list + ) + + client = OpenAI(api_key=OPENAI_API_KEY) + messages = [{"role": "user", "content": prompt}] + response = client.chat.completions.create(model="gpt-3.5-turbo", messages=messages) + + if "error" in response.choices[0].message: + raise Exception("Error generating changelog with OpenAI!") + + return response.choices[0].message.content.strip() + + +if __name__ == "__main__": + try: + # Get the latest and previous release tags + latest_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]).decode("utf-8").strip() + previous_tag = find_previous_release_tag(latest_tag) + + latest_tag_date = get_tag_creation_date(latest_tag) + previous_tag_date = get_tag_creation_date(previous_tag) + + prs = fetch_prs_between_tags(previous_tag_date,latest_tag_date) + pr_details = extract_commit_details_from_prs(prs) + + # Generate changelog + changelog = generate_changelog_with_openai(pr_details) + + post_changelog_to_slack(changelog,latest_tag) + # Print or post changelog to Slack + # set_multiline_output("changelog", changelog) + + except Exception as e: + print(str(e)) \ No newline at end of file diff --git a/.github/resources/rename_migration_files.py b/.github/resources/rename_migration_files.py new file mode 100644 index 000000000..5dd266728 --- /dev/null +++ b/.github/resources/rename_migration_files.py @@ -0,0 +1,26 @@ +import os +from datetime import datetime, timedelta + +def rename_migrations(): + migration_folder = "./backend/src/db/migrations" + with open("added_files.txt", "r") as file: + changed_files = file.readlines() + + # Find the latest file among the changed files + latest_timestamp = datetime.now() # utc time + for file_path in changed_files: + file_path = file_path.strip() + # each new file bump by 1s + latest_timestamp = latest_timestamp + timedelta(seconds=1) + + new_filename = os.path.join(migration_folder, latest_timestamp.strftime("%Y%m%d%H%M%S") + f"_{file_path.split('_')[1]}") + old_filename = os.path.join(migration_folder, file_path) + os.rename(old_filename, new_filename) + print(f"Renamed {old_filename} to {new_filename}") + + if len(changed_files) == 0: + print("No new files added to migration folder") + +if __name__ == "__main__": + rename_migrations() + diff --git a/.github/values.yaml b/.github/values.yaml index 90bf2ce0a..1b3ffd87a 100644 --- a/.github/values.yaml +++ b/.github/values.yaml @@ -13,11 +13,10 @@ fullnameOverride: "" ## infisical: - ## @param backend.enabled Enable backend - ## + autoDatabaseSchemaMigration: false + enabled: false - ## @param backend.name Backend name - ## + name: infisical replicaCount: 3 image: @@ -28,7 +27,7 @@ infisical: deploymentAnnotations: secrets.infisical.com/auto-reload: "true" - kubeSecretRef: "infisical-gamma-secrets" + kubeSecretRef: "managed-secret" ingress: ## @param ingress.enabled Enable ingress @@ -50,3 +49,9 @@ ingress: - secretName: letsencrypt-prod hosts: - gamma.infisical.com + +postgresql: + enabled: false + +redis: + enabled: false diff --git a/.github/workflows/build-docker-image-to-prod.yml b/.github/workflows/build-docker-image-to-prod.yml index d1ae80dad..3818fa1f8 100644 --- a/.github/workflows/build-docker-image-to-prod.yml +++ b/.github/workflows/build-docker-image-to-prod.yml @@ -41,6 +41,7 @@ jobs: load: true context: backend tags: infisical/infisical:test + platforms: linux/amd64,linux/arm64 - name: ⏻ Spawn backend container and dependencies run: | docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull @@ -92,6 +93,7 @@ jobs: project: 64mmf0n610 context: frontend tags: infisical/frontend:test + 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-patroni-docker-img.yml b/.github/workflows/build-patroni-docker-img.yml new file mode 100644 index 000000000..4ee99f27a --- /dev/null +++ b/.github/workflows/build-patroni-docker-img.yml @@ -0,0 +1,38 @@ +name: Build patroni +on: [workflow_dispatch] + +jobs: + patroni-image: + name: Build patroni + runs-on: ubuntu-latest + steps: + - name: ☁️ Checkout source + uses: actions/checkout@v3 + with: + repository: 'zalando/patroni' + - 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 backend and push to docker hub + uses: depot/build-push-action@v1 + with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} + push: true + context: . + file: Dockerfile + tags: | + infisical/patroni:${{ steps.commit.outputs.short }} + infisical/patroni:latest + platforms: linux/amd64,linux/arm64 + + \ No newline at end of file diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml new file mode 100644 index 000000000..a9b2046ae --- /dev/null +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -0,0 +1,140 @@ +name: Deployment pipeline +on: [workflow_dispatch] + +permissions: + id-token: write + contents: read + +jobs: + infisical-image: + name: Build backend image + runs-on: ubuntu-latest + steps: + - name: ☁️ Checkout source + uses: actions/checkout@v3 + - name: πŸ“¦ Install dependencies to test all dependencies + run: npm ci --only-production + working-directory: backend + - 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 backend and push to docker hub + uses: depot/build-push-action@v1 + with: + project: 64mmf0n610 + token: ${{ secrets.DEPOT_PROJECT_TOKEN }} + push: true + context: . + file: Dockerfile.standalone-infisical + tags: | + 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.commit.outputs.short }} + + gamma-deployment: + name: Deploy to gamma + runs-on: ubuntu-latest + needs: [infisical-image] + environment: + name: Gamma + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup Node.js environment + uses: actions/setup-node@v2 + with: + node-version: "20" + - name: Change directory to backend and install dependencies + env: + DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }} + run: | + cd backend + npm install + npm run migration:latest + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: us-east-1 + role-to-assume: arn:aws:iam::905418227878:role/deploy-new-ecs-img + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 + - name: Download task definition + run: | + aws ecs describe-task-definition --task-definition infisical-core-platform --query taskDefinition > task-definition.json + - name: Render Amazon ECS task definition + id: render-web-container + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: infisical-core-platform + image: infisical/staging_infisical:${{ steps.commit.outputs.short }} + environment-variables: "LOG_LEVEL=info" + - name: Deploy to Amazon ECS service + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.render-web-container.outputs.task-definition }} + service: infisical-core-platform + cluster: infisical-core-platform + wait-for-service-stability: true + + production-postgres-deployment: + name: Deploy to production + runs-on: ubuntu-latest + needs: [gamma-deployment] + environment: + name: Production + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup Node.js environment + uses: actions/setup-node@v2 + with: + node-version: "20" + - name: Change directory to backend and install dependencies + env: + DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }} + run: | + cd backend + npm install + npm run migration:latest + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + audience: sts.amazonaws.com + aws-region: us-east-1 + role-to-assume: arn:aws:iam::381492033652:role/gha-make-prod-deployment + - name: Save commit hashes for tag + id: commit + uses: pr-mpt/actions-commit-hash@v2 + - name: Download task definition + run: | + aws ecs describe-task-definition --task-definition infisical-core-platform --query taskDefinition > task-definition.json + - name: Render Amazon ECS task definition + id: render-web-container + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: infisical-core-platform + image: infisical/staging_infisical:${{ steps.commit.outputs.short }} + environment-variables: "LOG_LEVEL=info" + - name: Deploy to Amazon ECS service + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.render-web-container.outputs.task-definition }} + service: infisical-core-platform + cluster: infisical-core-platform + wait-for-service-stability: true diff --git a/.github/workflows/build-staging-and-deploy.yml b/.github/workflows/build-staging-and-deploy.yml deleted file mode 100644 index 31ffb8729..000000000 --- a/.github/workflows/build-staging-and-deploy.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Build, Publish and Deploy to Gamma -on: [workflow_dispatch] - -jobs: - infisical-image: - name: Build backend image - runs-on: ubuntu-latest - steps: - - name: ☁️ Checkout source - uses: actions/checkout@v3 - - name: πŸ“¦ Install dependencies to test all dependencies - run: npm ci --only-production - working-directory: backend - # - name: πŸ§ͺ Run tests - # run: npm run test:ci - # working-directory: backend - - name: Save commit hashes for tag - id: commit - uses: pr-mpt/actions-commit-hash@v2 - - 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 backend and export to Docker - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - load: true - 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 - # - name: πŸ§ͺ Test backend image - # run: | - # ./.github/resources/healthcheck.sh infisical-backend-test - # - name: ⏻ Shut down backend container and dependencies - # run: | - # docker compose -f .github/resources/docker-compose.be-test.yml down - - name: πŸ—οΈ Build backend and push - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - push: true - context: . - file: Dockerfile.standalone-infisical - tags: | - 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 }} - postgres-migration: - name: Run latest migration files - runs-on: ubuntu-latest - needs: [infisical-image] - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Setup Node.js environment - uses: actions/setup-node@v2 - with: - node-version: "20" - - name: Change directory to backend and install dependencies - env: - DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }} - run: | - cd backend - npm install - npm run migration:latest - # - name: Run postgres DB migration files - # env: - # DB_CONNECTION_URI: ${{ secrets.DB_CONNECTION_URI }} - # run: npm run migration:latest - gamma-deployment: - name: Deploy to gamma - runs-on: ubuntu-latest - needs: [postgres-migration] - steps: - - name: ☁️ Checkout source - uses: actions/checkout@v3 - - name: Install Helm - uses: azure/setup-helm@v3 - with: - version: v3.10.0 - - name: Install infisical helm chart - run: | - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - helm repo update - - name: Install kubectl - uses: azure/setup-kubectl@v3 - - name: Install doctl - uses: digitalocean/action-doctl@v2 - with: - token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} - - name: Save DigitalOcean kubeconfig with short-lived credentials - run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 infisical-gamma-postgres - - name: switch to gamma namespace - run: kubectl config set-context --current --namespace=gamma - - name: test kubectl - run: kubectl get ingress - - name: Download helm values to file and upgrade gamma deploy - run: | - wget https://raw.githubusercontent.com/Infisical/infisical/main/.github/values.yaml - helm upgrade infisical infisical-helm-charts/infisical-standalone --values values.yaml --wait --install - if [[ $(helm status infisical) == *"FAILED"* ]]; then - echo "Helm upgrade failed" - exit 1 - else - echo "Helm upgrade was successful" - fi diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index de0e0eb8c..2086601a8 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -1,10 +1,11 @@ -name: "Check Backend Breaking API Changes" +name: "Check API For Breaking Changes" on: pull_request: types: [opened, synchronize] paths: - "backend/src/server/routes/**" + - "backend/src/ee/routes/**" jobs: check-be-api-changes: @@ -14,36 +15,62 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v3 - - name: Setup Node 20 - uses: actions/setup-node@v3 - with: - node-version: "20" - # uncomment this when testing locally using nektos/act - # - uses: KengoTODA/actions-setup-docker-compose@v1 - # if: ${{ env.ACT }} - # name: Install `docker-compose` for local simulations + # - name: Setup Node 20 + # uses: actions/setup-node@v3 # with: - # version: "2.14.2" + # node-version: "20" + # uncomment this when testing locally using nektos/act + - uses: KengoTODA/actions-setup-docker-compose@v1 + if: ${{ env.ACT }} + name: Install `docker-compose` for local simulations + with: + version: "2.14.2" - name: πŸ“¦Build the latest image run: docker build --tag infisical-api . working-directory: backend - name: Start postgres and redis - run: touch .env && docker-compose -f "docker-compose.pg.yml" up db redis -d + run: touch .env && docker-compose -f docker-compose.dev.yml up -d db redis - name: Start the server - run: docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs" + run: | + echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env + echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env + echo "SECRET_SCANNING_WEBHOOK_SECRET=some-random" >> .env + docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET --env-file .env --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs" env: - REDIS_URL: redis://host.docker.internal:6379 - DB_CONNECTION_URI: postgres://infisical:infisical@host.docker.internal:5432/infisical?sslmode=disable + REDIS_URL: redis://172.17.0.1:6379 + DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable JWT_AUTH_SECRET: something-random - - name: Install openapi api diff - run: npm install -g openapi-diff - - name: Wait for containers to be stable - run: timeout 60s sh -c 'until docker ps | grep infisical-api | grep -q healthy; do echo "Waiting for container to be healthy..."; sleep 2; done' - - name: Get changes made in API - id: openapi-diff - run: openapi-diff https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json + - uses: actions/setup-go@v5 + with: + go-version: '1.21.5' + - name: Wait for container to be stable and check logs + run: | + SECONDS=0 + HEALTHY=0 + while [ $SECONDS -lt 60 ]; do + if docker ps | grep infisical-api | grep -q healthy; then + echo "Container is healthy." + HEALTHY=1 + break + fi + echo "Waiting for container to be healthy... ($SECONDS seconds elapsed)" + + docker logs infisical-api + + sleep 2 + SECONDS=$((SECONDS+2)) + done + + if [ $HEALTHY -ne 1 ]; then + echo "Container did not become healthy in time" + exit 1 + fi + - name: Install openapi-diff + run: go install github.com/tufin/oasdiff@latest + - name: Running OpenAPI Spec diff action + run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR - name: cleanup - run: | - docker-compose -f "docker-compose.pg.yml" down + run: | + docker-compose -f "docker-compose.dev.yml" down docker stop infisical-api - docker remove infisical-api + docker remove infisical-api \ No newline at end of file diff --git a/.github/workflows/check-fe-pull-request.yml b/.github/workflows/check-fe-ts-and-lint.yml similarity index 67% rename from .github/workflows/check-fe-pull-request.yml rename to .github/workflows/check-fe-ts-and-lint.yml index 75465a014..17e5a9d74 100644 --- a/.github/workflows/check-fe-pull-request.yml +++ b/.github/workflows/check-fe-ts-and-lint.yml @@ -1,4 +1,4 @@ -name: Check Frontend Pull Request +name: Check Frontend Type and Lint check on: pull_request: @@ -10,8 +10,8 @@ on: - "frontend/.eslintrc.js" jobs: - check-fe-pr: - name: Check + check-fe-ts-lint: + name: Check Frontend Type and Lint check runs-on: ubuntu-latest timeout-minutes: 15 @@ -25,12 +25,11 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: πŸ“¦ Install dependencies - run: npm ci --only-production --ignore-scripts + run: npm install working-directory: frontend - # - - # name: πŸ§ͺ Run tests - # run: npm run test:ci - # working-directory: frontend - - name: πŸ—οΈ Run build - run: npm run build + - name: πŸ—οΈ Run Type check + run: npm run type:check + working-directory: frontend + - name: πŸ—οΈ Run Link check + run: npm run lint:fix working-directory: frontend diff --git a/.github/workflows/generate-release-changelog.yml b/.github/workflows/generate-release-changelog.yml new file mode 100644 index 000000000..e26a304cf --- /dev/null +++ b/.github/workflows/generate-release-changelog.yml @@ -0,0 +1,34 @@ +name: Generate Changelog +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - "infisical/v*.*.*-postgres" + +jobs: + generate_changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12.0" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests openai + - name: Generate Changelog and Post to Slack + id: gen-changelog + run: python .github/resources/changelog-generator.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 54f4f4fbe..f08e882aa 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -5,9 +5,14 @@ on: - "infisical/v*.*.*-postgres" jobs: + infisical-tests: + name: Run tests before deployment + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview + uses: ./.github/workflows/run-backend-tests.yml infisical-standalone: name: Build infisical standalone image postgres runs-on: ubuntu-latest + needs: [infisical-tests] steps: - name: Extract version from tag id: extract_version diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index 9840e2d3e..e4a5945e0 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -1,58 +1,72 @@ name: Build and release CLI on: - push: - # run only against tags - tags: - - "infisical-cli/v*.*.*" + workflow_dispatch: + + push: + # run only against tags + tags: + - "infisical-cli/v*.*.*" permissions: - contents: write - # packages: write - # issues: write - + contents: write + # packages: write + # issues: write jobs: - goreleaser: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: πŸ‹ Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - run: git fetch --force --tags - - run: echo "Ref name ${{github.ref_name}}" - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - cache: true - cache-dependency-path: cli/go.sum - - name: libssl1.1 => libssl1.0-dev for OSXCross - run: | - echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list - sudo apt update && apt-cache policy libssl1.0-dev - sudo apt-get install libssl1.0-dev - - name: OSXCross for CGO Support - run: | - mkdir ../../osxcross - git clone https://github.com/plentico/osxcross-target.git ../../osxcross/target - - uses: goreleaser/goreleaser-action@v4 - with: - distribution: goreleaser-pro - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} - POSTHOG_API_KEY_FOR_CLI: ${{ secrets.POSTHOG_API_KEY_FOR_CLI }} - FURY_TOKEN: ${{ secrets.FURYPUSHTOKEN }} - AUR_KEY: ${{ secrets.AUR_KEY }} - GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} - - uses: actions/setup-python@v4 - - run: pip install --upgrade cloudsmith-cli - - name: Publish to CloudSmith - run: sh cli/upload_to_cloudsmith.sh - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + cli-integration-tests: + name: Run tests before deployment + uses: ./.github/workflows/run-cli-tests.yml + secrets: + CLI_TESTS_UA_CLIENT_ID: ${{ secrets.CLI_TESTS_UA_CLIENT_ID }} + CLI_TESTS_UA_CLIENT_SECRET: ${{ secrets.CLI_TESTS_UA_CLIENT_SECRET }} + CLI_TESTS_SERVICE_TOKEN: ${{ secrets.CLI_TESTS_SERVICE_TOKEN }} + CLI_TESTS_PROJECT_ID: ${{ secrets.CLI_TESTS_PROJECT_ID }} + CLI_TESTS_ENV_SLUG: ${{ secrets.CLI_TESTS_ENV_SLUG }} + + goreleaser: + runs-on: ubuntu-20.04 + needs: [cli-integration-tests] + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: πŸ‹ Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: πŸ”§ Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - run: git fetch --force --tags + - run: echo "Ref name ${{github.ref_name}}" + - uses: actions/setup-go@v3 + with: + go-version: ">=1.19.3" + cache: true + cache-dependency-path: cli/go.sum + - name: libssl1.1 => libssl1.0-dev for OSXCross + run: | + echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list + sudo apt update && apt-cache policy libssl1.0-dev + sudo apt-get install libssl1.0-dev + - name: OSXCross for CGO Support + run: | + mkdir ../../osxcross + git clone https://github.com/plentico/osxcross-target.git ../../osxcross/target + - uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser-pro + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} + POSTHOG_API_KEY_FOR_CLI: ${{ secrets.POSTHOG_API_KEY_FOR_CLI }} + FURY_TOKEN: ${{ secrets.FURYPUSHTOKEN }} + AUR_KEY: ${{ secrets.AUR_KEY }} + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} + - uses: actions/setup-python@v4 + - run: pip install --upgrade cloudsmith-cli + - name: Publish to CloudSmith + run: sh cli/upload_to_cloudsmith.sh + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} diff --git a/.github/workflows/run-backend-tests.yml b/.github/workflows/run-backend-tests.yml new file mode 100644 index 000000000..edb58f9a6 --- /dev/null +++ b/.github/workflows/run-backend-tests.yml @@ -0,0 +1,47 @@ +name: "Run backend tests" + +on: + pull_request: + types: [opened, synchronize] + paths: + - "backend/**" + - "!backend/README.md" + - "!backend/.*" + - "backend/.eslintrc.js" + workflow_call: + +jobs: + check-be-pr: + name: Run integration test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: ☁️ Checkout source + uses: actions/checkout@v3 + - uses: KengoTODA/actions-setup-docker-compose@v1 + if: ${{ env.ACT }} + name: Install `docker-compose` for local simulations + with: + version: "2.14.2" + - name: πŸ”§ Setup Node 20 + uses: actions/setup-node@v3 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + - name: Install dependencies + run: npm install + working-directory: backend + - name: Start postgres and redis + run: touch .env && docker-compose -f docker-compose.dev.yml up -d db redis + - name: Start integration test + run: npm run test:e2e + working-directory: backend + env: + REDIS_URL: redis://172.17.0.1:6379 + DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable + AUTH_SECRET: something-random + ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 + - name: cleanup + run: | + docker-compose -f "docker-compose.dev.yml" down \ No newline at end of file diff --git a/.github/workflows/run-cli-tests.yml b/.github/workflows/run-cli-tests.yml new file mode 100644 index 000000000..e814f9143 --- /dev/null +++ b/.github/workflows/run-cli-tests.yml @@ -0,0 +1,47 @@ +name: Go CLI Tests + +on: + pull_request: + types: [opened, synchronize] + paths: + - "cli/**" + + workflow_dispatch: + + workflow_call: + secrets: + CLI_TESTS_UA_CLIENT_ID: + required: true + CLI_TESTS_UA_CLIENT_SECRET: + required: true + CLI_TESTS_SERVICE_TOKEN: + required: true + CLI_TESTS_PROJECT_ID: + required: true + CLI_TESTS_ENV_SLUG: + required: true + +jobs: + test: + defaults: + run: + working-directory: ./cli + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.x" + - name: Install dependencies + run: go get . + - name: Test with the Go CLI + env: + CLI_TESTS_UA_CLIENT_ID: ${{ secrets.CLI_TESTS_UA_CLIENT_ID }} + CLI_TESTS_UA_CLIENT_SECRET: ${{ secrets.CLI_TESTS_UA_CLIENT_SECRET }} + CLI_TESTS_SERVICE_TOKEN: ${{ secrets.CLI_TESTS_SERVICE_TOKEN }} + CLI_TESTS_PROJECT_ID: ${{ secrets.CLI_TESTS_PROJECT_ID }} + CLI_TESTS_ENV_SLUG: ${{ secrets.CLI_TESTS_ENV_SLUG }} + + run: go test -v -count=1 ./test diff --git a/.github/workflows/update-be-new-migration-latest-timestamp.yml b/.github/workflows/update-be-new-migration-latest-timestamp.yml new file mode 100644 index 000000000..684c78654 --- /dev/null +++ b/.github/workflows/update-be-new-migration-latest-timestamp.yml @@ -0,0 +1,59 @@ +name: Rename Migrations + +on: + pull_request: + types: [closed] + paths: + - 'backend/src/db/migrations/**' + +jobs: + rename: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get list of newly added files in migration folder + run: | + git diff --name-status HEAD^ HEAD backend/src/db/migrations | grep '^A' | cut -f2 | xargs -n1 basename > added_files.txt + if [ ! -s added_files.txt ]; then + echo "No new files added. Skipping" + echo "SKIP_RENAME=true" >> $GITHUB_ENV + fi + + - name: Script to rename migrations + if: env.SKIP_RENAME != 'true' + run: python .github/resources/rename_migration_files.py + + - name: Commit and push changes + if: env.SKIP_RENAME != 'true' + run: | + git config user.name github-actions + git config user.email github-actions@github.com + git add ./backend/src/db/migrations + rm added_files.txt + git commit -m "chore: renamed new migration files to latest timestamp (gh-action)" + + - name: Get PR details + id: pr_details + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + PR_MERGER=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.merged_by.login') + + echo "PR Number: $PR_NUMBER" + echo "PR Merger: $PR_MERGER" + echo "pr_merger=$PR_MERGER" >> $GITHUB_OUTPUT + + - name: Create Pull Request + if: env.SKIP_RENAME != 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: renamed new migration files to latest UTC (gh-action)' + title: 'GH Action: rename new migration file timestamp' + branch-suffix: timestamp + reviewers: ${{ steps.pr_details.outputs.pr_merger }} diff --git a/.gitignore b/.gitignore index f3c03e814..b04860071 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ node_modules .env.gamma .env.prod .env.infisical - +.env.migration *~ *.swp *.swo @@ -59,7 +59,13 @@ yarn-error.log* # Infisical init .infisical.json +.infisicalignore + # Editor specific .vscode/* frontend-build + +*.tgz +cli/infisical-merge +cli/test/infisical-merge diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1c5db8f00..8f608c40c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -190,10 +190,34 @@ dockers: - dockerfile: docker/alpine goos: linux goarch: amd64 + use: buildx ids: - all-other-builds image_templates: - - "infisical/cli:{{ .Version }}" - - "infisical/cli:{{ .Major }}.{{ .Minor }}" - - "infisical/cli:{{ .Major }}" - - "infisical/cli:latest" + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" + - "infisical/cli:latest-amd64" + build_flag_templates: + - "--pull" + - "--platform=linux/amd64" + - dockerfile: docker/alpine + goos: linux + goarch: amd64 + use: buildx + ids: + - all-other-builds + image_templates: + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64" + - "infisical/cli:latest-arm64" + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" + +docker_manifests: + - name_template: "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" + image_templates: + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64" + - name_template: "infisical/cli:latest" + image_templates: + - "infisical/cli:latest-amd64" + - "infisical/cli:latest-arm64" diff --git a/.infisicalignore b/.infisicalignore index b8fafe6db..855047fe4 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -1 +1,7 @@ .github/resources/docker-compose.be-test.yml:generic-api-key:16 +frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx:generic-api-key:206 +frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:304 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 +docs/self-hosting/configuration/envars.mdx:generic-api-key:106 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e735b10d6..b2a9cabfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,6 @@ Thanks for taking the time to contribute! πŸ˜ƒ πŸš€ -Please refer to our [Contributing Guide](https://infisical.com/docs/contributing/overview) for instructions on how to contribute. +Please refer to our [Contributing Guide](https://infisical.com/docs/contributing/getting-started/overview) for instructions on how to contribute. We also have some πŸ”₯amazingπŸ”₯ merch for our contributors. Please reach out to tony@infisical.com for more info πŸ‘€ diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index d4596115e..737067534 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -1,6 +1,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id +ARG SAML_ORG_SLUG=saml-org-slug-default FROM node:20-alpine AS base @@ -35,6 +36,8 @@ ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION +ARG SAML_ORG_SLUG +ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG # Build RUN npm run build @@ -100,6 +103,9 @@ ENV 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 +ARG SAML_ORG_SLUG +ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ + BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG WORKDIR / @@ -118,9 +124,6 @@ WORKDIR /backend ENV TELEMETRY_ENABLED true -HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ - CMD node healthcheck.js - EXPOSE 8080 EXPOSE 443 diff --git a/Makefile b/Makefile index 544a0256d..11143162e 100644 --- a/Makefile +++ b/Makefile @@ -5,16 +5,13 @@ push: docker-compose -f docker-compose.yml push up-dev: - docker-compose -f docker-compose.dev.yml up --build + docker compose -f docker-compose.dev.yml up --build -up-pg-dev: - docker compose -f docker-compose.pg.yml up --build - -i-dev: - infisical run -- docker-compose -f docker-compose.dev.yml up --build +up-dev-ldap: + docker compose -f docker-compose.dev.yml --profile ldap up --build up-prod: - docker-compose -f docker-compose.yml up --build + docker-compose -f docker-compose.prod.yml up --build down: - docker-compose down + docker compose -f docker-compose.dev.yml down diff --git a/README.md b/README.md index 5e0cb854f..80f754c02 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Infisical Cloud | Self-Hosting | Docs | - Website + Website | + Hiring (Remote/SF)

@@ -75,7 +76,7 @@ Check out the [Quickstart Guides](https://infisical.com/docs/getting-started/int | Use Infisical Cloud | Deploy Infisical on premise | | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The fastest and most reliable way to
get started with Infisical is signing up
for free to [Infisical Cloud](https://app.infisical.com/login). | Deploy to DO
View all [deployment options](https://infisical.com/docs/self-hosting/overview) | +| The fastest and most reliable way to
get started with Infisical is signing up
for free to [Infisical Cloud](https://app.infisical.com/login). |
View all [deployment options](https://infisical.com/docs/self-hosting/overview) | ### Run Infisical locally @@ -84,13 +85,13 @@ To set up and run Infisical locally, make sure you have Git and Docker installed Linux/macOS: ```console -git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.yml up +git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.prod.yml up ``` Windows Command Prompt: ```console -git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.yml up +git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.prod.yml up ``` Create an account at `http://localhost:80` diff --git a/backend/.eslintignore b/backend/.eslintignore index c767a4a9e..660e6d10f 100644 --- a/backend/.eslintignore +++ b/backend/.eslintignore @@ -1,2 +1,3 @@ vitest-environment-infisical.ts vitest.config.ts +vitest.e2e.config.ts diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index e99c48c09..b23cf05ae 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -21,6 +21,19 @@ module.exports = { tsconfigRootDir: __dirname }, root: true, + overrides: [ + { + files: ["./e2e-test/**/*", "./src/db/migrations/**/*"], + rules: { + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off" + } + } + ], + rules: { "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-unsafe-enum-comparison": "off", diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts new file mode 100644 index 000000000..c85244129 --- /dev/null +++ b/backend/e2e-test/mocks/keystore.ts @@ -0,0 +1,30 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; + +export const mockKeyStore = (): TKeyStoreFactory => { + const store: Record = {}; + + return { + setItem: async (key, value) => { + store[key] = value; + return "OK"; + }, + setItemWithExpiry: async (key, value) => { + store[key] = value; + return "OK"; + }, + deleteItem: async (key) => { + delete store[key]; + return 1; + }, + getItem: async (key) => { + const value = store[key]; + if (typeof value === "string") { + return value; + } + return null; + }, + incrementBy: async () => { + return 1; + } + }; +}; diff --git a/backend/e2e-test/routes/v1/identity.spec.ts b/backend/e2e-test/routes/v1/identity.spec.ts new file mode 100644 index 000000000..ccb530c79 --- /dev/null +++ b/backend/e2e-test/routes/v1/identity.spec.ts @@ -0,0 +1,71 @@ +import { OrgMembershipRole } from "@app/db/schemas"; +import { seedData1 } from "@app/db/seed-data"; + +export const createIdentity = async (name: string, role: string) => { + const createIdentityRes = await testServer.inject({ + method: "POST", + url: "/api/v1/identities", + body: { + name, + role, + organizationId: seedData1.organization.id + }, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(createIdentityRes.statusCode).toBe(200); + return createIdentityRes.json().identity; +}; + +export const deleteIdentity = async (id: string) => { + const deleteIdentityRes = await testServer.inject({ + method: "DELETE", + url: `/api/v1/identities/${id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(deleteIdentityRes.statusCode).toBe(200); + return deleteIdentityRes.json().identity; +}; + +describe("Identity v1", async () => { + test("Create identity", async () => { + const newIdentity = await createIdentity("mac1", OrgMembershipRole.Admin); + expect(newIdentity.name).toBe("mac1"); + expect(newIdentity.authMethod).toBeNull(); + + await deleteIdentity(newIdentity.id); + }); + + test("Update identity", async () => { + const newIdentity = await createIdentity("mac1", OrgMembershipRole.Admin); + expect(newIdentity.name).toBe("mac1"); + expect(newIdentity.authMethod).toBeNull(); + + const updatedIdentity = await testServer.inject({ + method: "PATCH", + url: `/api/v1/identities/${newIdentity.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + name: "updated-mac-1", + role: OrgMembershipRole.Member + } + }); + + expect(updatedIdentity.statusCode).toBe(200); + expect(updatedIdentity.json().identity.name).toBe("updated-mac-1"); + + await deleteIdentity(newIdentity.id); + }); + + test("Delete Identity", async () => { + const newIdentity = await createIdentity("mac1", OrgMembershipRole.Admin); + + const deletedIdentity = await deleteIdentity(newIdentity.id); + expect(deletedIdentity.name).toBe("mac1"); + }); +}); diff --git a/backend/e2e-test/routes/v1/login.spec.ts b/backend/e2e-test/routes/v1/login.spec.ts index c95e1e016..cd6ec3194 100644 --- a/backend/e2e-test/routes/v1/login.spec.ts +++ b/backend/e2e-test/routes/v1/login.spec.ts @@ -1,6 +1,7 @@ -import { seedData1 } from "@app/db/seed-data"; import jsrp from "jsrp"; +import { seedData1 } from "@app/db/seed-data"; + describe("Login V1 Router", async () => { // eslint-disable-next-line const client = new jsrp.client(); diff --git a/backend/e2e-test/routes/v1/project-env.spec.ts b/backend/e2e-test/routes/v1/project-env.spec.ts index 936cfa859..ec06d6474 100644 --- a/backend/e2e-test/routes/v1/project-env.spec.ts +++ b/backend/e2e-test/routes/v1/project-env.spec.ts @@ -1,6 +1,40 @@ import { seedData1 } from "@app/db/seed-data"; import { DEFAULT_PROJECT_ENVS } from "@app/db/seeds/3-project"; +const createProjectEnvironment = async (name: string, slug: string) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/workspace/${seedData1.project.id}/environments`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + name, + slug + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("environment"); + return payload.environment; +}; + +const deleteProjectEnvironment = async (envId: string) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v1/workspace/${seedData1.project.id}/environments/${envId}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("environment"); + return payload.environment; +}; + describe("Project Environment Router", async () => { test("Get default environments", async () => { const res = await testServer.inject({ @@ -31,24 +65,10 @@ describe("Project Environment Router", async () => { expect(payload.workspace.environments.length).toBe(3); }); - const mockProjectEnv = { name: "temp", slug: "temp", id: "" }; // id will be filled in create op + const mockProjectEnv = { name: "temp", slug: "temp" }; // id will be filled in create op test("Create environment", async () => { - const res = await testServer.inject({ - method: "POST", - url: `/api/v1/workspace/${seedData1.project.id}/environments`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - name: mockProjectEnv.name, - slug: mockProjectEnv.slug - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("environment"); - expect(payload.environment).toEqual( + const newEnvironment = await createProjectEnvironment(mockProjectEnv.name, mockProjectEnv.slug); + expect(newEnvironment).toEqual( expect.objectContaining({ id: expect.any(String), name: mockProjectEnv.name, @@ -59,14 +79,15 @@ describe("Project Environment Router", async () => { updatedAt: expect.any(String) }) ); - mockProjectEnv.id = payload.environment.id; + await deleteProjectEnvironment(newEnvironment.id); }); test("Update environment", async () => { + const newEnvironment = await createProjectEnvironment(mockProjectEnv.name, mockProjectEnv.slug); const updatedName = { name: "temp#2", slug: "temp2" }; const res = await testServer.inject({ method: "PATCH", - url: `/api/v1/workspace/${seedData1.project.id}/environments/${mockProjectEnv.id}`, + url: `/api/v1/workspace/${seedData1.project.id}/environments/${newEnvironment.id}`, headers: { authorization: `Bearer ${jwtAuthToken}` }, @@ -82,7 +103,7 @@ describe("Project Environment Router", async () => { expect(payload).toHaveProperty("environment"); expect(payload.environment).toEqual( expect.objectContaining({ - id: expect.any(String), + id: newEnvironment.id, name: updatedName.name, slug: updatedName.slug, projectId: seedData1.project.id, @@ -91,61 +112,21 @@ describe("Project Environment Router", async () => { updatedAt: expect.any(String) }) ); - mockProjectEnv.name = updatedName.name; - mockProjectEnv.slug = updatedName.slug; + await deleteProjectEnvironment(newEnvironment.id); }); test("Delete environment", async () => { - const res = await testServer.inject({ - method: "DELETE", - url: `/api/v1/workspace/${seedData1.project.id}/environments/${mockProjectEnv.id}`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("environment"); - expect(payload.environment).toEqual( + const newEnvironment = await createProjectEnvironment(mockProjectEnv.name, mockProjectEnv.slug); + const deletedProjectEnvironment = await deleteProjectEnvironment(newEnvironment.id); + expect(deletedProjectEnvironment).toEqual( expect.objectContaining({ - id: expect.any(String), + id: deletedProjectEnvironment.id, name: mockProjectEnv.name, slug: mockProjectEnv.slug, - position: 1, + position: 4, createdAt: expect.any(String), updatedAt: expect.any(String) }) ); }); - - // after all these opreations the list of environment should be still same - test("Default list of environment", async () => { - const res = await testServer.inject({ - method: "GET", - url: `/api/v1/workspace/${seedData1.project.id}`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("workspace"); - // check for default environments - expect(payload).toEqual({ - workspace: expect.objectContaining({ - name: seedData1.project.name, - id: seedData1.project.id, - slug: seedData1.project.slug, - environments: expect.arrayContaining([ - expect.objectContaining(DEFAULT_PROJECT_ENVS[0]), - expect.objectContaining(DEFAULT_PROJECT_ENVS[1]), - expect.objectContaining(DEFAULT_PROJECT_ENVS[2]) - ]) - }) - }); - // ensure only two default environments exist - expect(payload.workspace.environments.length).toBe(3); - }); }); diff --git a/backend/e2e-test/routes/v1/secret-folder.spec.ts b/backend/e2e-test/routes/v1/secret-folder.spec.ts index bc290fed3..4d4bd7ab4 100644 --- a/backend/e2e-test/routes/v1/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v1/secret-folder.spec.ts @@ -1,5 +1,40 @@ import { seedData1 } from "@app/db/seed-data"; +const createFolder = async (dto: { path: string; name: string }) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: dto.name, + path: dto.path + } + }); + expect(res.statusCode).toBe(200); + return res.json().folder; +}; + +const deleteFolder = async (dto: { path: string; id: string }) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v1/folders/${dto.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + path: dto.path + } + }); + expect(res.statusCode).toBe(200); + return res.json().folder; +}; + describe("Secret Folder Router", async () => { test.each([ { name: "folder1", path: "/" }, // one in root @@ -7,30 +42,15 @@ describe("Secret Folder Router", async () => { { name: "folder2", path: "/" }, { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing ])("Create folder $name in $path", async ({ name, path }) => { - const res = await testServer.inject({ - method: "POST", - url: `/api/v1/folders`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - workspaceId: seedData1.project.id, - environment: seedData1.environment.slug, - name, - path - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("folder"); + const createdFolder = await createFolder({ path, name }); // check for default environments - expect(payload).toEqual({ - folder: expect.objectContaining({ + expect(createdFolder).toEqual( + expect.objectContaining({ name, id: expect.any(String) }) - }); + ); + await deleteFolder({ path, id: createdFolder.id }); }); test.each([ @@ -43,6 +63,8 @@ describe("Secret Folder Router", async () => { }, { path: "/level1/level2", expected: { folders: [{ name: "folder1" }], length: 1 } } ])("Get folders $path", async ({ path, expected }) => { + const newFolders = await Promise.all(expected.folders.map(({ name }) => createFolder({ name, path }))); + const res = await testServer.inject({ method: "GET", url: `/api/v1/folders`, @@ -59,36 +81,22 @@ describe("Secret Folder Router", async () => { expect(res.statusCode).toBe(200); const payload = JSON.parse(res.payload); expect(payload).toHaveProperty("folders"); - expect(payload.folders.length).toBe(expected.length); - expect(payload).toEqual({ folders: expected.folders.map((el) => expect.objectContaining(el)) }); - }); - - let toBeDeleteFolderId = ""; - test("Update a deep folder", async () => { - const res = await testServer.inject({ - method: "PATCH", - url: `/api/v1/folders/folder1`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - workspaceId: seedData1.project.id, - environment: seedData1.environment.slug, - name: "folder-updated", - path: "/level1/level2" - } + expect(payload.folders.length >= expected.folders.length).toBeTruthy(); + expect(payload).toEqual({ + folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el))) }); - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("folder"); - expect(payload.folder).toEqual( + await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id }))); + }); + + test("Update a deep folder", async () => { + const newFolder = await createFolder({ name: "folder-updated", path: "/level1/level2" }); + expect(newFolder).toEqual( expect.objectContaining({ id: expect.any(String), name: "folder-updated" }) ); - toBeDeleteFolderId = payload.folder.id; const resUpdatedFolders = await testServer.inject({ method: "GET", @@ -106,14 +114,16 @@ describe("Secret Folder Router", async () => { expect(resUpdatedFolders.statusCode).toBe(200); const updatedFolderList = JSON.parse(resUpdatedFolders.payload); expect(updatedFolderList).toHaveProperty("folders"); - expect(updatedFolderList.folders.length).toEqual(1); expect(updatedFolderList.folders[0].name).toEqual("folder-updated"); + + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); }); test("Delete a deep folder", async () => { + const newFolder = await createFolder({ name: "folder-updated", path: "/level1/level2" }); const res = await testServer.inject({ method: "DELETE", - url: `/api/v1/folders/${toBeDeleteFolderId}`, + url: `/api/v1/folders/${newFolder.id}`, headers: { authorization: `Bearer ${jwtAuthToken}` }, diff --git a/backend/e2e-test/routes/v1/secret-import.spec.ts b/backend/e2e-test/routes/v1/secret-import.spec.ts index f42c033c2..c184e44e5 100644 --- a/backend/e2e-test/routes/v1/secret-import.spec.ts +++ b/backend/e2e-test/routes/v1/secret-import.spec.ts @@ -1,32 +1,57 @@ import { seedData1 } from "@app/db/seed-data"; -describe("Secret Folder Router", async () => { +const createSecretImport = async (importPath: string, importEnv: string) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/secret-imports`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/", + import: { + environment: importEnv, + path: importPath + } + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("secretImport"); + return payload.secretImport; +}; + +const deleteSecretImport = async (id: string) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v1/secret-imports/${id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/" + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("secretImport"); + return payload.secretImport; +}; + +describe("Secret Import Router", async () => { test.each([ - { importEnv: "dev", importPath: "/" }, // one in root + { importEnv: "prod", importPath: "/" }, // one in root { importEnv: "staging", importPath: "/" } // then create a deep one creating intermediate ones ])("Create secret import $importEnv with path $importPath", async ({ importPath, importEnv }) => { - const res = await testServer.inject({ - method: "POST", - url: `/api/v1/secret-imports`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - workspaceId: seedData1.project.id, - environment: seedData1.environment.slug, - path: "/", - import: { - environment: importEnv, - path: importPath - } - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("secretImport"); // check for default environments - expect(payload.secretImport).toEqual( + const payload = await createSecretImport(importPath, importEnv); + expect(payload).toEqual( expect.objectContaining({ id: expect.any(String), importPath: expect.any(String), @@ -37,10 +62,12 @@ describe("Secret Folder Router", async () => { }) }) ); + await deleteSecretImport(payload.id); }); - let testSecretImportId = ""; test("Get secret imports", async () => { + const createdImport1 = await createSecretImport("/", "prod"); + const createdImport2 = await createSecretImport("/", "staging"); const res = await testServer.inject({ method: "GET", url: `/api/v1/secret-imports`, @@ -58,7 +85,6 @@ describe("Secret Folder Router", async () => { const payload = JSON.parse(res.payload); expect(payload).toHaveProperty("secretImports"); expect(payload.secretImports.length).toBe(2); - testSecretImportId = payload.secretImports[0].id; expect(payload.secretImports).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -72,12 +98,20 @@ describe("Secret Folder Router", async () => { }) ]) ); + await deleteSecretImport(createdImport1.id); + await deleteSecretImport(createdImport2.id); }); test("Update secret import position", async () => { - const res = await testServer.inject({ + const prodImportDetails = { path: "/", envSlug: "prod" }; + const stagingImportDetails = { path: "/", envSlug: "staging" }; + + const createdImport1 = await createSecretImport(prodImportDetails.path, prodImportDetails.envSlug); + const createdImport2 = await createSecretImport(stagingImportDetails.path, stagingImportDetails.envSlug); + + const updateImportRes = await testServer.inject({ method: "PATCH", - url: `/api/v1/secret-imports/${testSecretImportId}`, + url: `/api/v1/secret-imports/${createdImport1.id}`, headers: { authorization: `Bearer ${jwtAuthToken}` }, @@ -91,8 +125,8 @@ describe("Secret Folder Router", async () => { } }); - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); + expect(updateImportRes.statusCode).toBe(200); + const payload = JSON.parse(updateImportRes.payload); expect(payload).toHaveProperty("secretImport"); // check for default environments expect(payload.secretImport).toEqual( @@ -102,7 +136,7 @@ describe("Secret Folder Router", async () => { position: 2, importEnv: expect.objectContaining({ name: expect.any(String), - slug: expect.any(String), + slug: expect.stringMatching(prodImportDetails.envSlug), id: expect.any(String) }) }) @@ -124,28 +158,19 @@ describe("Secret Folder Router", async () => { expect(secretImportsListRes.statusCode).toBe(200); const secretImportList = JSON.parse(secretImportsListRes.payload); expect(secretImportList).toHaveProperty("secretImports"); - expect(secretImportList.secretImports[1].id).toEqual(testSecretImportId); + expect(secretImportList.secretImports[1].id).toEqual(createdImport1.id); + expect(secretImportList.secretImports[0].id).toEqual(createdImport2.id); + + await deleteSecretImport(createdImport1.id); + await deleteSecretImport(createdImport2.id); }); test("Delete secret import position", async () => { - const res = await testServer.inject({ - method: "DELETE", - url: `/api/v1/secret-imports/${testSecretImportId}`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - workspaceId: seedData1.project.id, - environment: seedData1.environment.slug, - path: "/" - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("secretImport"); + const createdImport1 = await createSecretImport("/", "prod"); + const createdImport2 = await createSecretImport("/", "staging"); + const deletedImport = await deleteSecretImport(createdImport1.id); // check for default environments - expect(payload.secretImport).toEqual( + expect(deletedImport).toEqual( expect.objectContaining({ id: expect.any(String), importPath: expect.any(String), @@ -175,5 +200,7 @@ describe("Secret Folder Router", async () => { expect(secretImportList).toHaveProperty("secretImports"); expect(secretImportList.secretImports.length).toEqual(1); expect(secretImportList.secretImports[0].position).toEqual(1); + + await deleteSecretImport(createdImport2.id); }); }); diff --git a/backend/e2e-test/routes/v2/service-token.spec.ts b/backend/e2e-test/routes/v2/service-token.spec.ts new file mode 100644 index 000000000..a07eda4b9 --- /dev/null +++ b/backend/e2e-test/routes/v2/service-token.spec.ts @@ -0,0 +1,579 @@ +import crypto from "node:crypto"; + +import { SecretType, TSecrets } from "@app/db/schemas"; +import { decryptSecret, encryptSecret, getUserPrivateKey, seedData1 } from "@app/db/seed-data"; +import { decryptAsymmetric, decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; + +const createServiceToken = async ( + scopes: { environment: string; secretPath: string }[], + permissions: ("read" | "write")[] +) => { + const projectKeyRes = await testServer.inject({ + method: "GET", + url: `/api/v2/workspace/${seedData1.project.id}/encrypted-key`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + const projectKeyEnc = JSON.parse(projectKeyRes.payload); + + const userInfoRes = await testServer.inject({ + method: "GET", + url: "/api/v2/users/me", + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + const { user: userInfo } = JSON.parse(userInfoRes.payload); + const privateKey = await getUserPrivateKey(seedData1.password, userInfo); + const projectKey = decryptAsymmetric({ + ciphertext: projectKeyEnc.encryptedKey, + nonce: projectKeyEnc.nonce, + publicKey: projectKeyEnc.sender.publicKey, + privateKey + }); + + const randomBytes = crypto.randomBytes(16).toString("hex"); + const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8(projectKey, randomBytes); + const serviceTokenRes = await testServer.inject({ + method: "POST", + url: "/api/v2/service-token", + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + name: "test-token", + workspaceId: seedData1.project.id, + scopes, + encryptedKey: ciphertext, + iv, + tag, + permissions, + expiresIn: null + } + }); + expect(serviceTokenRes.statusCode).toBe(200); + const serviceTokenInfo = serviceTokenRes.json(); + expect(serviceTokenInfo).toHaveProperty("serviceToken"); + expect(serviceTokenInfo).toHaveProperty("serviceTokenData"); + return `${serviceTokenInfo.serviceToken}.${randomBytes}`; +}; + +const deleteServiceToken = async () => { + const serviceTokenListRes = await testServer.inject({ + method: "GET", + url: `/api/v1/workspace/${seedData1.project.id}/service-token-data`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(serviceTokenListRes.statusCode).toBe(200); + const serviceTokens = JSON.parse(serviceTokenListRes.payload).serviceTokenData as { name: string; id: string }[]; + expect(serviceTokens.length).toBeGreaterThan(0); + const serviceTokenInfo = serviceTokens.find(({ name }) => name === "test-token"); + expect(serviceTokenInfo).toBeDefined(); + + const deleteTokenRes = await testServer.inject({ + method: "DELETE", + url: `/api/v2/service-token/${serviceTokenInfo?.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(deleteTokenRes.statusCode).toBe(200); +}; + +const createSecret = async (dto: { + projectKey: string; + path: string; + key: string; + value: string; + comment: string; + type?: SecretType; + token: string; +}) => { + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: dto.type || SecretType.Shared, + secretPath: dto.path, + ...encryptSecret(dto.projectKey, dto.key, dto.value, dto.comment) + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/${dto.key}`, + headers: { + authorization: `Bearer ${dto.token}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secret"); + return createdSecretPayload.secret; +}; + +const deleteSecret = async (dto: { path: string; key: string; token: string }) => { + const deleteSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/${dto.key}`, + headers: { + authorization: `Bearer ${dto.token}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: dto.path + } + }); + expect(deleteSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(deleteSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + return updatedSecretPayload.secret; +}; + +describe("Service token secret ops", async () => { + let serviceToken = ""; + let projectKey = ""; + let folderId = ""; + beforeAll(async () => { + serviceToken = await createServiceToken( + [{ secretPath: "/**", environment: seedData1.environment.slug }], + ["read", "write"] + ); + + // this is ensure cli service token decryptiong working fine + const serviceTokenInfoRes = await testServer.inject({ + method: "GET", + url: "/api/v2/service-token", + headers: { + authorization: `Bearer ${serviceToken}` + } + }); + expect(serviceTokenInfoRes.statusCode).toBe(200); + const serviceTokenInfo = serviceTokenInfoRes.json(); + const serviceTokenParts = serviceToken.split("."); + projectKey = decryptSymmetric128BitHexKeyUTF8({ + key: serviceTokenParts[3], + tag: serviceTokenInfo.tag, + ciphertext: serviceTokenInfo.encryptedKey, + iv: serviceTokenInfo.iv + }); + + // create a deep folder + const folderCreate = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder", + path: "/nested1/nested2" + } + }); + expect(folderCreate.statusCode).toBe(200); + folderId = folderCreate.json().folder.id; + }); + + afterAll(async () => { + await deleteServiceToken(); + + // create a deep folder + const deleteFolder = await testServer.inject({ + method: "DELETE", + url: `/api/v1/folders/${folderId}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/nested1/nested2" + } + }); + expect(deleteFolder.statusCode).toBe(200); + }); + + const testSecrets = [ + { + path: "/", + secret: { + key: "ST-SEC", + value: "something-secret", + comment: "some comment" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "NESTED-ST-SEC", + value: "something-secret", + comment: "some comment" + } + } + ]; + + const getSecrets = async (environment: string, secretPath = "/") => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + query: { + secretPath, + environment, + workspaceId: seedData1.project.id + } + }); + const secrets: TSecrets[] = JSON.parse(res.payload).secrets || []; + return secrets.map((el) => ({ ...decryptSecret(projectKey, el), type: el.type })); + }; + + test.each(testSecrets)("Create secret in path $path", async ({ secret, path }) => { + const createdSecret = await createSecret({ projectKey, path, ...secret, token: serviceToken }); + const decryptedSecret = decryptSecret(projectKey, createdSecret); + expect(decryptedSecret.key).toEqual(secret.key); + expect(decryptedSecret.value).toEqual(secret.value); + expect(decryptedSecret.comment).toEqual(secret.comment); + expect(decryptedSecret.version).toEqual(1); + + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: secret.value, + type: SecretType.Shared + }) + ]) + ); + await deleteSecret({ path, key: secret.key, token: serviceToken }); + }); + + test.each(testSecrets)("Get secret by name in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, path, ...secret, token: serviceToken }); + + const getSecByNameRes = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + query: { + secretPath: path, + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug + } + }); + expect(getSecByNameRes.statusCode).toBe(200); + const getSecretByNamePayload = JSON.parse(getSecByNameRes.payload); + expect(getSecretByNamePayload).toHaveProperty("secret"); + const decryptedSecret = decryptSecret(projectKey, getSecretByNamePayload.secret); + expect(decryptedSecret.key).toEqual(secret.key); + expect(decryptedSecret.value).toEqual(secret.value); + expect(decryptedSecret.comment).toEqual(secret.comment); + + await deleteSecret({ path, key: secret.key, token: serviceToken }); + }); + + test.each(testSecrets)("Update secret in path $path", async ({ path, secret }) => { + await createSecret({ projectKey, path, ...secret, token: serviceToken }); + const updateSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretPath: path, + ...encryptSecret(projectKey, secret.key, "new-value", secret.comment) + }; + const updateSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + body: updateSecretReqBody + }); + expect(updateSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(updateSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + const decryptedSecret = decryptSecret(projectKey, updatedSecretPayload.secret); + expect(decryptedSecret.key).toEqual(secret.key); + expect(decryptedSecret.value).toEqual("new-value"); + expect(decryptedSecret.comment).toEqual(secret.comment); + + // list secret should have updated value + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: "new-value", + type: SecretType.Shared + }) + ]) + ); + + await deleteSecret({ path, key: secret.key, token: serviceToken }); + }); + + test.each(testSecrets)("Delete secret in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, path, ...secret, token: serviceToken }); + const deletedSecret = await deleteSecret({ path, key: secret.key, token: serviceToken }); + const decryptedSecret = decryptSecret(projectKey, deletedSecret); + expect(decryptedSecret.key).toEqual(secret.key); + + // shared secret deletion should delete personal ones also + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + key: secret.key, + type: SecretType.Shared + }) + ]) + ); + }); + + test.each(testSecrets)("Bulk create secrets in path $path", async ({ secret, path }) => { + const createSharedSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}`, + ...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, secret.value, secret.comment) + })) + } + }); + expect(createSharedSecRes.statusCode).toBe(200); + const createSharedSecPayload = JSON.parse(createSharedSecRes.payload); + expect(createSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + key: `BULK-${secret.key}-${i + 1}`, + type: SecretType.Shared + }) + ) + ) + ); + + await Promise.all( + Array.from(Array(5)).map((_e, i) => + deleteSecret({ path, token: serviceToken, key: `BULK-${secret.key}-${i + 1}` }) + ) + ); + }); + + test.each(testSecrets)("Bulk create fail on existing secret in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, ...secret, key: `BULK-${secret.key}-1`, path, token: serviceToken }); + + const createSharedSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}`, + ...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, secret.value, secret.comment) + })) + } + }); + expect(createSharedSecRes.statusCode).toBe(400); + + await deleteSecret({ path, key: `BULK-${secret.key}-1`, token: serviceToken }); + }); + + test.each(testSecrets)("Bulk update secrets in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => + createSecret({ projectKey, token: serviceToken, ...secret, key: `BULK-${secret.key}-${i + 1}`, path }) + ) + ); + + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}`, + ...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, "update-value", secret.comment) + })) + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + key: `BULK-${secret.key}-${i + 1}`, + value: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all( + Array.from(Array(5)).map((_e, i) => + deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}`, token: serviceToken }) + ) + ); + }); + + test.each(testSecrets)("Bulk delete secrets in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => + createSecret({ projectKey, token: serviceToken, ...secret, key: `BULK-${secret.key}-${i + 1}`, path }) + ) + ); + + const deletedSharedSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${serviceToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}` + })) + } + }); + + expect(deletedSharedSecRes.statusCode).toBe(200); + const deletedSecretPayload = JSON.parse(deletedSharedSecRes.payload); + expect(deletedSecretPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.not.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + key: `BULK-${secret.value}-${i + 1}`, + type: SecretType.Shared + }) + ) + ) + ); + }); +}); + +describe("Service token fail cases", async () => { + test("Unauthorized secret path access", async () => { + const serviceToken = await createServiceToken( + [{ secretPath: "/", environment: seedData1.environment.slug }], + ["read", "write"] + ); + const fetchSecrets = await testServer.inject({ + method: "GET", + url: "/api/v3/secrets", + query: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: "/nested/deep" + }, + headers: { + authorization: `Bearer ${serviceToken}` + } + }); + expect(fetchSecrets.statusCode).toBe(401); + expect(fetchSecrets.json().error).toBe("PermissionDenied"); + await deleteServiceToken(); + }); + + test("Unauthorized secret environment access", async () => { + const serviceToken = await createServiceToken( + [{ secretPath: "/", environment: seedData1.environment.slug }], + ["read", "write"] + ); + const fetchSecrets = await testServer.inject({ + method: "GET", + url: "/api/v3/secrets", + query: { + workspaceId: seedData1.project.id, + environment: "prod", + secretPath: "/" + }, + headers: { + authorization: `Bearer ${serviceToken}` + } + }); + expect(fetchSecrets.statusCode).toBe(401); + expect(fetchSecrets.json().error).toBe("PermissionDenied"); + await deleteServiceToken(); + }); + + test("Unauthorized write operation", async () => { + const serviceToken = await createServiceToken( + [{ secretPath: "/", environment: seedData1.environment.slug }], + ["read"] + ); + const writeSecrets = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/NEW`, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretPath: "/", + // doesn't matter project key because this will fail before that due to read only access + ...encryptSecret(crypto.randomBytes(16).toString("hex"), "NEW", "value", "") + }, + headers: { + authorization: `Bearer ${serviceToken}` + } + }); + expect(writeSecrets.statusCode).toBe(401); + expect(writeSecrets.json().error).toBe("PermissionDenied"); + + // but read access should still work fine + const fetchSecrets = await testServer.inject({ + method: "GET", + url: "/api/v3/secrets", + query: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: "/" + }, + headers: { + authorization: `Bearer ${serviceToken}` + } + }); + expect(fetchSecrets.statusCode).toBe(200); + await deleteServiceToken(); + }); +}); diff --git a/backend/e2e-test/routes/v3/secrets.spec.ts b/backend/e2e-test/routes/v3/secrets.spec.ts index e69de29bb..e7e271279 100644 --- a/backend/e2e-test/routes/v3/secrets.spec.ts +++ b/backend/e2e-test/routes/v3/secrets.spec.ts @@ -0,0 +1,1115 @@ +import { SecretType, TSecrets } from "@app/db/schemas"; +import { decryptSecret, encryptSecret, getUserPrivateKey, seedData1 } from "@app/db/seed-data"; +import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const createSecret = async (dto: { + projectKey: string; + path: string; + key: string; + value: string; + comment: string; + type?: SecretType; +}) => { + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: dto.type || SecretType.Shared, + secretPath: dto.path, + ...encryptSecret(dto.projectKey, dto.key, dto.value, dto.comment) + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/${dto.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secret"); + return createdSecretPayload.secret; +}; + +const deleteSecret = async (dto: { path: string; key: string }) => { + const deleteSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/${dto.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: dto.path + } + }); + expect(deleteSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(deleteSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + return updatedSecretPayload.secret; +}; + +describe("Secret V3 Router", async () => { + const secretTestCases = [ + { + path: "/", + secret: { + key: "SEC1", + value: "something-secret", + comment: "some comment" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "NESTED-SEC1", + value: "something-secret", + comment: "some comment" + } + }, + { + path: "/", + secret: { + key: "secret-key-2", + value: `-----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCa6eeFk+cMVqFn + hoVQDYgn2Ptp5Azysr2UPq6P73pCL9BzUtOXKZROqDyGehzzfg3wE2KdYU1Jk5Uq + fP0ZOWDIlM2SaVCSI3FW32o5+ZiggjpqcVdLFc/PS0S/ZdSmpPd8h11iO2brtIAI + ugTW8fcKlGSNUwx9aFmE7A6JnTRliTxB1l6QaC+YAwTK39VgeVH2gDSWC407aS15 + QobAkaBKKmFkzB5D7i2ZJwt+uXJV/rbLmyDmtnw0lubciGn7NX9wbYef180fisqT + aPNAz0nPKk0fFH2Wd5MZixNGbrrpDA+FCYvI5doThZyT2hpj08qWP07oXXCAqw46 + IEupNSILAgMBAAECggEBAIJb5KzeaiZS3B3O8G4OBQ5rJB3WfyLYUHnoSWLsBbie + nc392/ovThLmtZAAQE6SO85Tsb93+t64Z2TKqv1H8G658UeMgfWIB78v4CcLJ2mi + TN/3opqXrzjkQOTDHzBgT7al/mpETHZ6fOdbCemK0fVALGFUioUZg4M8VXtuI4Jw + q28jAyoRKrCrzda4BeQ553NZ4G5RvwhX3O2I8B8upTbt5hLcisBKy8MPLYY5LUFj + YKAP+raf6QLliP6KYHuVxUlgzxjLTxVG41etcyqqZF+foyiKBO3PU3n8oh++tgQP + ExOxiR0JSkBG5b+oOBD0zxcvo3/SjBHn0dJOZCSU2SkCgYEAyCe676XnNyBZMRD7 + 6trsaoiCWBpA6M8H44+x3w4cQFtqV38RyLy60D+iMKjIaLqeBbnay61VMzo24Bz3 + EuF2n4+9k/MetLJ0NCw8HmN5k0WSMD2BFsJWG8glVbzaqzehP4tIclwDTYc1jQVt + IoV2/iL7HGT+x2daUwbU5kN5hK0CgYEAxiLB+fmjxJW7VY4SHDLqPdpIW0q/kv4K + d/yZBrCX799vjmFb9vLh7PkQUfJhMJ/ttJOd7EtT3xh4mfkBeLfHwVU0d/ahbmSH + UJu/E9ZGxAW3PP0kxHZtPrLKQwBnfq8AxBauIhR3rPSorQTIOKtwz1jMlHFSUpuL + 3KeK2YfDYJcCgYEAkQnJOlNcAuRb/WQzSHIvktssqK8NjiZHryy3Vc0hx7j2jES2 + HGI2dSVHYD9OSiXA0KFm3OTTsnViwm/60iGzFdjRJV6tR39xGUVcoyCuPnvRfUd0 + PYvBXgxgkYpyYlPDcwp5CvWGJy3tLi1acgOIwIuUr3S38sL//t4adGk8q1kCgYB8 + Jbs1Tl53BvrimKpwUNbE+sjrquJu0A7vL68SqgQJoQ7dP9PH4Ff/i+/V6PFM7mib + BQOm02wyFbs7fvKVGVJoqWK+6CIucX732x7W5yRgHtS5ukQXdbzt1Ek3wkEW98Cb + HTruz7RNAt/NyXlLSODeit1lBbx3Vk9EaxZtRsv88QKBgGn7JwXgez9NOyobsNIo + QVO80rpUeenSjuFi+R0VmbLKe/wgAQbYJ0xTAsQ0btqViMzB27D6mJyC+KUIwWNX + MN8a+m46v4kqvZkKL2c4gmDibyURNe/vCtCHFuanJS/1mo2tr4XDyEeiuK52eTd9 + omQDpP86RX/hIIQ+JyLSaWYa + -----END PRIVATE KEY-----`, + comment: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "secret-key-3", + value: `-----BEGIN PRIVATE KEY----- + MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCa6eeFk+cMVqFn + hoVQDYgn2Ptp5Azysr2UPq6P73pCL9BzUtOXKZROqDyGehzzfg3wE2KdYU1Jk5Uq + fP0ZOWDIlM2SaVCSI3FW32o5+ZiggjpqcVdLFc/PS0S/ZdSmpPd8h11iO2brtIAI + ugTW8fcKlGSNUwx9aFmE7A6JnTRliTxB1l6QaC+YAwTK39VgeVH2gDSWC407aS15 + QobAkaBKKmFkzB5D7i2ZJwt+uXJV/rbLmyDmtnw0lubciGn7NX9wbYef180fisqT + aPNAz0nPKk0fFH2Wd5MZixNGbrrpDA+FCYvI5doThZyT2hpj08qWP07oXXCAqw46 + IEupNSILAgMBAAECggEBAIJb5KzeaiZS3B3O8G4OBQ5rJB3WfyLYUHnoSWLsBbie + nc392/ovThLmtZAAQE6SO85Tsb93+t64Z2TKqv1H8G658UeMgfWIB78v4CcLJ2mi + TN/3opqXrzjkQOTDHzBgT7al/mpETHZ6fOdbCemK0fVALGFUioUZg4M8VXtuI4Jw + q28jAyoRKrCrzda4BeQ553NZ4G5RvwhX3O2I8B8upTbt5hLcisBKy8MPLYY5LUFj + YKAP+raf6QLliP6KYHuVxUlgzxjLTxVG41etcyqqZF+foyiKBO3PU3n8oh++tgQP + ExOxiR0JSkBG5b+oOBD0zxcvo3/SjBHn0dJOZCSU2SkCgYEAyCe676XnNyBZMRD7 + 6trsaoiCWBpA6M8H44+x3w4cQFtqV38RyLy60D+iMKjIaLqeBbnay61VMzo24Bz3 + EuF2n4+9k/MetLJ0NCw8HmN5k0WSMD2BFsJWG8glVbzaqzehP4tIclwDTYc1jQVt + IoV2/iL7HGT+x2daUwbU5kN5hK0CgYEAxiLB+fmjxJW7VY4SHDLqPdpIW0q/kv4K + d/yZBrCX799vjmFb9vLh7PkQUfJhMJ/ttJOd7EtT3xh4mfkBeLfHwVU0d/ahbmSH + UJu/E9ZGxAW3PP0kxHZtPrLKQwBnfq8AxBauIhR3rPSorQTIOKtwz1jMlHFSUpuL + 3KeK2YfDYJcCgYEAkQnJOlNcAuRb/WQzSHIvktssqK8NjiZHryy3Vc0hx7j2jES2 + HGI2dSVHYD9OSiXA0KFm3OTTsnViwm/60iGzFdjRJV6tR39xGUVcoyCuPnvRfUd0 + PYvBXgxgkYpyYlPDcwp5CvWGJy3tLi1acgOIwIuUr3S38sL//t4adGk8q1kCgYB8 + Jbs1Tl53BvrimKpwUNbE+sjrquJu0A7vL68SqgQJoQ7dP9PH4Ff/i+/V6PFM7mib + BQOm02wyFbs7fvKVGVJoqWK+6CIucX732x7W5yRgHtS5ukQXdbzt1Ek3wkEW98Cb + HTruz7RNAt/NyXlLSODeit1lBbx3Vk9EaxZtRsv88QKBgGn7JwXgez9NOyobsNIo + QVO80rpUeenSjuFi+R0VmbLKe/wgAQbYJ0xTAsQ0btqViMzB27D6mJyC+KUIwWNX + MN8a+m46v4kqvZkKL2c4gmDibyURNe/vCtCHFuanJS/1mo2tr4XDyEeiuK52eTd9 + omQDpP86RX/hIIQ+JyLSaWYa + -----END PRIVATE KEY-----`, + comment: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "secret-key-3", + value: + "TG9yZW0gaXBzdW0gZG9sb3Igc2l0IGFtZXQsIGNvbnNlY3RldHVyIGFkaXBpc2NpbmcgZWxpdC4gU2VkIGRvIGVpdXNtb2QgdGVtcG9yIGluY2lkaWR1bnQgdXQgbGFib3JlIGV0IGRvbG9yZSBtYWduYSBhbGlxdWEuIFV0IGVuaW0gYWQgbWluaW0gdmVuaWFtLCBxdWlzIG5vc3RydWQgZXhlcmNpdGF0aW9uCg==", + comment: "" + } + } + ]; + + let projectKey = ""; + let folderId = ""; + beforeAll(async () => { + const projectKeyRes = await testServer.inject({ + method: "GET", + url: `/api/v2/workspace/${seedData1.project.id}/encrypted-key`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + const projectKeyEncryptionDetails = JSON.parse(projectKeyRes.payload); + + const userInfoRes = await testServer.inject({ + method: "GET", + url: "/api/v2/users/me", + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + const { user: userInfo } = JSON.parse(userInfoRes.payload); + const privateKey = await getUserPrivateKey(seedData1.password, userInfo); + projectKey = decryptAsymmetric({ + ciphertext: projectKeyEncryptionDetails.encryptedKey, + nonce: projectKeyEncryptionDetails.nonce, + publicKey: projectKeyEncryptionDetails.sender.publicKey, + privateKey + }); + + // create a deep folder + const folderCreate = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder", + path: "/nested1/nested2" + } + }); + expect(folderCreate.statusCode).toBe(200); + folderId = folderCreate.json().folder.id; + }); + + afterAll(async () => { + const deleteFolder = await testServer.inject({ + method: "DELETE", + url: `/api/v1/folders/${folderId}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/nested1/nested2" + } + }); + expect(deleteFolder.statusCode).toBe(200); + }); + + const getSecrets = async (environment: string, secretPath = "/") => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + query: { + secretPath, + environment, + workspaceId: seedData1.project.id + } + }); + const secrets: TSecrets[] = JSON.parse(res.payload).secrets || []; + return secrets.map((el) => ({ ...decryptSecret(projectKey, el), type: el.type })); + }; + + test.each(secretTestCases)("Create secret in path $path", async ({ secret, path }) => { + const createdSecret = await createSecret({ projectKey, path, ...secret }); + const decryptedSecret = decryptSecret(projectKey, createdSecret); + expect(decryptedSecret.key).toEqual(secret.key); + expect(decryptedSecret.value).toEqual(secret.value); + expect(decryptedSecret.comment).toEqual(secret.comment); + expect(decryptedSecret.version).toEqual(1); + + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: secret.value, + type: SecretType.Shared + }) + ]) + ); + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)("Get secret by name in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, path, ...secret }); + + const getSecByNameRes = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + query: { + secretPath: path, + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug + } + }); + expect(getSecByNameRes.statusCode).toBe(200); + const getSecretByNamePayload = JSON.parse(getSecByNameRes.payload); + expect(getSecretByNamePayload).toHaveProperty("secret"); + const decryptedSecret = decryptSecret(projectKey, getSecretByNamePayload.secret); + expect(decryptedSecret.key).toEqual(secret.key); + expect(decryptedSecret.value).toEqual(secret.value); + expect(decryptedSecret.comment).toEqual(secret.comment); + + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)( + "Creating personal secret without shared throw error in path $path", + async ({ secret }) => { + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Personal, + ...encryptSecret(projectKey, "SEC2", secret.value, secret.comment) + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/SEC2`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createSecretReqBody + }); + const payload = JSON.parse(createSecRes.payload); + expect(createSecRes.statusCode).toBe(400); + expect(payload.error).toEqual("BadRequest"); + expect(payload.message).toEqual("Failed to create personal secret override for no corresponding shared secret"); + } + ); + + test.each(secretTestCases)("Creating personal secret in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, path, ...secret }); + + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Personal, + secretPath: path, + ...encryptSecret(projectKey, secret.key, "personal-value", secret.comment) + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + + // list secrets should contain personal one and shared one + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: secret.value, + type: SecretType.Shared + }), + expect.objectContaining({ + key: secret.key, + value: "personal-value", + type: SecretType.Personal + }) + ]) + ); + + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)("Update secret in path $path", async ({ path, secret }) => { + await createSecret({ projectKey, path, ...secret }); + const updateSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretPath: path, + ...encryptSecret(projectKey, secret.key, "new-value", secret.comment) + }; + const updateSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/${secret.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: updateSecretReqBody + }); + expect(updateSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(updateSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + const decryptedSecret = decryptSecret(projectKey, updatedSecretPayload.secret); + expect(decryptedSecret.key).toEqual(secret.key); + expect(decryptedSecret.value).toEqual("new-value"); + expect(decryptedSecret.comment).toEqual(secret.comment); + + // list secret should have updated value + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: "new-value", + type: SecretType.Shared + }) + ]) + ); + + await deleteSecret({ path, key: secret.key }); + }); + + test.each(secretTestCases)("Delete secret in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, path, ...secret }); + const deletedSecret = await deleteSecret({ path, key: secret.key }); + const decryptedSecret = decryptSecret(projectKey, deletedSecret); + expect(decryptedSecret.key).toEqual(secret.key); + + // shared secret deletion should delete personal ones also + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + key: secret.key, + type: SecretType.Shared + }), + expect.objectContaining({ + key: secret.key, + type: SecretType.Personal + }) + ]) + ); + }); + + test.each(secretTestCases)( + "Deleting personal one should not delete shared secret in path $path", + async ({ secret, path }) => { + await createSecret({ projectKey, path, ...secret }); // shared one + await createSecret({ projectKey, path, ...secret, type: SecretType.Personal }); + + // shared secret deletion should delete personal ones also + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + type: SecretType.Shared + }), + expect.not.objectContaining({ + key: secret.key, + type: SecretType.Personal + }) + ]) + ); + await deleteSecret({ path, key: secret.key }); + } + ); + + test.each(secretTestCases)("Bulk create secrets in path $path", async ({ secret, path }) => { + const createSharedSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}`, + ...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, secret.value, secret.comment) + })) + } + }); + expect(createSharedSecRes.statusCode).toBe(200); + const createSharedSecPayload = JSON.parse(createSharedSecRes.payload); + expect(createSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + key: `BULK-${secret.key}-${i + 1}`, + type: SecretType.Shared + }) + ) + ) + ); + + await Promise.all(Array.from(Array(5)).map((_e, i) => deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}` }))); + }); + + test.each(secretTestCases)("Bulk create fail on existing secret in path $path", async ({ secret, path }) => { + await createSecret({ projectKey, ...secret, key: `BULK-${secret.key}-1`, path }); + + const createSharedSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}`, + ...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, secret.value, secret.comment) + })) + } + }); + expect(createSharedSecRes.statusCode).toBe(400); + + await deleteSecret({ path, key: `BULK-${secret.key}-1` }); + }); + + test.each(secretTestCases)("Bulk update secrets in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => + createSecret({ projectKey, ...secret, key: `BULK-${secret.key}-${i + 1}`, path }) + ) + ); + + const updateSharedSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}`, + ...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, "update-value", secret.comment) + })) + } + }); + expect(updateSharedSecRes.statusCode).toBe(200); + const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload); + expect(updateSharedSecPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + key: `BULK-${secret.key}-${i + 1}`, + value: "update-value", + type: SecretType.Shared + }) + ) + ) + ); + await Promise.all(Array.from(Array(5)).map((_e, i) => deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}` }))); + }); + + test.each(secretTestCases)("Bulk delete secrets in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => + createSecret({ projectKey, ...secret, key: `BULK-${secret.key}-${i + 1}`, path }) + ) + ); + + const deletedSharedSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/batch`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path, + secrets: Array.from(Array(5)).map((_e, i) => ({ + secretName: `BULK-${secret.key}-${i + 1}` + })) + } + }); + + expect(deletedSharedSecRes.statusCode).toBe(200); + const deletedSecretPayload = JSON.parse(deletedSharedSecRes.payload); + expect(deletedSecretPayload).toHaveProperty("secrets"); + + // bulk ones should exist + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.not.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ + key: `BULK-${secret.value}-${i + 1}`, + type: SecretType.Shared + }) + ) + ) + ); + }); +}); + +const createRawSecret = async (dto: { + path: string; + key: string; + value: string; + comment: string; + type?: SecretType; +}) => { + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: dto.type || SecretType.Shared, + secretValue: dto.value, + secretComment: dto.comment, + secretPath: dto.path + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/raw/${dto.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secret"); + return createdSecretPayload.secret; +}; + +const deleteRawSecret = async (dto: { path: string; key: string }) => { + const deleteSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/raw/${dto.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: dto.path + } + }); + expect(deleteSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(deleteSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + return updatedSecretPayload.secret; +}; + +// raw secret endpoints +describe.each([{ auth: AuthMode.JWT }, { auth: AuthMode.IDENTITY_ACCESS_TOKEN }])( + "Secret V3 Raw Router - $auth mode", + async ({ auth }) => { + let folderId = ""; + let authToken = ""; + const testRawSecrets = [ + { + path: "/", + secret: { + key: "RAW-SEC1", + value: "something-secret", + comment: "some comment" + } + }, + { + path: "/nested1/nested2/folder", + secret: { + key: "NESTED-RAW-SEC1", + value: "something-secret", + comment: "some comment" + } + } + ]; + + beforeAll(async () => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v2/workspace/${seedData1.project.id}/encrypted-key`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(res.statusCode).toEqual(200); + const projectKeyEnc = JSON.parse(res.payload); + + const userInfoRes = await testServer.inject({ + method: "GET", + url: "/api/v2/users/me", + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(userInfoRes.statusCode).toEqual(200); + const { user: userInfo } = JSON.parse(userInfoRes.payload); + + const privateKey = await getUserPrivateKey(seedData1.password, userInfo); + const projectKey = decryptAsymmetric({ + ciphertext: projectKeyEnc.encryptedKey, + nonce: projectKeyEnc.nonce, + publicKey: projectKeyEnc.sender.publicKey, + privateKey + }); + + const projectBotRes = await testServer.inject({ + method: "GET", + url: `/api/v1/bot/${seedData1.project.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(projectBotRes.statusCode).toEqual(200); + const projectBot = JSON.parse(projectBotRes.payload).bot; + const botKey = encryptAsymmetric(projectKey, projectBot.publicKey, privateKey); + + // set bot as active + const setBotActive = await testServer.inject({ + method: "PATCH", + url: `/api/v1/bot/${projectBot.id}/active`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + isActive: true, + workspaceId: seedData1.project.id, + botKey: { + encryptedKey: botKey.ciphertext, + nonce: botKey.nonce + } + } + }); + expect(setBotActive.statusCode).toEqual(200); + + // create a deep folder + const folderCreate = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder", + path: "/nested1/nested2" + } + }); + expect(folderCreate.statusCode).toBe(200); + folderId = folderCreate.json().folder.id; + + if (auth === AuthMode.JWT) { + authToken = jwtAuthToken; + } else if (auth === AuthMode.IDENTITY_ACCESS_TOKEN) { + const identityLogin = await testServer.inject({ + method: "POST", + url: "/api/v1/auth/universal-auth/login", + body: { + clientSecret: seedData1.machineIdentity.clientCredentials.secret, + clientId: seedData1.machineIdentity.clientCredentials.id + } + }); + expect(identityLogin.statusCode).toBe(200); + authToken = identityLogin.json().accessToken; + } + }); + + afterAll(async () => { + const projectBotRes = await testServer.inject({ + method: "GET", + url: `/api/v1/bot/${seedData1.project.id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + expect(projectBotRes.statusCode).toEqual(200); + const projectBot = JSON.parse(projectBotRes.payload).bot; + + // set bot as inactive + const setBotInActive = await testServer.inject({ + method: "PATCH", + url: `/api/v1/bot/${projectBot.id}/active`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + isActive: false, + workspaceId: seedData1.project.id + } + }); + expect(setBotInActive.statusCode).toEqual(200); + const deleteFolder = await testServer.inject({ + method: "DELETE", + url: `/api/v1/folders/${folderId}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + path: "/nested1/nested2" + } + }); + expect(deleteFolder.statusCode).toBe(200); + }); + + const getSecrets = async (environment: string, secretPath = "/") => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets/raw`, + headers: { + authorization: `Bearer ${authToken}` + }, + query: { + secretPath, + environment, + workspaceId: seedData1.project.id + } + }); + const secrets: { secretKey: string; secretValue: string; type: SecretType; version: number }[] = + JSON.parse(res.payload).secrets || []; + return secrets.map((el) => ({ key: el.secretKey, value: el.secretValue, type: el.type, version: el.version })); + }; + + test.each(testRawSecrets)("Create secret raw in path $path", async ({ secret, path }) => { + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretValue: secret.value, + secretComment: secret.comment, + secretPath: path + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secret"); + + // fetch secrets + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: secret.value, + type: SecretType.Shared + }) + ]) + ); + + await deleteRawSecret({ path, key: secret.key }); + }); + + test.each(testRawSecrets)("Get secret by name raw in path $path", async ({ secret, path }) => { + await createRawSecret({ path, ...secret }); + + const getSecByNameRes = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + query: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + secretPath: path + } + }); + expect(getSecByNameRes.statusCode).toBe(200); + const secretPayload = JSON.parse(getSecByNameRes.payload); + expect(secretPayload).toHaveProperty("secret"); + expect(secretPayload.secret).toEqual( + expect.objectContaining({ + secretKey: secret.key, + secretValue: secret.value + }) + ); + + await deleteRawSecret({ path, key: secret.key }); + }); + + test.each(testRawSecrets)("List secret raw in path $path", async ({ secret, path }) => { + await Promise.all( + Array.from(Array(5)).map((_e, i) => createRawSecret({ path, ...secret, key: `BULK-${secret.key}-${i + 1}` })) + ); + + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets.length).toEqual(5); + expect(secrets).toEqual( + expect.arrayContaining( + Array.from(Array(5)).map((_e, i) => + expect.objectContaining({ value: expect.any(String), key: `BULK-${secret.key}-${i + 1}` }) + ) + ) + ); + + await Promise.all( + Array.from(Array(5)).map((_e, i) => deleteRawSecret({ path, key: `BULK-${secret.key}-${i + 1}` })) + ); + }); + + test.each(testRawSecrets)("Update secret raw in path $path", async ({ secret, path }) => { + await createRawSecret({ path, ...secret }); + + const updateSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretValue: "new-value", + secretPath: path + }; + const updateSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: updateSecretReqBody + }); + expect(updateSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(updateSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + + // fetch secrets + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: "new-value", + version: 2, + type: SecretType.Shared + }) + ]) + ); + + await deleteRawSecret({ path, key: secret.key }); + }); + + test.each(testRawSecrets)("Delete secret raw in path $path", async ({ path, secret }) => { + await createRawSecret({ path, ...secret }); + + const deletedSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretPath: path + }; + const deletedSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: deletedSecretReqBody + }); + expect(deletedSecRes.statusCode).toBe(200); + const deletedSecretPayload = JSON.parse(deletedSecRes.payload); + expect(deletedSecretPayload).toHaveProperty("secret"); + + // fetch secrets + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual([]); + }); + + test.each(testRawSecrets)("Bulk create secret raw in path $path", async ({ path, secret }) => { + const createSecretReqBody = { + projectSlug: seedData1.project.slug, + environment: seedData1.environment.slug, + secretPath: path, + secrets: [ + { + secretKey: secret.key, + secretValue: secret.value, + secretComment: secret.comment + } + ] + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/batch/raw`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secrets"); + + // fetch secrets + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: secret.value, + type: SecretType.Shared + }) + ]) + ); + + await deleteRawSecret({ path, key: secret.key }); + }); + + test.each(testRawSecrets)("Bulk update secret raw in path $path", async ({ secret, path }) => { + await createRawSecret({ path, ...secret }); + const updateSecretReqBody = { + projectSlug: seedData1.project.slug, + environment: seedData1.environment.slug, + secretPath: path, + secrets: [ + { + secretValue: "new-value", + secretKey: secret.key + } + ] + }; + const updateSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/batch/raw`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: updateSecretReqBody + }); + expect(updateSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(updateSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secrets"); + + // fetch secrets + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: secret.key, + value: "new-value", + version: 2, + type: SecretType.Shared + }) + ]) + ); + + await deleteRawSecret({ path, key: secret.key }); + }); + + test.each(testRawSecrets)("Bulk delete secret raw in path $path", async ({ path, secret }) => { + await createRawSecret({ path, ...secret }); + + const deletedSecretReqBody = { + projectSlug: seedData1.project.slug, + environment: seedData1.environment.slug, + secretPath: path, + secrets: [{ secretKey: secret.key }] + }; + const deletedSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/batch/raw`, + headers: { + authorization: `Bearer ${authToken}` + }, + body: deletedSecretReqBody + }); + expect(deletedSecRes.statusCode).toBe(200); + const deletedSecretPayload = JSON.parse(deletedSecRes.payload); + expect(deletedSecretPayload).toHaveProperty("secrets"); + + // fetch secrets + const secrets = await getSecrets(seedData1.environment.slug, path); + expect(secrets).toEqual([]); + }); + } +); + +describe("Secret V3 Raw Router Without E2EE enabled", async () => { + const secret = { + key: "RAW-SEC-1", + value: "something-secret", + comment: "some comment" + }; + + test("Create secret raw", async () => { + const createSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretValue: secret.value, + secretComment: secret.comment + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(400); + }); + + test("Update secret raw", async () => { + const updateSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared, + secretValue: "new-value" + }; + const updateSecRes = await testServer.inject({ + method: "PATCH", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: updateSecretReqBody + }); + expect(updateSecRes.statusCode).toBe(400); + }); + + test("Delete secret raw", async () => { + const deletedSecretReqBody = { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + type: SecretType.Shared + }; + const deletedSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/raw/${secret.key}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: deletedSecretReqBody + }); + expect(deletedSecRes.statusCode).toBe(400); + }); +}); diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 1e424401e..09ab05443 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -1,26 +1,31 @@ -// import { main } from "@app/server/app"; -import { initEnvConfig } from "@app/lib/config/env"; +// eslint-disable-next-line +import "ts-node/register"; + import dotenv from "dotenv"; +import jwt from "jsonwebtoken"; import knex from "knex"; import path from "path"; -import { mockSmtpServer } from "./mocks/smtp"; -import { initLogger } from "@app/lib/logger"; -import jwt from "jsonwebtoken"; -import "ts-node/register"; -import { main } from "@app/server/app"; -import { mockQueue } from "./mocks/queue"; -import { AuthTokenType } from "@app/services/auth/auth-type"; import { seedData1 } from "@app/db/seed-data"; +import { initEnvConfig } from "@app/lib/config/env"; +import { initLogger } from "@app/lib/logger"; +import { main } from "@app/server/app"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; -dotenv.config({ path: path.join(__dirname, "../.env.test") }); +import { mockQueue } from "./mocks/queue"; +import { mockSmtpServer } from "./mocks/smtp"; +import { mockKeyStore } from "./mocks/keystore"; + +dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { name: "knex-env", transformMode: "ssr", async setup() { + const logger = await initLogger(); + const cfg = initEnvConfig(logger); const db = knex({ client: "pg", - connection: process.env.DB_CONNECTION_URI, + connection: cfg.DB_CONNECTION_URI, migrations: { directory: path.join(__dirname, "../src/db/migrations"), extension: "ts", @@ -37,9 +42,8 @@ export default { await db.seed.run(); const smtp = mockSmtpServer(); const queue = mockQueue(); - const logger = await initLogger(); - const cfg = initEnvConfig(logger); - const server = await main({ db, smtp, logger, queue }); + const keyStore = mockKeyStore(); + const server = await main({ db, smtp, logger, queue, keyStore }); // @ts-expect-error type globalThis.testServer = server; // @ts-expect-error type @@ -48,12 +52,15 @@ export default { authTokenType: AuthTokenType.ACCESS_TOKEN, userId: seedData1.id, tokenVersionId: seedData1.token.id, + authMethod: AuthMethod.EMAIL, + organizationId: seedData1.organization.id, accessVersion: 1 }, cfg.AUTH_SECRET, { expiresIn: cfg.JWT_AUTH_LIFETIME } ); } catch (error) { + console.log("[TEST] Error setting up environment", error); await db.destroy(); throw error; } diff --git a/backend/package-lock.json b/backend/package-lock.json index 1bce540c6..cdaee75f4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,18 +9,19 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@aws-sdk/client-secrets-manager": "^3.485.0", + "@aws-sdk/client-iam": "^3.525.0", + "@aws-sdk/client-secrets-manager": "^3.504.0", "@casl/ability": "^6.5.0", - "@fastify/cookie": "^9.2.0", - "@fastify/cors": "^8.4.1", + "@fastify/cookie": "^9.3.1", + "@fastify/cors": "^8.5.0", "@fastify/etag": "^5.1.0", "@fastify/formbody": "^7.4.0", "@fastify/helmet": "^11.1.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", "@fastify/session": "^10.7.0", - "@fastify/swagger": "^8.12.0", - "@fastify/swagger-ui": "^1.10.1", + "@fastify/swagger": "^8.14.0", + "@fastify/swagger-ui": "^2.1.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -29,40 +30,48 @@ "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", - "aws-sdk": "^2.1532.0", - "axios": "^1.6.2", + "aws-sdk": "^2.1553.0", + "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", - "bullmq": "^5.1.1", - "dotenv": "^16.3.1", - "fastify": "^4.24.3", + "bullmq": "^5.4.2", + "cassandra-driver": "^4.7.2", + "dotenv": "^16.4.1", + "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", + "google-auth-library": "^9.9.0", + "googleapis": "^137.1.0", "handlebars": "^4.7.8", "ioredis": "^5.3.2", "jmespath": "^0.16.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "knex": "^3.0.1", + "ldapjs": "^3.0.7", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", - "mysql2": "^3.6.5", + "ms": "^2.1.3", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", - "node-cache": "^5.1.2", - "nodemailer": "^6.9.7", + "nodemailer": "^6.9.9", + "ora": "^7.0.1", + "oracledb": "^6.4.0", "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", - "posthog-node": "^3.6.0", - "probot": "^12.3.3", + "posthog-node": "^3.6.2", + "probot": "^13.0.0", "smee-client": "^2.0.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", "uuid": "^9.0.1", "zod": "^3.22.4", - "zod-to-json-schema": "^3.22.0" + "zod-to-json-schema": "^3.22.4" }, "devDependencies": { "@types/bcrypt": "^5.0.2", @@ -101,7 +110,7 @@ "tsx": "^4.4.0", "typescript": "^5.3.2", "vite-tsconfig-paths": "^4.2.2", - "vitest": "^1.0.4" + "vitest": "^1.2.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -355,22 +364,6 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/core": { - "version": "3.496.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.496.0.tgz", - "integrity": "sha512-yT+ug7Cw/3eJi7x2es0+46x12+cIJm5Xv+GPWsrTFD1TKgqO/VPEgfDtHFagDNbFmjNQA65Ygc/kEdIX9ICX/A==", - "dependencies": { - "@smithy/core": "^1.3.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/signature-v4": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-sdk/client-cloudwatch-logs/node_modules/@aws-sdk/credential-provider-env": { "version": "3.496.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.496.0.tgz", @@ -675,50 +668,540 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.485.0.tgz", - "integrity": "sha512-TruRGEdTy1y/5ln1NcU5LvIZyK38O89zU9vCfNQIKwTSrpS0sDJQukjg8VfMC8gbqUUvXdiPcS61Fxr1WfWn7g==", + "node_modules/@aws-sdk/client-iam": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.525.0.tgz", + "integrity": "sha512-h705ebOYcgZWN9a4Pdkwd7DAPK4KTEGEFtXEj2FoaadBYBcR9r5yffm7umHZv9gOCHxFMaap8/NSZXirF2VHKg==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/client-sts": "3.485.0", - "@aws-sdk/core": "3.485.0", - "@aws-sdk/credential-provider-node": "3.485.0", - "@aws-sdk/middleware-host-header": "3.485.0", - "@aws-sdk/middleware-logger": "3.485.0", - "@aws-sdk/middleware-recursion-detection": "3.485.0", - "@aws-sdk/middleware-signing": "3.485.0", - "@aws-sdk/middleware-user-agent": "3.485.0", - "@aws-sdk/region-config-resolver": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@aws-sdk/util-endpoints": "3.485.0", - "@aws-sdk/util-user-agent-browser": "3.485.0", - "@aws-sdk/util-user-agent-node": "3.485.0", - "@smithy/config-resolver": "^2.0.23", - "@smithy/core": "^1.2.2", - "@smithy/fetch-http-handler": "^2.3.2", - "@smithy/hash-node": "^2.0.18", - "@smithy/invalid-dependency": "^2.0.16", - "@smithy/middleware-content-length": "^2.0.18", - "@smithy/middleware-endpoint": "^2.3.0", - "@smithy/middleware-retry": "^2.0.26", - "@smithy/middleware-serde": "^2.0.16", - "@smithy/middleware-stack": "^2.0.10", - "@smithy/node-config-provider": "^2.1.9", - "@smithy/node-http-handler": "^2.2.2", - "@smithy/protocol-http": "^3.0.12", - "@smithy/smithy-client": "^2.2.1", - "@smithy/types": "^2.8.0", - "@smithy/url-parser": "^2.0.16", - "@smithy/util-base64": "^2.0.1", - "@smithy/util-body-length-browser": "^2.0.1", - "@smithy/util-body-length-node": "^2.1.0", - "@smithy/util-defaults-mode-browser": "^2.0.24", - "@smithy/util-defaults-mode-node": "^2.0.32", - "@smithy/util-endpoints": "^1.0.8", - "@smithy/util-retry": "^2.0.9", - "@smithy/util-utf8": "^2.0.2", + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/credential-provider-node": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "@smithy/util-waiter": "^2.1.3", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.525.0.tgz", + "integrity": "sha512-6KwGQWFoNLH1UupdWPFdKPfTgjSz1kN8/r8aCzuvvXBe4Pz+iDUZ6FEJzGWNc9AapjvZDNO1hs23slomM9rTaA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.525.0.tgz", + "integrity": "sha512-zz13k/6RkjPSLmReSeGxd8wzGiiZa4Odr2Tv3wTcxClM4wOjD+zOgGv4Fe32b9AMqaueiCdjbvdu7AKcYxFA4A==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.525.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sts": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.525.0.tgz", + "integrity": "sha512-a8NUGRvO6rkfTZCbMaCsjDjLbERCwIUU9dIywFYcRgbFhkupJ7fSaZz3Het98U51M9ZbTEpaTa3fz0HaJv8VJw==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.525.0", + "@aws-sdk/middleware-host-header": "3.523.0", + "@aws-sdk/middleware-logger": "3.523.0", + "@aws-sdk/middleware-recursion-detection": "3.523.0", + "@aws-sdk/middleware-user-agent": "3.525.0", + "@aws-sdk/region-config-resolver": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@aws-sdk/util-user-agent-browser": "3.523.0", + "@aws-sdk/util-user-agent-node": "3.525.0", + "@smithy/config-resolver": "^2.1.4", + "@smithy/core": "^1.3.5", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/hash-node": "^2.1.3", + "@smithy/invalid-dependency": "^2.1.3", + "@smithy/middleware-content-length": "^2.1.3", + "@smithy/middleware-endpoint": "^2.4.4", + "@smithy/middleware-retry": "^2.1.4", + "@smithy/middleware-serde": "^2.1.3", + "@smithy/middleware-stack": "^2.1.3", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/url-parser": "^2.1.3", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.4", + "@smithy/util-defaults-mode-node": "^2.2.3", + "@smithy/util-endpoints": "^1.1.4", + "@smithy/util-middleware": "^2.1.3", + "@smithy/util-retry": "^2.1.3", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.525.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/core": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.525.0.tgz", + "integrity": "sha512-E3LtEtMWCriQOFZpVKpLYzbdw/v2PAOEAMhn2VRRZ1g0/g1TXzQrfhEU2yd8l/vQEJaCJ82ooGGg7YECviBUxA==", + "dependencies": { + "@smithy/core": "^1.3.5", + "@smithy/protocol-http": "^3.2.1", + "@smithy/signature-v4": "^2.1.3", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.523.0.tgz", + "integrity": "sha512-Y6DWdH6/OuMDoNKVzZlNeBc6f1Yjk1lYMjANKpIhMbkRCvLJw/PYZKOZa8WpXbTYdgg9XLjKybnLIb3ww3uuzA==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.525.0.tgz", + "integrity": "sha512-RNWQGuSBQZhl3iqklOslUEfQ4br1V3DCPboMpeqFtddUWJV3m2u2extFur9/4Uy+1EHVF120IwZUKtd8dF+ibw==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/fetch-http-handler": "^2.4.3", + "@smithy/node-http-handler": "^2.4.1", + "@smithy/property-provider": "^2.1.3", + "@smithy/protocol-http": "^3.2.1", + "@smithy/smithy-client": "^2.4.2", + "@smithy/types": "^2.10.1", + "@smithy/util-stream": "^2.1.3", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.525.0.tgz", + "integrity": "sha512-JDnccfK5JRb9jcgpc9lirL9PyCwGIqY0nKdw3LlX5WL5vTpTG4E1q7rLAlpNh7/tFD1n66Itarfv2tsyHMIqCw==", + "dependencies": { + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/credential-provider-env": "3.523.0", + "@aws-sdk/credential-provider-process": "3.523.0", + "@aws-sdk/credential-provider-sso": "3.525.0", + "@aws-sdk/credential-provider-web-identity": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/credential-provider-imds": "^2.2.3", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.525.0.tgz", + "integrity": "sha512-RJXlO8goGXpnoHQAyrCcJ0QtWEOFa34LSbfdqBIjQX/fwnjUuEmiGdXTV3AZmwYQ7juk49tfBneHbtOP3AGqsQ==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.523.0", + "@aws-sdk/credential-provider-http": "3.525.0", + "@aws-sdk/credential-provider-ini": "3.525.0", + "@aws-sdk/credential-provider-process": "3.523.0", + "@aws-sdk/credential-provider-sso": "3.525.0", + "@aws-sdk/credential-provider-web-identity": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/credential-provider-imds": "^2.2.3", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.523.0.tgz", + "integrity": "sha512-f0LP9KlFmMvPWdKeUKYlZ6FkQAECUeZMmISsv6NKtvPCI9e4O4cLTeR09telwDK8P0HrgcRuZfXM7E30m8re0Q==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.525.0.tgz", + "integrity": "sha512-7V7ybtufxdD3plxeIeB6aqHZeFIUlAyPphXIUgXrGY10iNcosL970rQPBeggsohe4gCM6UvY2TfMeEcr+ZE8FA==", + "dependencies": { + "@aws-sdk/client-sso": "3.525.0", + "@aws-sdk/token-providers": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.525.0.tgz", + "integrity": "sha512-sAukOjR1oKb2JXG4nPpuBFpSwGUhrrY17PG/xbTy8NAoLLhrqRwnErcLfdTfmj6tH+3094k6ws/Sh8a35ae7fA==", + "dependencies": { + "@aws-sdk/client-sts": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.523.0.tgz", + "integrity": "sha512-4g3q7Ta9sdD9TMUuohBAkbx/e3I/juTqfKi7TPgP+8jxcYX72MOsgemAMHuP6CX27eyj4dpvjH+w4SIVDiDSmg==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.523.0.tgz", + "integrity": "sha512-PeDNJNhfiaZx54LBaLTXzUaJ9LXFwDFFIksipjqjvxMafnoVcQwKbkoPUWLe5ytT4nnL1LogD3s55mERFUsnwg==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.523.0.tgz", + "integrity": "sha512-nZ3Vt7ehfSDYnrcg/aAfjjvpdE+61B3Zk68i6/hSUIegT3IH9H1vSW67NDKVp+50hcEfzWwM2HMPXxlzuyFyrw==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.525.0.tgz", + "integrity": "sha512-4al/6uO+t/QIYXK2OgqzDKQzzLAYJza1vWFS+S0lJ3jLNGyLB5BMU5KqWjDzevYZ4eCnz2Nn7z0FveUTNz8YdQ==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@aws-sdk/util-endpoints": "3.525.0", + "@smithy/protocol-http": "^3.2.1", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.525.0.tgz", + "integrity": "sha512-8kFqXk6UyKgTMi7N7QlhA6qM4pGPWbiUXqEY2RgUWngtxqNFGeM9JTexZeuavQI+qLLe09VPShPNX71fEDcM6w==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/types": "^2.10.1", + "@smithy/util-config-provider": "^2.2.1", + "@smithy/util-middleware": "^2.1.3", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/token-providers": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.525.0.tgz", + "integrity": "sha512-puVjbxuK0Dq7PTQ2HdddHy2eQjOH8GZbump74yWJa6JVpRW84LlOcNmP+79x4Kscvz2ldWB8XDFw/pcCiSDe5A==", + "dependencies": { + "@aws-sdk/client-sso-oidc": "3.525.0", + "@aws-sdk/types": "3.523.0", + "@smithy/property-provider": "^2.1.3", + "@smithy/shared-ini-file-loader": "^2.3.3", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/types": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.523.0.tgz", + "integrity": "sha512-AqGIu4u+SxPiUuNBp2acCVcq80KDUFjxe6e3cMTvKWTzCbrVk1AXv0dAaJnCmdkWIha6zJDWxpIk/aL4EGhZ9A==", + "dependencies": { + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.525.0.tgz", + "integrity": "sha512-DIW7WWU5tIGkeeKX6NJUyrEIdWMiqjLQG3XBzaUj+ufIENwNjdAHhlD8l2vX7Yr3JZRT6yN/84wBCj7Tw1xd1g==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/types": "^2.10.1", + "@smithy/util-endpoints": "^1.1.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.523.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.523.0.tgz", + "integrity": "sha512-6ZRNdGHX6+HQFqTbIA5+i8RWzxFyxsZv8D3soRfpdyWIKkzhSz8IyRKXRciwKBJDaC7OX2jzGE90wxRQft27nA==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/types": "^2.10.1", + "bowser": "^2.11.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.525.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.525.0.tgz", + "integrity": "sha512-88Wjt4efyUSBGcyIuh1dvoMqY1k15jpJc5A/3yi67clBQEFsu9QCodQCQPqmRjV3VRcMtBOk+jeCTiUzTY5dRQ==", + "dependencies": { + "@aws-sdk/types": "3.523.0", + "@smithy/node-config-provider": "^2.2.4", + "@smithy/types": "^2.10.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-secrets-manager": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.504.0.tgz", + "integrity": "sha512-JPwsYfQMjs5t74JmA4r1AjpiOG/LEw74d4a8vEdSy3pe2lhl/sSsxSdQtbI30wlJJramngtLNZjxn2+BGDphbg==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/credential-provider-node": "3.504.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-signing": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", "tslib": "^2.5.0", "uuid": "^8.3.2" }, @@ -726,6 +1209,58 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, "node_modules/@aws-sdk/client-secrets-manager/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -735,112 +1270,166 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.485.0.tgz", - "integrity": "sha512-apN2bEn0PZs0jD4jAfvwO3dlWqw9YIQJ6TAudM1bd3S5vzWqlBBcLfQpK6taHoQaI+WqgUWXLuOf7gRFbGXKPg==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.502.0.tgz", + "integrity": "sha512-OZAYal1+PQgUUtWiHhRayDtX0OD+XpXHKAhjYgEIPbyhQaCMp3/Bq1xDX151piWXvXqXLJHFKb8DUEqzwGO9QA==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/core": "3.485.0", - "@aws-sdk/middleware-host-header": "3.485.0", - "@aws-sdk/middleware-logger": "3.485.0", - "@aws-sdk/middleware-recursion-detection": "3.485.0", - "@aws-sdk/middleware-user-agent": "3.485.0", - "@aws-sdk/region-config-resolver": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@aws-sdk/util-endpoints": "3.485.0", - "@aws-sdk/util-user-agent-browser": "3.485.0", - "@aws-sdk/util-user-agent-node": "3.485.0", - "@smithy/config-resolver": "^2.0.23", - "@smithy/core": "^1.2.2", - "@smithy/fetch-http-handler": "^2.3.2", - "@smithy/hash-node": "^2.0.18", - "@smithy/invalid-dependency": "^2.0.16", - "@smithy/middleware-content-length": "^2.0.18", - "@smithy/middleware-endpoint": "^2.3.0", - "@smithy/middleware-retry": "^2.0.26", - "@smithy/middleware-serde": "^2.0.16", - "@smithy/middleware-stack": "^2.0.10", - "@smithy/node-config-provider": "^2.1.9", - "@smithy/node-http-handler": "^2.2.2", - "@smithy/protocol-http": "^3.0.12", - "@smithy/smithy-client": "^2.2.1", - "@smithy/types": "^2.8.0", - "@smithy/url-parser": "^2.0.16", - "@smithy/util-base64": "^2.0.1", - "@smithy/util-body-length-browser": "^2.0.1", - "@smithy/util-body-length-node": "^2.1.0", - "@smithy/util-defaults-mode-browser": "^2.0.24", - "@smithy/util-defaults-mode-node": "^2.0.32", - "@smithy/util-endpoints": "^1.0.8", - "@smithy/util-retry": "^2.0.9", - "@smithy/util-utf8": "^2.0.2", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", "tslib": "^2.5.0" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-sts": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.485.0.tgz", - "integrity": "sha512-PI4q36kVF0fpIPZyeQhrwwJZ6SRkOGvU3rX5Qn4b5UY5X+Ct1aLhqSX8/OB372UZIcnh6eSvERu8POHleDO7Jw==", + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.504.0.tgz", + "integrity": "sha512-ODA33/nm2srhV08EW0KZAP577UgV0qjyr7Xp2yEo8MXWL4ZqQZprk1c+QKBhjr4Djesrm0VPmSD/np0mtYP68A==", "dependencies": { "@aws-crypto/sha256-browser": "3.0.0", "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/core": "3.485.0", - "@aws-sdk/credential-provider-node": "3.485.0", - "@aws-sdk/middleware-host-header": "3.485.0", - "@aws-sdk/middleware-logger": "3.485.0", - "@aws-sdk/middleware-recursion-detection": "3.485.0", - "@aws-sdk/middleware-user-agent": "3.485.0", - "@aws-sdk/region-config-resolver": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@aws-sdk/util-endpoints": "3.485.0", - "@aws-sdk/util-user-agent-browser": "3.485.0", - "@aws-sdk/util-user-agent-node": "3.485.0", - "@smithy/config-resolver": "^2.0.23", - "@smithy/core": "^1.2.2", - "@smithy/fetch-http-handler": "^2.3.2", - "@smithy/hash-node": "^2.0.18", - "@smithy/invalid-dependency": "^2.0.16", - "@smithy/middleware-content-length": "^2.0.18", - "@smithy/middleware-endpoint": "^2.3.0", - "@smithy/middleware-retry": "^2.0.26", - "@smithy/middleware-serde": "^2.0.16", - "@smithy/middleware-stack": "^2.0.10", - "@smithy/node-config-provider": "^2.1.9", - "@smithy/node-http-handler": "^2.2.2", - "@smithy/protocol-http": "^3.0.12", - "@smithy/smithy-client": "^2.2.1", - "@smithy/types": "^2.8.0", - "@smithy/url-parser": "^2.0.16", - "@smithy/util-base64": "^2.0.1", - "@smithy/util-body-length-browser": "^2.0.1", - "@smithy/util-body-length-node": "^2.1.0", - "@smithy/util-defaults-mode-browser": "^2.0.24", - "@smithy/util-defaults-mode-node": "^2.0.32", - "@smithy/util-endpoints": "^1.0.8", - "@smithy/util-middleware": "^2.0.9", - "@smithy/util-retry": "^2.0.9", - "@smithy/util-utf8": "^2.0.2", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-signing": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", + "dependencies": { + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", "fast-xml-parser": "4.2.5", "tslib": "^2.5.0" }, "engines": { "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" } }, "node_modules/@aws-sdk/core": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.485.0.tgz", - "integrity": "sha512-Yvi80DQcbjkYCft471ClE3HuetuNVqntCs6eFOomDcrJaqdOFrXv2kJAxky84MRA/xb7bGlDGAPbTuj1ICputg==", + "version": "3.496.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.496.0.tgz", + "integrity": "sha512-yT+ug7Cw/3eJi7x2es0+46x12+cIJm5Xv+GPWsrTFD1TKgqO/VPEgfDtHFagDNbFmjNQA65Ygc/kEdIX9ICX/A==", "dependencies": { - "@smithy/core": "^1.2.2", - "@smithy/protocol-http": "^3.0.12", - "@smithy/signature-v4": "^2.0.0", - "@smithy/smithy-client": "^2.2.1", - "@smithy/types": "^2.8.0", + "@smithy/core": "^1.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/signature-v4": "^2.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -848,13 +1437,32 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.485.0.tgz", - "integrity": "sha512-3XkFgwVU1XOB33dV7t9BKJ/ptdl2iS+0dxE7ecq8aqT2/gsfKmLCae1G17P8WmdD3z0kMDTvnqM2aWgUnSOkmg==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.502.0.tgz", + "integrity": "sha512-KIB8Ae1Z7domMU/jU4KiIgK4tmYgvuXlhR54ehwlVHxnEoFPoPuGHFZU7oFn79jhhSLUFQ1lRYMxP0cEwb7XeQ==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.503.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.503.1.tgz", + "integrity": "sha512-rTdlFFGoPPFMF2YjtlfRuSgKI+XsF49u7d98255hySwhsbwd3Xp+utTTPquxP+CwDxMHbDlI7NxDzFiFdsoZug==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/util-stream": "^2.1.1", "tslib": "^2.5.0" }, "engines": { @@ -862,40 +1470,94 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.485.0.tgz", - "integrity": "sha512-cFYF/Bdw7EnT4viSxYpNIv3IBkri/Yb+JpQXl8uDq7bfVJfAN5qZmK07vRkg08xL6TC4F41wshhMSAucGdTwIw==", + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.504.0.tgz", + "integrity": "sha512-ODICLXfr8xTUd3wweprH32Ge41yuBa+u3j0JUcLdTUO1N9ldczSMdo8zOPlP0z4doqD3xbnqMkjNQWgN/Q+5oQ==", "dependencies": { - "@aws-sdk/credential-provider-env": "3.485.0", - "@aws-sdk/credential-provider-process": "3.485.0", - "@aws-sdk/credential-provider-sso": "3.485.0", - "@aws-sdk/credential-provider-web-identity": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@smithy/credential-provider-imds": "^2.0.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/shared-ini-file-loader": "^2.0.6", - "@smithy/types": "^2.8.0", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/credential-provider-env": "3.502.0", + "@aws-sdk/credential-provider-process": "3.502.0", + "@aws-sdk/credential-provider-sso": "3.504.0", + "@aws-sdk/credential-provider-web-identity": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/credential-provider-imds": "^2.2.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.485.0.tgz", - "integrity": "sha512-2DwzO2azkSzngifKDT61W/DL0tSzewuaFHiLJWdfc8Et3mdAQJ9x3KAj8u7XFpjIcGNqk7FiKjN+zeGUuNiEhA==", + "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", "dependencies": { - "@aws-sdk/credential-provider-env": "3.485.0", - "@aws-sdk/credential-provider-ini": "3.485.0", - "@aws-sdk/credential-provider-process": "3.485.0", - "@aws-sdk/credential-provider-sso": "3.485.0", - "@aws-sdk/credential-provider-web-identity": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@smithy/credential-provider-imds": "^2.0.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/shared-ini-file-loader": "^2.0.6", - "@smithy/types": "^2.8.0", + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.504.0.tgz", + "integrity": "sha512-6+V5hIh+tILmUjf2ZQWQINR3atxQVgH/bFrGdSR/sHSp/tEgw3m0xWL3IRslWU1e4/GtXrfg1iYnMknXy68Ikw==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.502.0", + "@aws-sdk/credential-provider-http": "3.503.1", + "@aws-sdk/credential-provider-ini": "3.504.0", + "@aws-sdk/credential-provider-process": "3.502.0", + "@aws-sdk/credential-provider-sso": "3.504.0", + "@aws-sdk/credential-provider-web-identity": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/credential-provider-imds": "^2.2.1", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -903,14 +1565,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.485.0.tgz", - "integrity": "sha512-X9qS6ZO/rDKYDgWqD1YmSX7sAUUHax9HbXlgGiTTdtfhZvQh1ZmnH6wiPu5WNliafHZFtZT2W07kgrDLPld/Ug==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.502.0.tgz", + "integrity": "sha512-fJJowOjQ4infYQX0E1J3xFVlmuwEYJAFk0Mo1qwafWmEthsBJs+6BR2RiWDELHKrSK35u4Pf3fu3RkYuCtmQFw==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/shared-ini-file-loader": "^2.0.6", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -918,16 +1580,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.485.0.tgz", - "integrity": "sha512-l0oC8GTrWh+LFQQfSmG1Jai1PX7Mhj9arb/CaS1/tmeZE0hgIXW++tvljYs/Dds4LGXUlaWG+P7BrObf6OyIXA==", + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.504.0.tgz", + "integrity": "sha512-4MgH2or2SjPzaxM08DCW+BjaX4DSsEGJlicHKmz6fh+w9JmLh750oXcTnbvgUeVz075jcs6qTKjvUcsdGM/t8Q==", "dependencies": { - "@aws-sdk/client-sso": "3.485.0", - "@aws-sdk/token-providers": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/shared-ini-file-loader": "^2.0.6", - "@smithy/types": "^2.8.0", + "@aws-sdk/client-sso": "3.502.0", + "@aws-sdk/token-providers": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -935,27 +1597,80 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.485.0.tgz", - "integrity": "sha512-WpBFZFE0iXtnibH5POMEKITj/hR0YV5l2n9p8BEvKjdJ63s3Xke1RN20ZdIyKDaRDwj8adnKDgNPEnAKdS4kLw==", + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.504.0.tgz", + "integrity": "sha512-L1ljCvGpIEFdJk087ijf2ohg7HBclOeB1UgBxUBBzf4iPRZTQzd2chGaKj0hm2VVaXz7nglswJeURH5PFcS5oA==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/types": "^2.8.0", + "@aws-sdk/client-sts": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.485.0.tgz", - "integrity": "sha512-1mAUX9dQNGo2RIKseVj7SI/D5abQJQ/Os8hQ0NyVAyyVYF+Yjx5PphKgfhM5yoBwuwZUl6q71XPYEGNx7be6SA==", + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/client-sts": { + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.504.0.tgz", + "integrity": "sha512-IESs8FkL7B/uY+ml4wgoRkrr6xYo4PizcNw6JX17eveq1gRBCPKeGMjE6HTDOcIYZZ8rqz/UeuH3JD4UhrMOnA==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/protocol-http": "^3.0.12", - "@smithy/types": "^2.8.0", + "@aws-crypto/sha256-browser": "3.0.0", + "@aws-crypto/sha256-js": "3.0.0", + "@aws-sdk/core": "3.496.0", + "@aws-sdk/middleware-host-header": "3.502.0", + "@aws-sdk/middleware-logger": "3.502.0", + "@aws-sdk/middleware-recursion-detection": "3.502.0", + "@aws-sdk/middleware-user-agent": "3.502.0", + "@aws-sdk/region-config-resolver": "3.502.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@aws-sdk/util-user-agent-browser": "3.502.0", + "@aws-sdk/util-user-agent-node": "3.502.0", + "@smithy/config-resolver": "^2.1.1", + "@smithy/core": "^1.3.1", + "@smithy/fetch-http-handler": "^2.4.1", + "@smithy/hash-node": "^2.1.1", + "@smithy/invalid-dependency": "^2.1.1", + "@smithy/middleware-content-length": "^2.1.1", + "@smithy/middleware-endpoint": "^2.4.1", + "@smithy/middleware-retry": "^2.1.1", + "@smithy/middleware-serde": "^2.1.1", + "@smithy/middleware-stack": "^2.1.1", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/node-http-handler": "^2.3.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/smithy-client": "^2.3.1", + "@smithy/types": "^2.9.1", + "@smithy/url-parser": "^2.1.1", + "@smithy/util-base64": "^2.1.1", + "@smithy/util-body-length-browser": "^2.1.1", + "@smithy/util-body-length-node": "^2.2.1", + "@smithy/util-defaults-mode-browser": "^2.1.1", + "@smithy/util-defaults-mode-node": "^2.1.1", + "@smithy/util-endpoints": "^1.1.1", + "@smithy/util-middleware": "^2.1.1", + "@smithy/util-retry": "^2.1.1", + "@smithy/util-utf8": "^2.1.1", + "fast-xml-parser": "4.2.5", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": "^3.504.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.502.0.tgz", + "integrity": "sha512-EjnG0GTYXT/wJBmm5/mTjDcAkzU8L7wQjOzd3FTXuTCNNyvAvwrszbOj5FlarEw5XJBbQiZtBs+I5u9+zy560w==", + "dependencies": { + "@aws-sdk/types": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -963,12 +1678,12 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.485.0.tgz", - "integrity": "sha512-O8IgJ0LHi5wTs5GlpI7nqmmSSagkVdd1shpGgQWY2h0kMSCII8CJZHBG97dlFFpGTvx5EDlhPNek7rl/6F4dRw==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.502.0.tgz", + "integrity": "sha512-FDyv6K4nCoHxbjLGS2H8ex8I0KDIiu4FJgVRPs140ZJy6gE5Pwxzv6YTzZGLMrnqcIs9gh065Lf6DjwMelZqaw==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -976,13 +1691,13 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.485.0.tgz", - "integrity": "sha512-ZeVNATGNFcqkWDut3luVszROTUzkU5u+rJpB/xmeMoenlDAjPRiHt/ca3WkI5wAnIJ1VSNGpD2sOFLMCH+EWag==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.502.0.tgz", + "integrity": "sha512-hvbyGJbxeuezxOu8VfFmcV4ql1hKXLxHTe5FNYfEBat2KaZXVhc1Hg+4TvB06/53p+E8J99Afmumkqbxs2esUA==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/protocol-http": "^3.0.12", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -990,16 +1705,16 @@ } }, "node_modules/@aws-sdk/middleware-signing": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.485.0.tgz", - "integrity": "sha512-41xzT2p1sOibhsLkdE5rwPJkNbBtKD8Gp36/ySfu0KE415wfXKacElSVxAaBw39/j7iSWDYqqybeEYbAzk+3GQ==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.502.0.tgz", + "integrity": "sha512-4hF08vSzJ7L6sB+393gOFj3s2N6nLusYS0XrMW6wYNFU10IDdbf8Z3TZ7gysDJJHEGQPmTAesPEDBsasGWcMxg==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/property-provider": "^2.0.0", - "@smithy/protocol-http": "^3.0.12", - "@smithy/signature-v4": "^2.0.0", - "@smithy/types": "^2.8.0", - "@smithy/util-middleware": "^2.0.9", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/protocol-http": "^3.1.1", + "@smithy/signature-v4": "^2.1.1", + "@smithy/types": "^2.9.1", + "@smithy/util-middleware": "^2.1.1", "tslib": "^2.5.0" }, "engines": { @@ -1007,14 +1722,14 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.485.0.tgz", - "integrity": "sha512-CddCVOn+OPQ0CcchketIg+WF6v+MDLAf3GOYTR2htUxxIm7HABuRd6R3kvQ5Jny9CV8gMt22G1UZITsFexSJlQ==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.502.0.tgz", + "integrity": "sha512-TxbBZbRiXPH0AUxegqiNd9aM9zNSbfjtBs5MEfcBsweeT/B2O7K1EjP9+CkB8Xmk/5FLKhAKLr19b1TNoE27rw==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@aws-sdk/util-endpoints": "3.485.0", - "@smithy/protocol-http": "^3.0.12", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@aws-sdk/util-endpoints": "3.502.0", + "@smithy/protocol-http": "^3.1.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -1022,14 +1737,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.485.0.tgz", - "integrity": "sha512-2FB2EQ0sIE+YgFqGtkE1lDIMIL6nYe6MkOHBwBM7bommadKIrbbr2L22bPZGs3ReTsxiJabjzxbuCAVhrpHmhg==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.502.0.tgz", + "integrity": "sha512-mxmsX2AGgnSM+Sah7mcQCIneOsJQNiLX0COwEttuf8eO+6cLMAZvVudH3BnWTfea4/A9nuri9DLCqBvEmPrilg==", "dependencies": { - "@smithy/node-config-provider": "^2.1.9", - "@smithy/types": "^2.8.0", - "@smithy/util-config-provider": "^2.1.0", - "@smithy/util-middleware": "^2.0.9", + "@aws-sdk/types": "3.502.0", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/types": "^2.9.1", + "@smithy/util-config-provider": "^2.2.1", + "@smithy/util-middleware": "^2.1.1", "tslib": "^2.5.0" }, "engines": { @@ -1037,46 +1753,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.485.0.tgz", - "integrity": "sha512-kOXA1WKIVIFNRqHL8ynVZ3hCKLsgnEmGr2iDR6agDNw5fYIlCO/6N2xR6QdGcLTvUUbwOlz4OvKLUQnWMKAnnA==", + "version": "3.504.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.504.0.tgz", + "integrity": "sha512-YIJWWsZi2ClUiILS1uh5L6VjmCUSTI6KKMuL9DkGjYqJ0aI6M8bd8fT9Wm7QmXCyjcArTgr/Atkhia4T7oKvzQ==", "dependencies": { - "@aws-crypto/sha256-browser": "3.0.0", - "@aws-crypto/sha256-js": "3.0.0", - "@aws-sdk/middleware-host-header": "3.485.0", - "@aws-sdk/middleware-logger": "3.485.0", - "@aws-sdk/middleware-recursion-detection": "3.485.0", - "@aws-sdk/middleware-user-agent": "3.485.0", - "@aws-sdk/region-config-resolver": "3.485.0", - "@aws-sdk/types": "3.485.0", - "@aws-sdk/util-endpoints": "3.485.0", - "@aws-sdk/util-user-agent-browser": "3.485.0", - "@aws-sdk/util-user-agent-node": "3.485.0", - "@smithy/config-resolver": "^2.0.23", - "@smithy/fetch-http-handler": "^2.3.2", - "@smithy/hash-node": "^2.0.18", - "@smithy/invalid-dependency": "^2.0.16", - "@smithy/middleware-content-length": "^2.0.18", - "@smithy/middleware-endpoint": "^2.3.0", - "@smithy/middleware-retry": "^2.0.26", - "@smithy/middleware-serde": "^2.0.16", - "@smithy/middleware-stack": "^2.0.10", - "@smithy/node-config-provider": "^2.1.9", - "@smithy/node-http-handler": "^2.2.2", - "@smithy/property-provider": "^2.0.0", - "@smithy/protocol-http": "^3.0.12", - "@smithy/shared-ini-file-loader": "^2.0.6", - "@smithy/smithy-client": "^2.2.1", - "@smithy/types": "^2.8.0", - "@smithy/url-parser": "^2.0.16", - "@smithy/util-base64": "^2.0.1", - "@smithy/util-body-length-browser": "^2.0.1", - "@smithy/util-body-length-node": "^2.1.0", - "@smithy/util-defaults-mode-browser": "^2.0.24", - "@smithy/util-defaults-mode-node": "^2.0.32", - "@smithy/util-endpoints": "^1.0.8", - "@smithy/util-retry": "^2.0.9", - "@smithy/util-utf8": "^2.0.2", + "@aws-sdk/client-sso-oidc": "3.504.0", + "@aws-sdk/types": "3.502.0", + "@smithy/property-provider": "^2.1.1", + "@smithy/shared-ini-file-loader": "^2.3.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -1084,11 +1769,11 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.485.0.tgz", - "integrity": "sha512-+QW32YQdvZRDOwrAQPo/qCyXoSjgXB6RwJwCwkd8ebJXRXw6tmGKIHaZqYHt/LtBymvnaBgBBADNa4+qFvlOFw==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.502.0.tgz", + "integrity": "sha512-M0DSPYe/gXhwD2QHgoukaZv5oDxhW3FfvYIrJptyqUq3OnPJBcDbihHjrE0PBtfh/9kgMZT60/fQ2NVFANfa2g==", "dependencies": { - "@smithy/types": "^2.8.0", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -1096,12 +1781,13 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.485.0.tgz", - "integrity": "sha512-dTd642F7nJisApF8YjniqQ6U59CP/DCtar11fXf1nG9YNBCBsNNVw5ZfZb5nSNzaIdy27mQioWTCV18JEj1mxg==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.502.0.tgz", + "integrity": "sha512-6LKFlJPp2J24r1Kpfoz5ESQn+1v5fEjDB3mtUKRdpwarhm3syu7HbKlHCF3KbcCOyahobvLvhoedT78rJFEeeg==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/util-endpoints": "^1.0.8", + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", + "@smithy/util-endpoints": "^1.1.1", "tslib": "^2.5.0" }, "engines": { @@ -1120,24 +1806,24 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.485.0.tgz", - "integrity": "sha512-QliWbjg0uOhGTcWgWTKPMY0SBi07g253DjwrCINT1auqDrdQPxa10xozpZExBYjAK2KuhYDNUzni127ae6MHOw==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.502.0.tgz", + "integrity": "sha512-v8gKyCs2obXoIkLETAeEQ3AM+QmhHhst9xbM1cJtKUGsRlVIak/XyyD+kVE6kmMm1cjfudHpHKABWk9apQcIZQ==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@smithy/types": "^2.9.1", "bowser": "^2.11.0", "tslib": "^2.5.0" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.485.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.485.0.tgz", - "integrity": "sha512-QF+aQ9jnDlPUlFBxBRqOylPf86xQuD3aEPpOErR+50qJawVvKa94uiAFdvtI9jv6hnRZmuFsTj2rsyytnbAYBA==", + "version": "3.502.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.502.0.tgz", + "integrity": "sha512-9RjxpkGZKbTdl96tIJvAo+vZoz4P/cQh36SBUt9xfRfW0BtsaLyvSrvlR5wyUYhvRcC12Axqh/8JtnAPq//+Vw==", "dependencies": { - "@aws-sdk/types": "3.485.0", - "@smithy/node-config-provider": "^2.1.9", - "@smithy/types": "^2.8.0", + "@aws-sdk/types": "3.502.0", + "@smithy/node-config-provider": "^2.2.1", + "@smithy/types": "^2.9.1", "tslib": "^2.5.0" }, "engines": { @@ -1183,6 +1869,22 @@ "node": ">=12" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -1655,21 +2357,21 @@ } }, "node_modules/@fastify/cookie": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.2.0.tgz", - "integrity": "sha512-fkg1yjjQRHPFAxSHeLC8CqYuNzvR6Lwlj/KjrzQcGjNBK+K82nW+UfCjfN71g1GkoVoc1GTOgIWkFJpcMfMkHQ==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-9.3.1.tgz", + "integrity": "sha512-h1NAEhB266+ZbZ0e9qUE6NnNR07i7DnNXWG9VbbZ8uC6O/hxHpl+Zoe5sw1yfdZ2U6XhToUGDnzQtWJdCaPwfg==", "dependencies": { "cookie-signature": "^1.1.0", "fastify-plugin": "^4.0.0" } }, "node_modules/@fastify/cors": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.4.1.tgz", - "integrity": "sha512-iYQJtrY3pFiDS5mo5zRaudzg2OcUdJ96PD6xfkKOOEilly5nnrFZx/W6Sce2T79xxlEn2qpU3t5+qS2phS369w==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz", + "integrity": "sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==", "dependencies": { "fastify-plugin": "^4.0.0", - "mnemonist": "0.39.5" + "mnemonist": "0.39.6" } }, "node_modules/@fastify/deepmerge": { @@ -1778,9 +2480,9 @@ } }, "node_modules/@fastify/swagger": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.12.0.tgz", - "integrity": "sha512-IMRc0xYuzRvtFDMuaWHyVbvM7CuAi0g3o2jaVgLDvETXPrXWAMWsHYR5niIdWBDPgGUq+soHkag1DKXyhPDB0w==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.14.0.tgz", + "integrity": "sha512-sGiznEb3rl6pKGGUZ+JmfI7ct5cwbTQGo+IjewaTvtzfrshnryu4dZwEsjw0YHABpBA+kCz3kpRaHB7qpa67jg==", "dependencies": { "fastify-plugin": "^4.0.0", "json-schema-resolver": "^2.0.0", @@ -1790,9 +2492,9 @@ } }, "node_modules/@fastify/swagger-ui": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-1.10.1.tgz", - "integrity": "sha512-u3EJqNKvVr3X+6jY5i6pbs6/tXCrSlqc2Y+PVjnHBTOGh/d36uHMz+z4jPFy9gie2my6iHUrAdM8itlVmoUjog==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-2.1.0.tgz", + "integrity": "sha512-mu0C28kMEQDa3miE8f3LmI/OQSmqaKS3dYhZVFO5y4JdgBIPbzZj6COCoRU/P/9nu7UogzzcCJtg89wwLwKtWg==", "dependencies": { "@fastify/static": "^6.0.0", "fastify-plugin": "^4.0.0", @@ -1967,6 +2669,83 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" + }, "node_modules/@lukeed/ms": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.1.tgz", @@ -2193,297 +2972,77 @@ } }, "node_modules/@octokit/auth-app": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-4.0.13.tgz", - "integrity": "sha512-NBQkmR/Zsc+8fWcVIFrwDgNXS7f4XDrkd9LHdi9DPQw1NdGHLviLzRO2ZBwTtepnwHXW5VTrVU9eFGijMUqllg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.0.3.tgz", + "integrity": "sha512-9N7IlBAKEJR3tJgPSubCxIDYGXSdc+2xbkjYpk9nCyqREnH8qEMoMhiEB1WgoA9yTFp91El92XNXAi+AjuKnfw==", "dependencies": { - "@octokit/auth-oauth-app": "^5.0.0", - "@octokit/auth-oauth-user": "^2.0.0", - "@octokit/request": "^6.0.0", - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0", + "@octokit/auth-oauth-app": "^7.0.0", + "@octokit/auth-oauth-user": "^4.0.0", + "@octokit/request": "^8.0.2", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", "deprecation": "^2.3.1", - "lru-cache": "^9.0.0", - "universal-github-app-jwt": "^1.1.1", + "lru-cache": "^10.0.0", + "universal-github-app-jwt": "^1.1.2", "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/endpoint": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", - "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", - "dependencies": { - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/openapi-types": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", - "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/request": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", - "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", - "dependencies": { - "@octokit/endpoint": "^7.0.0", - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "node-fetch": "^2.6.7", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/request-error": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", - "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", - "dependencies": { - "@octokit/types": "^9.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", - "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", - "dependencies": { - "@octokit/openapi-types": "^18.0.0" + "node": ">= 18" } }, "node_modules/@octokit/auth-app/node_modules/lru-cache": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.2.tgz", - "integrity": "sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", "engines": { "node": "14 || >=16.14" } }, "node_modules/@octokit/auth-oauth-app": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-5.0.6.tgz", - "integrity": "sha512-SxyfIBfeFcWd9Z/m1xa4LENTQ3l1y6Nrg31k2Dcb1jS5ov7pmwMJZ6OGX8q3K9slRgVpeAjNA1ipOAMHkieqyw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-7.0.1.tgz", + "integrity": "sha512-RE0KK0DCjCHXHlQBoubwlLijXEKfhMhKm9gO56xYvFmP1QTMb+vvwRPmQLLx0V+5AvV9N9I3lr1WyTzwL3rMDg==", "dependencies": { - "@octokit/auth-oauth-device": "^4.0.0", - "@octokit/auth-oauth-user": "^2.0.0", - "@octokit/request": "^6.0.0", - "@octokit/types": "^9.0.0", + "@octokit/auth-oauth-device": "^6.0.0", + "@octokit/auth-oauth-user": "^4.0.0", + "@octokit/request": "^8.0.2", + "@octokit/types": "^12.0.0", "@types/btoa-lite": "^1.0.0", "btoa-lite": "^1.0.0", "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/endpoint": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", - "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", - "dependencies": { - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/openapi-types": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", - "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/request": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", - "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", - "dependencies": { - "@octokit/endpoint": "^7.0.0", - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "node-fetch": "^2.6.7", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/request-error": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", - "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", - "dependencies": { - "@octokit/types": "^9.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", - "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", - "dependencies": { - "@octokit/openapi-types": "^18.0.0" + "node": ">= 18" } }, "node_modules/@octokit/auth-oauth-device": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-4.0.5.tgz", - "integrity": "sha512-XyhoWRTzf2ZX0aZ52a6Ew5S5VBAfwwx1QnC2Np6Et3MWQpZjlREIcbcvVZtkNuXp6Z9EeiSLSDUqm3C+aMEHzQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-6.0.1.tgz", + "integrity": "sha512-yxU0rkL65QkjbqQedgVx3gmW7YM5fF+r5uaSj9tM/cQGVqloXcqP2xK90eTyYvl29arFVCW8Vz4H/t47mL0ELw==", "dependencies": { - "@octokit/oauth-methods": "^2.0.0", - "@octokit/request": "^6.0.0", - "@octokit/types": "^9.0.0", + "@octokit/oauth-methods": "^4.0.0", + "@octokit/request": "^8.0.0", + "@octokit/types": "^12.0.0", "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/endpoint": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", - "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", - "dependencies": { - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/openapi-types": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", - "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/request": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", - "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", - "dependencies": { - "@octokit/endpoint": "^7.0.0", - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "node-fetch": "^2.6.7", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/request-error": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", - "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", - "dependencies": { - "@octokit/types": "^9.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", - "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", - "dependencies": { - "@octokit/openapi-types": "^18.0.0" + "node": ">= 18" } }, "node_modules/@octokit/auth-oauth-user": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-2.1.2.tgz", - "integrity": "sha512-kkRqNmFe7s5GQcojE3nSlF+AzYPpPv7kvP/xYEnE57584pixaFBH8Vovt+w5Y3E4zWUEOxjdLItmBTFAWECPAg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-4.0.1.tgz", + "integrity": "sha512-N94wWW09d0hleCnrO5wt5MxekatqEJ4zf+1vSe8MKMrhZ7gAXKFOKrDEZW2INltvBWJCyDUELgGRv8gfErH1Iw==", "dependencies": { - "@octokit/auth-oauth-device": "^4.0.0", - "@octokit/oauth-methods": "^2.0.0", - "@octokit/request": "^6.0.0", - "@octokit/types": "^9.0.0", + "@octokit/auth-oauth-device": "^6.0.0", + "@octokit/oauth-methods": "^4.0.0", + "@octokit/request": "^8.0.2", + "@octokit/types": "^12.0.0", "btoa-lite": "^1.0.0", "universal-user-agent": "^6.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/endpoint": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", - "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", - "dependencies": { - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/openapi-types": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", - "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/request": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", - "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", - "dependencies": { - "@octokit/endpoint": "^7.0.0", - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "node-fetch": "^2.6.7", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/request-error": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", - "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", - "dependencies": { - "@octokit/types": "^9.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", - "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", - "dependencies": { - "@octokit/openapi-types": "^18.0.0" + "node": ">= 18" } }, "node_modules/@octokit/auth-token": { @@ -2495,41 +3054,15 @@ } }, "node_modules/@octokit/auth-unauthenticated": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-3.0.5.tgz", - "integrity": "sha512-yH2GPFcjrTvDWPwJWWCh0tPPtTL5SMgivgKPA+6v/XmYN6hGQkAto8JtZibSKOpf8ipmeYhLNWQ2UgW0GYILCw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-5.0.1.tgz", + "integrity": "sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==", "dependencies": { - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0" + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/openapi-types": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", - "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" - }, - "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/request-error": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", - "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", - "dependencies": { - "@octokit/types": "^9.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", - "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", - "dependencies": { - "@octokit/openapi-types": "^18.0.0" + "node": ">= 18" } }, "node_modules/@octokit/core": { @@ -2575,81 +3108,26 @@ } }, "node_modules/@octokit/oauth-authorization-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-5.0.0.tgz", - "integrity": "sha512-y1WhN+ERDZTh0qZ4SR+zotgsQUE1ysKnvBt1hvDRB2WRzYtVKQjn97HEPzoehh66Fj9LwNdlZh+p6TJatT0zzg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz", + "integrity": "sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==", "engines": { - "node": ">= 14" + "node": ">= 18" } }, "node_modules/@octokit/oauth-methods": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-2.0.6.tgz", - "integrity": "sha512-l9Uml2iGN2aTWLZcm8hV+neBiFXAQ9+3sKiQe/sgumHlL6HDg0AQ8/l16xX/5jJvfxueqTW5CWbzd0MjnlfHZw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-4.0.1.tgz", + "integrity": "sha512-1NdTGCoBHyD6J0n2WGXg9+yDLZrRNZ0moTEex/LSPr49m530WNKcCfXDghofYptr3st3eTii+EHoG5k/o+vbtw==", "dependencies": { - "@octokit/oauth-authorization-url": "^5.0.0", - "@octokit/request": "^6.2.3", - "@octokit/request-error": "^3.0.3", - "@octokit/types": "^9.0.0", + "@octokit/oauth-authorization-url": "^6.0.2", + "@octokit/request": "^8.0.2", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", "btoa-lite": "^1.0.0" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/endpoint": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.6.tgz", - "integrity": "sha512-5L4fseVRUsDFGR00tMWD/Trdeeihn999rTMGRMC1G/Ldi1uWlWJzI98H4Iak5DB/RVvQuyMYKqSK/R6mbSOQyg==", - "dependencies": { - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/openapi-types": { - "version": "18.1.1", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-18.1.1.tgz", - "integrity": "sha512-VRaeH8nCDtF5aXWnjPuEMIYf1itK/s3JYyJcWFJT8X9pSNnBtriDf7wlEWsGuhPLl4QIH4xM8fqTXDwJ3Mu6sw==" - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/request": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.8.tgz", - "integrity": "sha512-ow4+pkVQ+6XVVsekSYBzJC0VTVvh/FCTUUgTsboGq+DTeWdyIFV8WSCdo0RIxk6wSkBTHqIK1mYuY7nOBXOchw==", - "dependencies": { - "@octokit/endpoint": "^7.0.0", - "@octokit/request-error": "^3.0.0", - "@octokit/types": "^9.0.0", - "is-plain-object": "^5.0.0", - "node-fetch": "^2.6.7", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/request-error": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.3.tgz", - "integrity": "sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ==", - "dependencies": { - "@octokit/types": "^9.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/types": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-9.3.2.tgz", - "integrity": "sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==", - "dependencies": { - "@octokit/openapi-types": "^18.0.0" + "node": ">= 18" } }, "node_modules/@octokit/openapi-types": { @@ -2658,35 +3136,15 @@ "integrity": "sha512-6G+ywGClliGQwRsjvqVYpklIfa7oRPA0vyhPQG/1Feh+B+wU0vGH1JiJ5T25d3g1JZYBHzR2qefLi9x8Gt+cpw==" }, "node_modules/@octokit/plugin-enterprise-compatibility": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-enterprise-compatibility/-/plugin-enterprise-compatibility-1.3.0.tgz", - "integrity": "sha512-h34sMGdEOER/OKrZJ55v26ntdHb9OPfR1fwOx6Q4qYyyhWA104o11h9tFxnS/l41gED6WEI41Vu2G2zHDVC5lQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-enterprise-compatibility/-/plugin-enterprise-compatibility-4.0.1.tgz", + "integrity": "sha512-d5cqeO0F/xZsTxOPOTYdw+0x8p+9GuTGGPj7oGj3y9vLluGnd7q97PTEzeJnOSERrhS4DguihQmrGu+7PhVP9Q==", "dependencies": { - "@octokit/request-error": "^2.1.0", - "@octokit/types": "^6.0.3" - } - }, - "node_modules/@octokit/plugin-enterprise-compatibility/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/@octokit/plugin-enterprise-compatibility/node_modules/@octokit/request-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", - "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", - "dependencies": { - "@octokit/types": "^6.0.3", - "deprecation": "^2.0.0", - "once": "^1.4.0" - } - }, - "node_modules/@octokit/plugin-enterprise-compatibility/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0" + }, + "engines": { + "node": ">= 18" } }, "node_modules/@octokit/plugin-paginate-rest": { @@ -2729,25 +3187,34 @@ } }, "node_modules/@octokit/plugin-retry": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-3.0.9.tgz", - "integrity": "sha512-r+fArdP5+TG6l1Rv/C9hVoty6tldw6cE2pRHNGmFPdyfrc696R6JjrQ3d7HdVqGwuzfyrcaLAKD7K8TX8aehUQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", + "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", "dependencies": { - "@octokit/types": "^6.0.3", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" } }, - "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "node_modules/@octokit/plugin-throttling": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.1.3.tgz", + "integrity": "sha512-pfyqaqpc0EXh5Cn4HX9lWYsZ4gGbjnSmUILeu4u2gnuM50K/wIk9s1Pxt3lVeVwekmITgN/nJdoh43Ka+vye8A==", "dependencies": { - "@octokit/openapi-types": "^12.11.0" + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5.0.0" } }, "node_modules/@octokit/request": { @@ -2800,53 +3267,36 @@ } }, "node_modules/@octokit/webhooks": { - "version": "9.26.3", - "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-9.26.3.tgz", - "integrity": "sha512-DLGk+gzeVq5oK89Bo601txYmyrelMQ7Fi5EnjHE0Xs8CWicy2xkmnJMKptKJrBJpstqbd/9oeDFi/Zj2pudBDQ==", + "version": "12.0.11", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-12.0.11.tgz", + "integrity": "sha512-YEQOb7v0TZ662nh5jsbY1CMgJyMajCEagKrHWC30LTCwCtnuIrLtEpE20vq4AtH0SuZI90+PtV66/Bnnw0jkvg==", "dependencies": { - "@octokit/request-error": "^2.0.2", - "@octokit/webhooks-methods": "^2.0.0", - "@octokit/webhooks-types": "5.8.0", + "@octokit/request-error": "^5.0.0", + "@octokit/webhooks-methods": "^4.0.0", + "@octokit/webhooks-types": "7.1.0", "aggregate-error": "^3.1.0" + }, + "engines": { + "node": ">= 18" } }, "node_modules/@octokit/webhooks-methods": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-2.0.0.tgz", - "integrity": "sha512-35cfQ4YWlnZnmZKmIxlGPUPLtbkF8lr/A/1Sk1eC0ddLMwQN06dOuLc+dI3YLQS+T+MoNt3DIQ0NynwgKPilig==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-4.0.0.tgz", + "integrity": "sha512-M8mwmTXp+VeolOS/kfRvsDdW+IO0qJ8kYodM/sAysk093q6ApgmBXwK1ZlUvAwXVrp/YVHp6aArj4auAxUAOFw==", + "engines": { + "node": ">= 18" + } }, "node_modules/@octokit/webhooks-types": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.3.1.tgz", "integrity": "sha512-u6355ZsZnHwmxen30SrqnYb1pXieBFkYgkNzt+Ed4Ao5tupN1OErHfzwiV6hq6duGkDAYASbq7/uVJQ69PjLEg==" }, - "node_modules/@octokit/webhooks/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/@octokit/webhooks/node_modules/@octokit/request-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", - "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", - "dependencies": { - "@octokit/types": "^6.0.3", - "deprecation": "^2.0.0", - "once": "^1.4.0" - } - }, - "node_modules/@octokit/webhooks/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, "node_modules/@octokit/webhooks/node_modules/@octokit/webhooks-types": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-5.8.0.tgz", - "integrity": "sha512-8adktjIb76A7viIdayQSFuBEwOzwhDC+9yxZpKNHjfzrlostHCw0/N7JWpWMObfElwvJMk2fY2l1noENCk9wmw==" + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.1.0.tgz", + "integrity": "sha512-y92CpG4kFFtBBjni8LHoV12IegJ+KFxLgKRengrVjKmGE5XMeCuGvlfRe75lTRrgXaG6XIWJlFpIDTlkoJsU8w==" }, "node_modules/@phc/format": { "version": "1.0.0", @@ -2884,15 +3334,17 @@ "integrity": "sha512-yVgyCdTyooGX6+czDLkJahEcwgBWZsKH9xbjvjDNVFjY3QtiI/tHRiB3zjgJCQMZehXxv2CFHZQSpWRXdr6CeQ==" }, "node_modules/@probot/octokit-plugin-config": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@probot/octokit-plugin-config/-/octokit-plugin-config-1.1.6.tgz", - "integrity": "sha512-L29wmnFvilzSfWn9tUgItxdLv0LJh2ICjma3FmLr80Spu3wZ9nHyRrKMo9R5/K2m7VuWmgoKnkgRt2zPzAQBEQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@probot/octokit-plugin-config/-/octokit-plugin-config-2.0.1.tgz", + "integrity": "sha512-aWQYzPY2xiKscTVTKveghtbglqZ+W4eBLIdK1C/cNiFIofy3AxKogWgEZj29PjIe5ZRYx0sRHAPc/pkcXyOmTQ==", "dependencies": { - "@types/js-yaml": "^4.0.5", "js-yaml": "^4.1.0" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { - "@octokit/core": ">=3" + "@octokit/core": ">=5" } }, "node_modules/@probot/pino": { @@ -2967,9 +3419,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.8.0.tgz", - "integrity": "sha512-zdTObFRoNENrdPpnTNnhOljYIcOX7aI7+7wyrSpPFFIOf/nRdedE6IYsjaBE7tjukphh1tMTojgJ7p3lKY8x6Q==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", + "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", "cpu": [ "arm" ], @@ -2980,9 +3432,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.8.0.tgz", - "integrity": "sha512-aiItwP48BiGpMFS9Znjo/xCNQVwTQVcRKkFKsO81m8exrGjHkCBDvm9PHay2kpa8RPnZzzKcD1iQ9KaLY4fPQQ==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", + "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", "cpu": [ "arm64" ], @@ -2993,9 +3445,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.8.0.tgz", - "integrity": "sha512-zhNIS+L4ZYkYQUjIQUR6Zl0RXhbbA0huvNIWjmPc2SL0cB1h5Djkcy+RZ3/Bwszfb6vgwUvcVJYD6e6Zkpsi8g==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", + "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", "cpu": [ "arm64" ], @@ -3006,9 +3458,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.8.0.tgz", - "integrity": "sha512-A/FAHFRNQYrELrb/JHncRWzTTXB2ticiRFztP4ggIUAfa9Up1qfW8aG2w/mN9jNiZ+HB0t0u0jpJgFXG6BfRTA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", + "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", "cpu": [ "x64" ], @@ -3019,9 +3471,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.8.0.tgz", - "integrity": "sha512-JsidBnh3p2IJJA4/2xOF2puAYqbaczB3elZDT0qHxn362EIoIkq7hrR43Xa8RisgI6/WPfvb2umbGsuvf7E37A==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", + "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", + "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", "cpu": [ "arm" ], @@ -3032,9 +3497,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.8.0.tgz", - "integrity": "sha512-hBNCnqw3EVCkaPB0Oqd24bv8SklETptQWcJz06kb9OtiShn9jK1VuTgi7o4zPSt6rNGWQOTDEAccbk0OqJmS+g==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", + "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", "cpu": [ "arm64" ], @@ -3045,9 +3510,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.8.0.tgz", - "integrity": "sha512-Fw9ChYfJPdltvi9ALJ9wzdCdxGw4wtq4t1qY028b2O7GwB5qLNSGtqMsAel1lfWTZvf4b6/+4HKp0GlSYg0ahA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", + "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", "cpu": [ "arm64" ], @@ -3057,10 +3522,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", + "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.8.0.tgz", - "integrity": "sha512-BH5xIh7tOzS9yBi8dFrCTG8Z6iNIGWGltd3IpTSKp6+pNWWO6qy8eKoRxOtwFbMrid5NZaidLYN6rHh9aB8bEw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", + "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", "cpu": [ "riscv64" ], @@ -3070,10 +3548,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", + "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.8.0.tgz", - "integrity": "sha512-PmvAj8k6EuWiyLbkNpd6BLv5XeYFpqWuRvRNRl80xVfpGXK/z6KYXmAgbI4ogz7uFiJxCnYcqyvZVD0dgFog7Q==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", + "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", "cpu": [ "x64" ], @@ -3084,9 +3575,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.8.0.tgz", - "integrity": "sha512-mdxnlW2QUzXwY+95TuxZ+CurrhgrPAMveDWI97EQlA9bfhR8tw3Pt7SUlc/eSlCNxlWktpmT//EAA8UfCHOyXg==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", + "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", "cpu": [ "x64" ], @@ -3097,9 +3588,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.8.0.tgz", - "integrity": "sha512-ge7saUz38aesM4MA7Cad8CHo0Fyd1+qTaqoIo+Jtk+ipBi4ATSrHWov9/S4u5pbEQmLjgUjB7BJt+MiKG2kzmA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", + "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", "cpu": [ "arm64" ], @@ -3110,9 +3601,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.8.0.tgz", - "integrity": "sha512-p9E3PZlzurhlsN5h9g7zIP1DnqKXJe8ZUkFwAazqSvHuWfihlIISPxG9hCHCoA+dOOspL/c7ty1eeEVFTE0UTw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", + "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", "cpu": [ "ia32" ], @@ -3123,9 +3614,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.8.0.tgz", - "integrity": "sha512-kb4/auKXkYKqlUYTE8s40FcJIj5soOyRLHKd4ugR0dCq0G2EfcF54eYcfQiGkHzjidZ40daB4ulsFdtqNKZtBg==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", + "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", "cpu": [ "x64" ], @@ -3327,72 +3818,72 @@ } }, "node_modules/@smithy/abort-controller": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.1.1.tgz", - "integrity": "sha512-1+qdrUqLhaALYL0iOcN43EP6yAXXQ2wWZ6taf4S2pNGowmOc5gx+iMQv+E42JizNJjB0+gEadOXeV1Bf7JWL1Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/config-resolver": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.1.1.tgz", - "integrity": "sha512-lxfLDpZm+AWAHPFZps5JfDoO9Ux1764fOgvRUBpHIO8HWHcSN1dkgsago1qLRVgm1BZ8RCm8cgv99QvtaOWIhw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-2.2.0.tgz", + "integrity": "sha512-fsiMgd8toyUba6n1WRmr+qACzXltpdDkPTAaDqc8QqPBUzO+/JKwL6bUBseHVi8tu9l+3JOK+tSf7cay+4B3LA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/types": "^2.9.1", - "@smithy/util-config-provider": "^2.2.1", - "@smithy/util-middleware": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/types": "^2.12.0", + "@smithy/util-config-provider": "^2.3.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.3.1.tgz", - "integrity": "sha512-tf+NIu9FkOh312b6M9G4D68is4Xr7qptzaZGZUREELF8ysE1yLKphqt7nsomjKZVwW7WE5pDDex9idowNGRQ/Q==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-1.4.2.tgz", + "integrity": "sha512-2fek3I0KZHWJlRLvRTqxTEri+qV0GRHrJIoLFuBMZB4EMg4WgeBGfF0X6abnrNYpq55KJ6R4D6x4f0vLnhzinA==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.1", - "@smithy/middleware-retry": "^2.1.1", - "@smithy/middleware-serde": "^2.1.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/util-middleware": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-retry": "^2.3.1", + "@smithy/middleware-serde": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/credential-provider-imds": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.2.1.tgz", - "integrity": "sha512-7XHjZUxmZYnONheVQL7j5zvZXga+EWNgwEAP6OPZTi7l8J4JTeNh9aIOfE5fKHZ/ee2IeNOh54ZrSna+Vc6TFA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-2.3.0.tgz", + "integrity": "sha512-BWB9mIukO1wjEOo1Ojgl6LrG4avcaC7T/ZP6ptmAaW4xluhSIPZhY+/PI5YKzlk+jsm+4sQZB45Bt1OfMeQa3w==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/property-provider": "^2.1.1", - "@smithy/types": "^2.9.1", - "@smithy/url-parser": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/property-provider": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/url-parser": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/eventstream-codec": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.1.1.tgz", - "integrity": "sha512-E8KYBxBIuU4c+zrpR22VsVrOPoEDzk35bQR3E+xm4k6Pa6JqzkDOdMyf9Atac5GPNKHJBdVaQ4JtjdWX2rl/nw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.1.3.tgz", + "integrity": "sha512-rGlCVuwSDv6qfKH4/lRxFjcZQnIE0LZ3D4lkMHg7ZSltK9rA74r0VuGSvWVQ4N/d70VZPaniFhp4Z14QYZsa+A==", "dependencies": { "@aws-crypto/crc32": "3.0.0", - "@smithy/types": "^2.9.1", + "@smithy/types": "^2.10.1", "@smithy/util-hex-encoding": "^2.1.1", "tslib": "^2.5.0" } @@ -3449,458 +3940,463 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.4.1.tgz", - "integrity": "sha512-VYGLinPsFqH68lxfRhjQaSkjXM7JysUOJDTNjHBuN/ykyRb2f1gyavN9+VhhPTWCy32L4yZ2fdhpCs/nStEicg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-2.5.0.tgz", + "integrity": "sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==", "dependencies": { - "@smithy/protocol-http": "^3.1.1", - "@smithy/querystring-builder": "^2.1.1", - "@smithy/types": "^2.9.1", - "@smithy/util-base64": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-base64": "^2.3.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/hash-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.1.1.tgz", - "integrity": "sha512-Qhoq0N8f2OtCnvUpCf+g1vSyhYQrZjhSwvJ9qvR8BUGOtTXiyv2x1OD2e6jVGmlpC4E4ax1USHoyGfV9JFsACg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-2.2.0.tgz", + "integrity": "sha512-zLWaC/5aWpMrHKpoDF6nqpNtBhlAYKF/7+9yMN7GpdR8CzohnWfGtMznPybnwSS8saaXBMxIGwJqR4HmRp6b3g==", "dependencies": { - "@smithy/types": "^2.9.1", - "@smithy/util-buffer-from": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/invalid-dependency": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.1.1.tgz", - "integrity": "sha512-7WTgnKw+VPg8fxu2v9AlNOQ5yaz6RA54zOVB4f6vQuR0xFKd+RzlCpt0WidYTsye7F+FYDIaS/RnJW4pxjNInw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-2.2.0.tgz", + "integrity": "sha512-nEDASdbKFKPXN2O6lOlTgrEEOO9NHIeO+HVvZnkqc8h5U9g3BIhWsvzFo+UcUbliMHvKNPD/zVxDrkP1Sbgp8Q==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/is-array-buffer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.1.1.tgz", - "integrity": "sha512-xozSQrcUinPpNPNPds4S7z/FakDTh1MZWtRP/2vQtYB/u3HYrX2UXuZs+VhaKBd6Vc7g2XPr2ZtwGBNDN6fNKQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-content-length": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.1.1.tgz", - "integrity": "sha512-rSr9ezUl9qMgiJR0UVtVOGEZElMdGFyl8FzWEF5iEKTlcWxGr2wTqGfDwtH3LAB7h+FPkxqv4ZU4cpuCN9Kf/g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-2.2.0.tgz", + "integrity": "sha512-5bl2LG1Ah/7E5cMSC+q+h3IpVHMeOkG0yLRyQT1p2aMJkSrZG7RlXHPuAgb7EyaFeidKEnnd/fNaLLaKlHGzDQ==", "dependencies": { - "@smithy/protocol-http": "^3.1.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/protocol-http": "^3.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-endpoint": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.4.1.tgz", - "integrity": "sha512-XPZTb1E2Oav60Ven3n2PFx+rX9EDsU/jSTA8VDamt7FXks67ekjPY/XrmmPDQaFJOTUHJNKjd8+kZxVO5Ael4Q==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-2.5.1.tgz", + "integrity": "sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==", "dependencies": { - "@smithy/middleware-serde": "^2.1.1", - "@smithy/node-config-provider": "^2.2.1", - "@smithy/shared-ini-file-loader": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/url-parser": "^2.1.1", - "@smithy/util-middleware": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/middleware-serde": "^2.3.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/shared-ini-file-loader": "^2.4.0", + "@smithy/types": "^2.12.0", + "@smithy/url-parser": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-retry": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.1.1.tgz", - "integrity": "sha512-eMIHOBTXro6JZ+WWzZWd/8fS8ht5nS5KDQjzhNMHNRcG5FkNTqcKpYhw7TETMYzbLfhO5FYghHy1vqDWM4FLDA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-2.3.1.tgz", + "integrity": "sha512-P2bGufFpFdYcWvqpyqqmalRtwFUNUA8vHjJR5iGqbfR6mp65qKOLcUd6lTr4S9Gn/enynSrSf3p3FVgVAf6bXA==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/service-error-classification": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/util-middleware": "^2.1.1", - "@smithy/util-retry": "^2.1.1", - "tslib": "^2.5.0", - "uuid": "^8.3.2" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/service-error-classification": "^2.1.5", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-retry": "^2.2.0", + "tslib": "^2.6.2", + "uuid": "^9.0.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@smithy/middleware-serde": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.1.1.tgz", - "integrity": "sha512-D8Gq0aQBeE1pxf3cjWVkRr2W54t+cdM2zx78tNrVhqrDykRA7asq8yVJij1u5NDtKzKqzBSPYh7iW0svUKg76g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-2.3.0.tgz", + "integrity": "sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/middleware-stack": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.1.1.tgz", - "integrity": "sha512-KPJhRlhsl8CjgGXK/DoDcrFGfAqoqvuwlbxy+uOO4g2Azn1dhH+GVfC3RAp+6PoL5PWPb+vt6Z23FP+Mr6qeCw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-2.2.0.tgz", + "integrity": "sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/node-config-provider": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.2.1.tgz", - "integrity": "sha512-epzK3x1xNxA9oJgHQ5nz+2j6DsJKdHfieb+YgJ7ATWxzNcB7Hc+Uya2TUck5MicOPhDV8HZImND7ZOecVr+OWg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-2.3.0.tgz", + "integrity": "sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==", "dependencies": { - "@smithy/property-provider": "^2.1.1", - "@smithy/shared-ini-file-loader": "^2.3.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/property-provider": "^2.2.0", + "@smithy/shared-ini-file-loader": "^2.4.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/node-http-handler": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.3.1.tgz", - "integrity": "sha512-gLA8qK2nL9J0Rk/WEZSvgin4AppvuCYRYg61dcUo/uKxvMZsMInL5I5ZdJTogOvdfVug3N2dgI5ffcUfS4S9PA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-2.5.0.tgz", + "integrity": "sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==", "dependencies": { - "@smithy/abort-controller": "^2.1.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/querystring-builder": "^2.1.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/abort-controller": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/querystring-builder": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/property-provider": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.1.1.tgz", - "integrity": "sha512-FX7JhhD/o5HwSwg6GLK9zxrMUrGnb3PzNBrcthqHKBc3dH0UfgEAU24xnJ8F0uow5mj17UeBEOI6o3CF2k7Mhw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-2.2.0.tgz", + "integrity": "sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/protocol-http": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.1.1.tgz", - "integrity": "sha512-6ZRTSsaXuSL9++qEwH851hJjUA0OgXdQFCs+VDw4tGH256jQ3TjYY/i34N4vd24RV3nrjNsgd1yhb57uMoKbzQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.3.0.tgz", + "integrity": "sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/querystring-builder": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.1.1.tgz", - "integrity": "sha512-C/ko/CeEa8jdYE4gt6nHO5XDrlSJ3vdCG0ZAc6nD5ZIE7LBp0jCx4qoqp7eoutBu7VrGMXERSRoPqwi1WjCPbg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-2.2.0.tgz", + "integrity": "sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==", "dependencies": { - "@smithy/types": "^2.9.1", - "@smithy/util-uri-escape": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "@smithy/util-uri-escape": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/querystring-parser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.1.1.tgz", - "integrity": "sha512-H4+6jKGVhG1W4CIxfBaSsbm98lOO88tpDWmZLgkJpt8Zkk/+uG0FmmqMuCAc3HNM2ZDV+JbErxr0l5BcuIf/XQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-2.2.0.tgz", + "integrity": "sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/service-error-classification": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.1.tgz", - "integrity": "sha512-txEdZxPUgM1PwGvDvHzqhXisrc5LlRWYCf2yyHfvITWioAKat7srQvpjMAvgzf0t6t7j8yHrryXU9xt7RZqFpw==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.5.tgz", + "integrity": "sha512-uBDTIBBEdAQryvHdc5W8sS5YX7RQzF683XrHePVdFmAgKiMofU15FLSM0/HU03hKTnazdNRFa0YHS7+ArwoUSQ==", "dependencies": { - "@smithy/types": "^2.9.1" + "@smithy/types": "^2.12.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.3.1.tgz", - "integrity": "sha512-2E2kh24igmIznHLB6H05Na4OgIEilRu0oQpYXo3LCNRrawHAcfDKq9004zJs+sAMt2X5AbY87CUCJ7IpqpSgdw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-2.4.0.tgz", + "integrity": "sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/signature-v4": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.1.1.tgz", - "integrity": "sha512-Hb7xub0NHuvvQD3YwDSdanBmYukoEkhqBjqoxo+bSdC0ryV9cTfgmNjuAQhTPYB6yeU7hTR+sPRiFMlxqv6kmg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.3.0.tgz", + "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", "dependencies": { - "@smithy/eventstream-codec": "^2.1.1", - "@smithy/is-array-buffer": "^2.1.1", - "@smithy/types": "^2.9.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-middleware": "^2.1.1", - "@smithy/util-uri-escape": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/is-array-buffer": "^2.2.0", + "@smithy/types": "^2.12.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-middleware": "^2.2.0", + "@smithy/util-uri-escape": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/smithy-client": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.3.1.tgz", - "integrity": "sha512-YsTdU8xVD64r2pLEwmltrNvZV6XIAC50LN6ivDopdt+YiF/jGH6PY9zUOu0CXD/d8GMB8gbhnpPsdrjAXHS9QA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-2.5.1.tgz", + "integrity": "sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==", "dependencies": { - "@smithy/middleware-endpoint": "^2.4.1", - "@smithy/middleware-stack": "^2.1.1", - "@smithy/protocol-http": "^3.1.1", - "@smithy/types": "^2.9.1", - "@smithy/util-stream": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/middleware-endpoint": "^2.5.1", + "@smithy/middleware-stack": "^2.2.0", + "@smithy/protocol-http": "^3.3.0", + "@smithy/types": "^2.12.0", + "@smithy/util-stream": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/types": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.9.1.tgz", - "integrity": "sha512-vjXlKNXyprDYDuJ7UW5iobdmyDm6g8dDG+BFUncAg/3XJaN45Gy5RWWWUVgrzIK7S4R1KWgIX5LeJcfvSI24bw==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/url-parser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.1.1.tgz", - "integrity": "sha512-qC9Bv8f/vvFIEkHsiNrUKYNl8uKQnn4BdhXl7VzQRP774AwIjiSMMwkbT+L7Fk8W8rzYVifzJNYxv1HwvfBo3Q==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-2.2.0.tgz", + "integrity": "sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==", "dependencies": { - "@smithy/querystring-parser": "^2.1.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/querystring-parser": "^2.2.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" } }, "node_modules/@smithy/util-base64": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.1.1.tgz", - "integrity": "sha512-UfHVpY7qfF/MrgndI5PexSKVTxSZIdz9InghTFa49QOvuu9I52zLPLUHXvHpNuMb1iD2vmc6R+zbv/bdMipR/g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-2.3.0.tgz", + "integrity": "sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==", "dependencies": { - "@smithy/util-buffer-from": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-body-length-browser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.1.1.tgz", - "integrity": "sha512-ekOGBLvs1VS2d1zM2ER4JEeBWAvIOUKeaFch29UjjJsxmZ/f0L3K3x0dEETgh3Q9bkZNHgT+rkdl/J/VUqSRag==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-2.2.0.tgz", + "integrity": "sha512-dtpw9uQP7W+n3vOtx0CfBD5EWd7EPdIdsQnWTDoFf77e3VUf05uA7R7TGipIo8e4WL2kuPdnsr3hMQn9ziYj5w==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" } }, "node_modules/@smithy/util-body-length-node": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.2.1.tgz", - "integrity": "sha512-/ggJG+ta3IDtpNVq4ktmEUtOkH1LW64RHB5B0hcr5ZaWBmo96UX2cIOVbjCqqDickTXqBWZ4ZO0APuaPrD7Abg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-2.3.0.tgz", + "integrity": "sha512-ITWT1Wqjubf2CJthb0BuT9+bpzBfXeMokH/AAa5EJQgbv9aPMVfnM76iFIZVFf50hYXGbtiV71BHAthNWd6+dw==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-buffer-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.1.1.tgz", - "integrity": "sha512-clhNjbyfqIv9Md2Mg6FffGVrJxw7bgK7s3Iax36xnfVj6cg0fUG7I4RH0XgXJF8bxi+saY5HR21g2UPKSxVCXg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "dependencies": { - "@smithy/is-array-buffer": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-config-provider": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.2.1.tgz", - "integrity": "sha512-50VL/tx9oYYcjJn/qKqNy7sCtpD0+s8XEBamIFo4mFFTclKMNp+rsnymD796uybjiIquB7VCB/DeafduL0y2kw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-2.3.0.tgz", + "integrity": "sha512-HZkzrRcuFN1k70RLqlNK4FnPXKOpkik1+4JaBoHNJn+RnJGYqaa3c5/+XtLOXhlKzlRgNvyaLieHTW2VwGN0VQ==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.1.1.tgz", - "integrity": "sha512-lqLz/9aWRO6mosnXkArtRuQqqZBhNpgI65YDpww4rVQBuUT7qzKbDLG5AmnQTCiU4rOquaZO/Kt0J7q9Uic7MA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.2.1.tgz", + "integrity": "sha512-RtKW+8j8skk17SYowucwRUjeh4mCtnm5odCL0Lm2NtHQBsYKrNW0od9Rhopu9wF1gHMfHeWF7i90NwBz/U22Kw==", "dependencies": { - "@smithy/property-provider": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", + "@smithy/property-provider": "^2.2.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", "bowser": "^2.11.0", - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.1.1.tgz", - "integrity": "sha512-tYVrc+w+jSBfBd267KDnvSGOh4NMz+wVH7v4CClDbkdPfnjvImBZsOURncT5jsFwR9KCuDyPoSZq4Pa6+eCUrA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.3.1.tgz", + "integrity": "sha512-vkMXHQ0BcLFysBMWgSBLSk3+leMpFSyyFj8zQtv5ZyUBx8/owVh1/pPEkzmW/DR/Gy/5c8vjLDD9gZjXNKbrpA==", "dependencies": { - "@smithy/config-resolver": "^2.1.1", - "@smithy/credential-provider-imds": "^2.2.1", - "@smithy/node-config-provider": "^2.2.1", - "@smithy/property-provider": "^2.1.1", - "@smithy/smithy-client": "^2.3.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/config-resolver": "^2.2.0", + "@smithy/credential-provider-imds": "^2.3.0", + "@smithy/node-config-provider": "^2.3.0", + "@smithy/property-provider": "^2.2.0", + "@smithy/smithy-client": "^2.5.1", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 10.0.0" } }, "node_modules/@smithy/util-endpoints": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.1.1.tgz", - "integrity": "sha512-sI4d9rjoaekSGEtq3xSb2nMjHMx8QXcz2cexnVyRWsy4yQ9z3kbDpX+7fN0jnbdOp0b3KSTZJZ2Yb92JWSanLw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-1.2.0.tgz", + "integrity": "sha512-BuDHv8zRjsE5zXd3PxFXFknzBG3owCpjq8G3FcsXW3CykYXuEqM3nTSsmLzw5q+T12ZYuDlVUZKBdpNbhVtlrQ==", "dependencies": { - "@smithy/node-config-provider": "^2.2.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/node-config-provider": "^2.3.0", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@smithy/util-hex-encoding": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.1.1.tgz", - "integrity": "sha512-3UNdP2pkYUUBGEXzQI9ODTDK+Tcu1BlCyDBaRHwyxhA+8xLP8agEKQq4MGmpjqb4VQAjq9TwlCQX0kP6XDKYLg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.2.0.tgz", + "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-middleware": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.1.1.tgz", - "integrity": "sha512-mKNrk8oz5zqkNcbcgAAepeJbmfUW6ogrT2Z2gDbIUzVzNAHKJQTYmH9jcy0jbWb+m7ubrvXKb6uMjkSgAqqsFA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.2.0.tgz", + "integrity": "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==", "dependencies": { - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-retry": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.1.1.tgz", - "integrity": "sha512-Mg+xxWPTeSPrthpC5WAamJ6PW4Kbo01Fm7lWM1jmGRvmrRdsd3192Gz2fBXAMURyXpaNxyZf6Hr/nQ4q70oVEA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-2.2.0.tgz", + "integrity": "sha512-q9+pAFPTfftHXRytmZ7GzLFFrEGavqapFc06XxzZFcSIGERXMerXxCitjOG1prVDR9QdjqotF40SWvbqcCpf8g==", "dependencies": { - "@smithy/service-error-classification": "^2.1.1", - "@smithy/types": "^2.9.1", - "tslib": "^2.5.0" + "@smithy/service-error-classification": "^2.1.5", + "@smithy/types": "^2.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@smithy/util-stream": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.1.1.tgz", - "integrity": "sha512-J7SMIpUYvU4DQN55KmBtvaMc7NM3CZ2iWICdcgaovtLzseVhAqFRYqloT3mh0esrFw+3VEK6nQFteFsTqZSECQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-2.2.0.tgz", + "integrity": "sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==", "dependencies": { - "@smithy/fetch-http-handler": "^2.4.1", - "@smithy/node-http-handler": "^2.3.1", - "@smithy/types": "^2.9.1", - "@smithy/util-base64": "^2.1.1", - "@smithy/util-buffer-from": "^2.1.1", - "@smithy/util-hex-encoding": "^2.1.1", - "@smithy/util-utf8": "^2.1.1", - "tslib": "^2.5.0" + "@smithy/fetch-http-handler": "^2.5.0", + "@smithy/node-http-handler": "^2.5.0", + "@smithy/types": "^2.12.0", + "@smithy/util-base64": "^2.3.0", + "@smithy/util-buffer-from": "^2.2.0", + "@smithy/util-hex-encoding": "^2.2.0", + "@smithy/util-utf8": "^2.3.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-uri-escape": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.1.1.tgz", - "integrity": "sha512-saVzI1h6iRBUVSqtnlOnc9ssU09ypo7n+shdQ8hBTZno/9rZ3AuRYvoHInV57VF7Qn7B+pFJG7qTzFiHxWlWBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.2.0.tgz", + "integrity": "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==", "dependencies": { - "tslib": "^2.5.0" + "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@smithy/util-utf8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.1.1.tgz", - "integrity": "sha512-BqTpzYEcUMDwAKr7/mVRUtHDhs6ZoXDi9NypMvMfOr/+u1NW7JgqodPDECiiLboEm6bobcPcECxzjtQh865e9A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "dependencies": { - "@smithy/util-buffer-from": "^2.1.1", + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-2.1.3.tgz", + "integrity": "sha512-3R0wNFAQQoH9e4m+bVLDYNOst2qNxtxFgq03WoNHWTBOqQT3jFnOBRj1W51Rf563xDA5kwqjziksxn6RKkHB+Q==", + "dependencies": { + "@smithy/abort-controller": "^2.1.3", + "@smithy/types": "^2.10.1", "tslib": "^2.5.0" }, "engines": { @@ -4207,6 +4703,12 @@ "@types/ms": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -4234,25 +4736,12 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, - "node_modules/@types/ioredis": { - "version": "4.28.10", - "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", - "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/jmespath": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", "dev": true }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4279,6 +4768,14 @@ "integrity": "sha512-2h3tFvkbHksiNcDiUdcJ08gXWG10fnahp30GJ2Tbt4vd4pfsbfkoKTaTbYykFoppaJ6DL3914nQ3PU1vVIlBRQ==", "dev": true }, + "node_modules/@types/ldapjs": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", + "integrity": "sha512-Lv/nD6QDCmcT+V1vaTRnEKE8UgOilVv5pHcQuzkU1LcRe4mbHHuUo/KHi0LKrpdHhQY8FJzryF38fcVdeUIrzg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/libsodium-wrappers": { "version": "0.7.13", "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.13.tgz", @@ -4300,6 +4797,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/long": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/long/-/long-5.0.0.tgz", + "integrity": "sha512-eQs9RsucA/LNjnMoJvWG/nXa7Pot/RbBzilF/QRIU/xRl+0ApxrSUFsV5lmf01SvSlqMzJ7Zwxe440wmz2SJGA==", + "deprecated": "This is a stub types definition. long provides its own type definitions, so you do not need this installed.", + "dependencies": { + "long": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4460,51 +4966,6 @@ "integrity": "sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==", "dev": true }, - "node_modules/@types/pino": { - "version": "6.3.12", - "resolved": "https://registry.npmjs.org/@types/pino/-/pino-6.3.12.tgz", - "integrity": "sha512-dsLRTq8/4UtVSpJgl9aeqHvbh6pzdmjYD3C092SYgLD2TyoCqHpTJk6vp8DvCTGGc7iowZ2MoiYiVUUCcu7muw==", - "dependencies": { - "@types/node": "*", - "@types/pino-pretty": "*", - "@types/pino-std-serializers": "*", - "sonic-boom": "^2.1.0" - } - }, - "node_modules/@types/pino-http": { - "version": "5.8.4", - "resolved": "https://registry.npmjs.org/@types/pino-http/-/pino-http-5.8.4.tgz", - "integrity": "sha512-UTYBQ2acmJ2eK0w58vVtgZ9RAicFFndfrnWC1w5cBTf8zwn/HEy8O+H7psc03UZgTzHmlcuX8VkPRnRDEj+FUQ==", - "dependencies": { - "@types/pino": "6.3" - } - }, - "node_modules/@types/pino-pretty": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/pino-pretty/-/pino-pretty-5.0.0.tgz", - "integrity": "sha512-N1uzqSzioqz8R3AkDbSJwcfDWeI3YMPNapSQQhnB2ISU4NYgUIcAh+hYT5ygqBM+klX4htpEhXMmoJv3J7GrdA==", - "deprecated": "This is a stub types definition. pino-pretty provides its own type definitions, so you do not need this installed.", - "dependencies": { - "pino-pretty": "*" - } - }, - "node_modules/@types/pino-std-serializers": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz", - "integrity": "sha512-gXfUZx2xIBbFYozGms53fT0nvkacx/+62c8iTxrEqH5PkIGAQvDbXg2774VWOycMPbqn5YJBQ3BMsg4Li3dWbg==", - "deprecated": "This is a stub types definition. pino-std-serializers provides its own type definitions, so you do not need this installed.", - "dependencies": { - "pino-std-serializers": "*" - } - }, - "node_modules/@types/pino/node_modules/sonic-boom": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", - "integrity": "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, "node_modules/@types/prompt-sync": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/prompt-sync/-/prompt-sync-4.2.3.tgz", @@ -4927,13 +5388,13 @@ "dev": true }, "node_modules/@vitest/expect": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.0.4.tgz", - "integrity": "sha512-/NRN9N88qjg3dkhmFcCBwhn/Ie4h064pY3iv7WLRsDJW7dXnEgeoa8W9zy7gIPluhz6CkgqiB3HmpIXgmEY5dQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.2.tgz", + "integrity": "sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==", "dev": true, "dependencies": { - "@vitest/spy": "1.0.4", - "@vitest/utils": "1.0.4", + "@vitest/spy": "1.2.2", + "@vitest/utils": "1.2.2", "chai": "^4.3.10" }, "funding": { @@ -4941,12 +5402,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.0.4.tgz", - "integrity": "sha512-rhOQ9FZTEkV41JWXozFM8YgOqaG9zA7QXbhg5gy6mFOVqh4PcupirIJ+wN7QjeJt8S8nJRYuZH1OjJjsbxAXTQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.2.tgz", + "integrity": "sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==", "dev": true, "dependencies": { - "@vitest/utils": "1.0.4", + "@vitest/utils": "1.2.2", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -4982,9 +5443,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.0.4.tgz", - "integrity": "sha512-vkfXUrNyNRA/Gzsp2lpyJxh94vU2OHT1amoD6WuvUAA12n32xeVZQ0KjjQIf8F6u7bcq2A2k969fMVxEsxeKYA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.2.tgz", + "integrity": "sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -4996,9 +5457,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.0.4.tgz", - "integrity": "sha512-9ojTFRL1AJVh0hvfzAQpm0QS6xIS+1HFIw94kl/1ucTfGCaj1LV/iuJU4Y6cdR03EzPDygxTHwE1JOm+5RCcvA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.2.tgz", + "integrity": "sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -5008,12 +5469,13 @@ } }, "node_modules/@vitest/utils": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.0.4.tgz", - "integrity": "sha512-gsswWDXxtt0QvtK/y/LWukN7sGMYmnCcv1qv05CsY6cU/Y1zpGX1QuvLs+GO1inczpE6Owixeel3ShkjhYtGfA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.2.tgz", + "integrity": "sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", "loupe": "^2.3.7", "pretty-format": "^29.7.0" }, @@ -5084,14 +5546,22 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.0.tgz", - "integrity": "sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.12.tgz", + "integrity": "sha512-6TVU49mK6KZb4qG6xWaaM4C7sA/sgUMLy/JYMOzkcp3BvVLpW0fXDFQiIzAuxFCt/2+xD7fNIiPFAoLZPhVNLQ==", + "engines": { + "node": ">=6.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5171,7 +5641,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -5471,6 +5940,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -5536,9 +6021,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/aws-sdk": { - "version": "2.1532.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1532.0.tgz", - "integrity": "sha512-4QVQs01LEAxo7UpSHlq/HaO+SJ1WrYF8W1otO2WhKpVRYXkSxXIgZgfYaK+sQ762XTtB6tSuD2ZS2HGsKNXVLw==", + "version": "2.1553.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1553.0.tgz", + "integrity": "sha512-CfZaw8dR9e642aBOeFhkFL7KoQApeLR15uH2IQqfL/12snWYayAAesYh0tEaU+XbhrH0CUsf2Zro5IraEXEZMg==", "dependencies": { "buffer": "4.9.2", "events": "1.1.1", @@ -5549,7 +6034,7 @@ "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", - "xml2js": "0.5.0" + "xml2js": "0.6.2" }, "engines": { "node": ">= 10.0.0" @@ -5596,12 +6081,32 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "node_modules/aws-sdk/node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", "dependencies": { - "follow-redirects": "^1.15.0", + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/aws-sdk/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dependencies": { + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -5617,6 +6122,17 @@ "axios": "0.x || 1.x" } }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5662,11 +6178,24 @@ "node": ">= 10.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -5676,13 +6205,36 @@ "node": ">=8" } }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -5690,7 +6242,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -5796,15 +6348,14 @@ } }, "node_modules/bullmq": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.1.1.tgz", - "integrity": "sha512-j3zbNEQWsyHjpqGWiem2XBfmxAjYcArbwsmGlkM1E9MAVcrqB5hQUsXmyy9gEBAdL+PVotMICr7xTquR4Y2sKQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", + "integrity": "sha512-dkR/KGUw18miLe3QWtvSlmGvEe08aZF+w1jZyqEHMWFW3RP4162qp6OGud0/QCAOjusiRI8UOxUhbnortPY+rA==", "dependencies": { "cron-parser": "^4.6.0", - "glob": "^8.0.3", "ioredis": "^5.3.2", "lodash": "^4.17.21", - "msgpackr": "^1.6.2", + "msgpackr": "^1.10.1", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", @@ -5873,10 +6424,24 @@ "node": ">=6" } }, + "node_modules/cassandra-driver": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/cassandra-driver/-/cassandra-driver-4.7.2.tgz", + "integrity": "sha512-gwl1DeYvL8Wy3i1GDMzFtpUg5G473fU7EnHFZj7BUtdLB7loAfgZgB3zBhROc9fbaDSUDs6YwOPPojS5E1kbSA==", + "dependencies": { + "@types/long": "~5.0.0", + "@types/node": ">=8", + "adm-zip": "~0.5.10", + "long": "~5.2.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/chai": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", - "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -5891,6 +6456,17 @@ "node": ">=4" } }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -5955,12 +6531,29 @@ "node": ">=6" } }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dependencies": { + "restore-cursor": "^4.0.0" + }, "engines": { - "node": ">=0.8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cluster-key-slot": { @@ -6000,7 +6593,8 @@ "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -6072,6 +6666,11 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -6282,9 +6881,9 @@ } }, "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", + "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", "engines": { "node": ">=12" }, @@ -6319,8 +6918,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", @@ -6335,6 +6933,11 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -6996,18 +7599,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", @@ -7041,6 +7632,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7083,16 +7683,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -7123,17 +7723,12 @@ "node": ">= 0.10.0" } }, - "node_modules/express-handlebars": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-6.0.7.tgz", - "integrity": "sha512-iYeMFpc/hMD+E6FNAZA5fgWeXnXr4rslOSPkeEV6TwdmpJ5lEXuWX0u9vFYs31P2MURctQq2batR09oeNj0LIg==", - "dependencies": { - "glob": "^8.1.0", - "graceful-fs": "^4.2.10", - "handlebars": "^4.7.7" - }, + "node_modules/express/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { - "node": ">=v12.22.9" + "node": ">= 0.6" } }, "node_modules/express/node_modules/cookie-signature": { @@ -7154,6 +7749,19 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ] + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -7162,7 +7770,8 @@ "node_modules/fast-copy": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", - "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==" + "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==", + "dev": true }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", @@ -7248,19 +7857,6 @@ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.3.0.tgz", "integrity": "sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==" }, - "node_modules/fast-url-parser": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", - "integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==", - "dependencies": { - "punycode": "^1.3.2" - } - }, - "node_modules/fast-url-parser/node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" - }, "node_modules/fast-xml-parser": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz", @@ -7283,9 +7879,19 @@ } }, "node_modules/fastify": { - "version": "4.24.3", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.24.3.tgz", - "integrity": "sha512-6HHJ+R2x2LS3y1PqxnwEIjOTZxFl+8h4kSC/TuDPXtA+v2JnV9yEtOsNSKK1RMD7sIR2y1ZsA4BEFaid/cK5pg==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.26.0.tgz", + "integrity": "sha512-Fq/7ziWKc6pYLYLIlCRaqJqEVTIZ5tZYfcW/mDK2AQ9v/sqjGFpj0On0/7hU50kbPVjLO4de+larPA1WwPZSfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "dependencies": { "@fastify/ajv-compiler": "^3.5.0", "@fastify/error": "^3.4.0", @@ -7294,10 +7900,10 @@ "avvio": "^8.2.1", "fast-content-type-parse": "^1.1.0", "fast-json-stringify": "^5.8.0", - "find-my-way": "^7.7.0", + "find-my-way": "^8.0.0", "light-my-request": "^5.11.0", - "pino": "^8.16.0", - "process-warning": "^2.2.0", + "pino": "^8.17.0", + "process-warning": "^3.0.0", "proxy-addr": "^2.0.7", "rfdc": "^1.3.0", "secure-json-parse": "^2.7.0", @@ -7310,6 +7916,11 @@ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==" }, + "node_modules/fastify/node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -7373,9 +7984,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/find-my-way": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-7.7.0.tgz", - "integrity": "sha512-+SrHpvQ52Q6W9f3wJoJBbAQULJuNEEQwBvlvYwACDhBTLOTMiQ0HYWh4+vC3OivGP2ENcTI1oKlFA2OepJNjhQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.1.0.tgz", + "integrity": "sha512-41QwjCGcVTODUmLLqTMeoHeiozbMXYMAE1CKFiDyi9zVZ2Vjh0yz3MF0WQZoIb+cmzP/XlbFjlF2NtJmvZHznA==", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", @@ -7450,11 +8061,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/flatstr": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/flatstr/-/flatstr-1.0.12.tgz", - "integrity": "sha512-4zPxDyhCyiN2wIAtSLI6gc82/EjqZc1onI4Mz/l0pWrAlsSfYH/2ZIcU+e3oA2wDwbzIWNKwa23F8rh6+DRWkw==" - }, "node_modules/flatted": { "version": "3.2.9", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", @@ -7462,9 +8068,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -7677,6 +8283,88 @@ "node": ">=8" } }, + "node_modules/gaxios": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.5.0.tgz", + "integrity": "sha512-R9QGdv8j4/dlNoQbX3hSaK/S0rkMijqjVvW3YM06CoBdbU/VdKd159j4hePpng0KuE6Lh6JJ7UdmVGJZFcAG1w==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gaxios/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -7685,6 +8373,14 @@ "is-property": "^1.0.2" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -7866,6 +8562,69 @@ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "dev": true }, + "node_modules/google-auth-library": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.9.0.tgz", + "integrity": "sha512-9l+zO07h1tDJdIHN74SpnWIlNR+OuOemXlWJlLP9pXy6vFtizgpEzMuwJa4lqY9UAdiAv5DVd5ql0Am916I+aA==", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -7888,6 +8647,37 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -8026,6 +8816,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", + "dev": true, "dependencies": { "glob": "^8.0.0", "readable-stream": "^3.6.0" @@ -8035,6 +8826,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8421,6 +9213,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -8466,14 +9269,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -8574,6 +9369,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -8648,6 +9454,14 @@ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -8876,6 +9690,78 @@ "node": ">=8" } }, + "node_modules/ldap-filter": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.3.3.tgz", + "integrity": "sha512-/tFkx5WIn4HuO+6w9lsfxq4FN3O+fDZeO9Mek8dCD8rTUpqzRa766BOBO7BcGkn3X86m5+cBm1/2S/Shzz7gMg==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ldapauth-fork": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-5.0.5.tgz", + "integrity": "sha512-LWUk76+V4AOZbny/3HIPQtGPWZyA3SW2tRhsWIBi9imP22WJktKLHV1ofd8Jo/wY7Ve6vAT7FCI5mEn3blZTjw==", + "dependencies": { + "@types/ldapjs": "^2.2.2", + "bcryptjs": "^2.4.0", + "ldapjs": "^2.2.1", + "lru-cache": "^7.10.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ldapauth-fork/node_modules/ldapjs": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-2.3.3.tgz", + "integrity": "sha512-75QiiLJV/PQqtpH+HGls44dXweviFwQ6SiIK27EqzKQ5jU/7UFrl2E5nLdQ3IYRBzJ/AVFJI66u0MZ0uofKYwg==", + "dependencies": { + "abstract-logging": "^2.0.0", + "asn1": "^0.2.4", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "ldap-filter": "^0.3.3", + "once": "^1.4.0", + "vasync": "^2.2.0", + "verror": "^1.8.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ldapauth-fork/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -9008,11 +9894,6 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -9070,6 +9951,21 @@ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", "dev": true }, + "node_modules/log-symbols": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", + "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "dependencies": { + "chalk": "^5.0.0", + "is-unicode-supported": "^1.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -9109,9 +10005,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -9253,7 +10149,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "engines": { "node": ">=6" } @@ -9332,9 +10227,9 @@ } }, "node_modules/mnemonist": { - "version": "0.39.5", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.5.tgz", - "integrity": "sha512-FPUtkhtJ0efmEFGpU14x7jGbTB+s18LrzRL2KgoWz9YvcY3cPomz8tih01GbHwnGk/OmkOKfqd/RAQoc8Lm7DQ==", + "version": "0.39.6", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz", + "integrity": "sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==", "dependencies": { "obliterator": "^2.0.1" } @@ -9395,9 +10290,9 @@ } }, "node_modules/mysql2": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.6.5.tgz", - "integrity": "sha512-pS/KqIb0xlXmtmqEuTvBXTmLoQ5LmAz5NW/r8UyQ1ldvnprNEj3P9GbmuQQ2J0A4LO+ynotGi6TbscPa8OUb+w==", + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", + "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -9496,17 +10391,6 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, - "node_modules/node-cache": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", - "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", - "dependencies": { - "clone": "2.x" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9777,38 +10661,20 @@ "dev": true }, "node_modules/octokit-auth-probot": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/octokit-auth-probot/-/octokit-auth-probot-1.2.9.tgz", - "integrity": "sha512-mMjw6Y760EwJnW2tSVooJK8BMdsG6D40SoCclnefVf/5yWjaNVquEu8NREBVWb60OwbpnMEz4vREXHB5xdMFYQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/octokit-auth-probot/-/octokit-auth-probot-2.0.0.tgz", + "integrity": "sha512-bxidVIyxYJ+hWkG24pchPrN6mJdQrklZ2Acu+oGmZlh9aRONsIrw0KNW5W7QC2VlkxsFQwb9lnV+vH0BcEhnLQ==", "dependencies": { - "@octokit/auth-app": "^4.0.2", - "@octokit/auth-token": "^3.0.0", - "@octokit/auth-unauthenticated": "^3.0.0", - "@octokit/types": "^8.0.0" + "@octokit/auth-app": "^6.0.1", + "@octokit/auth-token": "^4.0.0", + "@octokit/auth-unauthenticated": "^5.0.1", + "@octokit/types": "^12.0.0" + }, + "engines": { + "node": ">=18" }, "peerDependencies": { - "@octokit/core": ">=3.2" - } - }, - "node_modules/octokit-auth-probot/node_modules/@octokit/auth-token": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.4.tgz", - "integrity": "sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==", - "engines": { - "node": ">= 14" - } - }, - "node_modules/octokit-auth-probot/node_modules/@octokit/openapi-types": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-14.0.0.tgz", - "integrity": "sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw==" - }, - "node_modules/octokit-auth-probot/node_modules/@octokit/types": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-8.2.1.tgz", - "integrity": "sha512-8oWMUji8be66q2B9PmEIUyQm00VPDPun07umUWSaCwxmeaquFBro4Hcc3ruVoDo3zkQyZBlRvhIMEYS3pBhanw==", - "dependencies": { - "@octokit/openapi-types": "^14.0.0" + "@octokit/core": ">=5" } }, "node_modules/on-exit-leak-free": { @@ -9842,7 +10708,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -9875,6 +10740,37 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-7.0.1.tgz", + "integrity": "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^4.0.0", + "cli-spinners": "^2.9.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^1.3.0", + "log-symbols": "^5.1.0", + "stdin-discarder": "^0.1.0", + "string-width": "^6.1.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oracledb": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/oracledb/-/oracledb-6.4.0.tgz", + "integrity": "sha512-TJI08qzQlf/l7T49VojP9BoQpjEr14NXZmpSzzcLrbNs7qSl0QA/Mc9gGiEdkg5WmwH0wqUjtMC7jlf1WamlYA==", + "hasInstallScript": true, + "engines": { + "node": ">=14.6" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9904,14 +10800,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "engines": { - "node": ">=6" - } - }, "node_modules/p-throttle": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-5.1.0.tgz", @@ -10018,6 +10906,18 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-ldapauth": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-3.0.1.tgz", + "integrity": "sha512-TRRx3BHi8GC8MfCT9wmghjde/EGeKjll7zqHRRfGRxXbLcaDce2OftbQrFG7/AWaeFhR6zpZHtBQ/IkINdLVjQ==", + "dependencies": { + "ldapauth-fork": "^5.0.1", + "passport-strategy": "^1.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/passport-oauth2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", @@ -10174,6 +11074,14 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/pg-cursor": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.10.3.tgz", + "integrity": "sha512-rDyBVoqPVnx/PTmnwQAYgusSeAKlTL++gmpf5klVK+mYMFEqsOc6VHHZnPKc/4lOvr4r6fiMuoxSFuBF1dx4FQ==", + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -10204,6 +11112,17 @@ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" }, + "node_modules/pg-query-stream": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.5.3.tgz", + "integrity": "sha512-ufa94r/lHJdjAm3+zPZEO0gXAmCb4tZPaOt7O76mjcxdL/HxwTuryy76km+u0odBBgtfdKFYq/9XGfiYeQF0yA==", + "dependencies": { + "pg-cursor": "^2.10.3" + }, + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -10258,16 +11177,16 @@ } }, "node_modules/pino": { - "version": "8.16.2", - "resolved": "https://registry.npmjs.org/pino/-/pino-8.16.2.tgz", - "integrity": "sha512-2advCDGVEvkKu9TTVSa/kWW7Z3htI/sBKEZpqiHk6ive0i/7f5b1rsU8jn0aimxqfnSz5bj/nOYkwhBUn5xxvg==", + "version": "8.17.2", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.17.2.tgz", + "integrity": "sha512-LA6qKgeDMLr2ux2y/YiUt47EfgQ+S9LznBWOJdN3q1dx2sv0ziDLUBeVpyVv17TEcGCBuWf0zNtg3M5m1NhhWQ==", "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "v1.1.0", "pino-std-serializers": "^6.0.0", - "process-warning": "^2.0.0", + "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", @@ -10288,60 +11207,26 @@ } }, "node_modules/pino-http": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-5.8.0.tgz", - "integrity": "sha512-YwXiyRb9y0WCD1P9PcxuJuh3Dc5qmXde/paJE86UGYRdiFOi828hR9iUGmk5gaw6NBT9gLtKANOHFimvh19U5w==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-8.6.1.tgz", + "integrity": "sha512-J0hiJgUExtBXP2BjrK4VB305tHXS31sCmWJ9XJo2wPkLHa1NFPuW4V9wjG27PAc2fmBCigiNhQKpvrx+kntBPA==", "dependencies": { - "fast-url-parser": "^1.1.3", - "pino": "^6.13.0", - "pino-std-serializers": "^4.0.0" + "get-caller-file": "^2.0.5", + "pino": "^8.17.1", + "pino-std-serializers": "^6.2.2", + "process-warning": "^3.0.0" } }, - "node_modules/pino-http/node_modules/pino": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz", - "integrity": "sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==", - "dependencies": { - "fast-redact": "^3.0.0", - "fast-safe-stringify": "^2.0.8", - "flatstr": "^1.0.12", - "pino-std-serializers": "^3.1.0", - "process-warning": "^1.0.0", - "quick-format-unescaped": "^4.0.3", - "sonic-boom": "^1.0.2" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-http/node_modules/pino-std-serializers": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz", - "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==" - }, - "node_modules/pino-http/node_modules/pino/node_modules/pino-std-serializers": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", - "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" - }, "node_modules/pino-http/node_modules/process-warning": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", - "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==" - }, - "node_modules/pino-http/node_modules/sonic-boom": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", - "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", - "dependencies": { - "atomic-sleep": "^1.0.0", - "flatstr": "^1.0.12" - } + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" }, "node_modules/pino-pretty": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.2.3.tgz", "integrity": "sha512-4jfIUc8TC1GPUfDyMSlW1STeORqkoxec71yhxIpLDQapUu8WOuoz2TTCoidrIssyz78LZC69whBMPIKCMbi3cw==", + "dev": true, "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", @@ -10367,6 +11252,11 @@ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" }, + "node_modules/pino/node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -10468,9 +11358,9 @@ } }, "node_modules/postcss": { - "version": "8.4.32", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "dev": true, "funding": [ { @@ -10489,7 +11379,7 @@ "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -10590,9 +11480,9 @@ "dev": true }, "node_modules/posthog-node": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-3.6.0.tgz", - "integrity": "sha512-N/4//SIQR4fhwbHnDdJ2rQCYdu9wo0EVPK4lVgZswp5R/E42RKlpuO6ZfPsBl+Bcg06OYiOd/WR/jLV90FCoSw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-3.6.2.tgz", + "integrity": "sha512-tVIaShR3SxBx17AlAUS86jQTweKuJIFRedBB504fCz7YPnXJTYSrVcUHn5IINE2wu4jUQimQK6ihQr90Djrdrg==", "dependencies": { "axios": "^1.6.2", "rusha": "^0.8.14" @@ -10601,6 +11491,14 @@ "node": ">=15.0.0" } }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10665,413 +11563,59 @@ } }, "node_modules/probot": { - "version": "12.3.3", - "resolved": "https://registry.npmjs.org/probot/-/probot-12.3.3.tgz", - "integrity": "sha512-cdtKd+xISzi8sw6++BYBXleRknCA6hqUMoHj/sJqQBrjbNxQLhfeFCq9O2d0Z4eShsy5YFRR3MWwDKJ9uAE0CA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/probot/-/probot-13.0.0.tgz", + "integrity": "sha512-3ht9kAJ+ISjLyWLLCKVdrLE5xs/x+zUx07J5kYTxAyIxUvwF6Acr8xT5fiNihbBHAsEl4+A4CMYZQvZ5hx5bgw==", "dependencies": { - "@octokit/core": "^3.2.4", - "@octokit/plugin-enterprise-compatibility": "^1.2.8", - "@octokit/plugin-paginate-rest": "^2.6.2", - "@octokit/plugin-rest-endpoint-methods": "^5.0.1", - "@octokit/plugin-retry": "^3.0.6", - "@octokit/plugin-throttling": "^3.3.4", - "@octokit/types": "^8.0.0", - "@octokit/webhooks": "^9.26.3", - "@probot/get-private-key": "^1.1.0", - "@probot/octokit-plugin-config": "^1.0.0", - "@probot/pino": "^2.2.0", - "@types/express": "^4.17.9", - "@types/ioredis": "^4.27.1", - "@types/pino": "^6.3.4", - "@types/pino-http": "^5.0.6", - "commander": "^6.2.0", - "deepmerge": "^4.2.2", - "deprecation": "^2.3.1", - "dotenv": "^8.2.0", + "@octokit/core": "^5.0.2", + "@octokit/plugin-enterprise-compatibility": "^4.0.1", + "@octokit/plugin-paginate-rest": "^9.1.4", + "@octokit/plugin-rest-endpoint-methods": "^10.1.5", + "@octokit/plugin-retry": "^6.0.1", + "@octokit/plugin-throttling": "^8.1.3", + "@octokit/request": "^8.1.6", + "@octokit/types": "^12.3.0", + "@octokit/webhooks": "^12.0.10", + "@probot/get-private-key": "^1.1.2", + "@probot/octokit-plugin-config": "^2.0.1", + "@probot/pino": "^2.3.5", + "@types/express": "^4.17.21", + "commander": "^11.1.0", + "deepmerge": "^4.3.1", + "dotenv": "^16.3.1", "eventsource": "^2.0.2", - "express": "^4.17.1", - "express-handlebars": "^6.0.3", - "ioredis": "^4.27.8", - "js-yaml": "^3.14.1", - "lru-cache": "^6.0.0", - "octokit-auth-probot": "^1.2.2", - "pino": "^6.7.0", - "pino-http": "^5.3.0", + "express": "^4.18.2", + "ioredis": "^5.3.2", + "js-yaml": "^4.1.0", + "lru-cache": "^10.0.3", + "octokit-auth-probot": "^2.0.0", + "pino": "^8.16.1", + "pino-http": "^8.5.1", "pkg-conf": "^3.1.0", - "resolve": "^1.19.0", - "semver": "^7.3.4", - "update-dotenv": "^1.1.1", - "uuid": "^8.3.2" + "resolve": "^1.22.8", + "update-dotenv": "^1.1.1" }, "bin": { "probot": "bin/probot.js" }, "engines": { - "node": ">=10.21" - } - }, - "node_modules/probot/node_modules/@octokit/auth-token": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", - "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", - "dependencies": { - "@octokit/types": "^6.0.3" - } - }, - "node_modules/probot/node_modules/@octokit/auth-token/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/auth-token/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/core": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", - "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", - "dependencies": { - "@octokit/auth-token": "^2.4.4", - "@octokit/graphql": "^4.5.8", - "@octokit/request": "^5.6.3", - "@octokit/request-error": "^2.0.5", - "@octokit/types": "^6.0.3", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - } - }, - "node_modules/probot/node_modules/@octokit/core/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/core/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/endpoint": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", - "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", - "dependencies": { - "@octokit/types": "^6.0.3", - "is-plain-object": "^5.0.0", - "universal-user-agent": "^6.0.0" - } - }, - "node_modules/probot/node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/endpoint/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/graphql": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", - "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", - "dependencies": { - "@octokit/request": "^5.6.0", - "@octokit/types": "^6.0.3", - "universal-user-agent": "^6.0.0" - } - }, - "node_modules/probot/node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/graphql/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/openapi-types": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-14.0.0.tgz", - "integrity": "sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw==" - }, - "node_modules/probot/node_modules/@octokit/plugin-paginate-rest": { - "version": "2.21.3", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz", - "integrity": "sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw==", - "dependencies": { - "@octokit/types": "^6.40.0" - }, - "peerDependencies": { - "@octokit/core": ">=2" - } - }, - "node_modules/probot/node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "5.16.2", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz", - "integrity": "sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw==", - "dependencies": { - "@octokit/types": "^6.39.0", - "deprecation": "^2.3.1" - }, - "peerDependencies": { - "@octokit/core": ">=3" - } - }, - "node_modules/probot/node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/plugin-throttling": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-3.7.0.tgz", - "integrity": "sha512-qrKT1Yl/KuwGSC6/oHpLBot3ooC9rq0/ryDYBCpkRtoj+R8T47xTMDT6Tk2CxWopFota/8Pi/2SqArqwC0JPow==", - "dependencies": { - "@octokit/types": "^6.0.1", - "bottleneck": "^2.15.3" - }, - "peerDependencies": { - "@octokit/core": "^3.5.0" - } - }, - "node_modules/probot/node_modules/@octokit/plugin-throttling/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/request": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", - "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", - "dependencies": { - "@octokit/endpoint": "^6.0.1", - "@octokit/request-error": "^2.1.0", - "@octokit/types": "^6.16.1", - "is-plain-object": "^5.0.0", - "node-fetch": "^2.6.7", - "universal-user-agent": "^6.0.0" - } - }, - "node_modules/probot/node_modules/@octokit/request-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", - "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", - "dependencies": { - "@octokit/types": "^6.0.3", - "deprecation": "^2.0.0", - "once": "^1.4.0" - } - }, - "node_modules/probot/node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/request-error/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/request/node_modules/@octokit/openapi-types": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", - "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==" - }, - "node_modules/probot/node_modules/@octokit/request/node_modules/@octokit/types": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", - "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", - "dependencies": { - "@octokit/openapi-types": "^12.11.0" - } - }, - "node_modules/probot/node_modules/@octokit/types": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-8.2.1.tgz", - "integrity": "sha512-8oWMUji8be66q2B9PmEIUyQm00VPDPun07umUWSaCwxmeaquFBro4Hcc3ruVoDo3zkQyZBlRvhIMEYS3pBhanw==", - "dependencies": { - "@octokit/openapi-types": "^14.0.0" - } - }, - "node_modules/probot/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" + "node": ">=18" } }, "node_modules/probot/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", "engines": { - "node": ">= 6" + "node": ">=16" } }, - "node_modules/probot/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, + "node_modules/probot/node_modules/lru-cache": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/probot/node_modules/denque": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", - "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/probot/node_modules/dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", - "engines": { - "node": ">=10" - } - }, - "node_modules/probot/node_modules/ioredis": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.28.5.tgz", - "integrity": "sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==", - "dependencies": { - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.1", - "denque": "^1.1.0", - "lodash.defaults": "^4.2.0", - "lodash.flatten": "^4.4.0", - "lodash.isarguments": "^3.1.0", - "p-map": "^2.1.0", - "redis-commands": "1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, - "node_modules/probot/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/probot/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/probot/node_modules/pino": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz", - "integrity": "sha512-iuhEDel3Z3hF9Jfe44DPXR8l07bhjuFY3GMHIXbjnY9XcafbyDDwl2sN2vw2GjMPf5Nkoe+OFao7ffn9SXaKDg==", - "dependencies": { - "fast-redact": "^3.0.0", - "fast-safe-stringify": "^2.0.8", - "flatstr": "^1.0.12", - "pino-std-serializers": "^3.1.0", - "process-warning": "^1.0.0", - "quick-format-unescaped": "^4.0.3", - "sonic-boom": "^1.0.2" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/probot/node_modules/pino-std-serializers": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-3.2.0.tgz", - "integrity": "sha512-EqX4pwDPrt3MuOAAUBMU0Tk5kR/YcCM5fNPEzgCO2zJ5HfX0vbiH9HbJglnyeQsN96Kznae6MWD47pZB5avTrg==" - }, - "node_modules/probot/node_modules/process-warning": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", - "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==" - }, - "node_modules/probot/node_modules/sonic-boom": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-1.4.1.tgz", - "integrity": "sha512-LRHh/A8tpW7ru89lrlkU4AszXt1dbwSjVWguGrmlxE7tawVmDBlI1PILMkXAxJTwqhgsEeTHzj36D5CmHgQmNg==", - "dependencies": { - "atomic-sleep": "^1.0.0", - "flatstr": "^1.0.12" - } - }, - "node_modules/probot/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" + "node": "14 || >=16.14" } }, "node_modules/process": { @@ -11231,9 +11775,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -11319,11 +11863,6 @@ "node": ">= 10.13.0" } }, - "node_modules/redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" - }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -11402,6 +11941,21 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ret": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", @@ -11498,10 +12052,13 @@ } }, "node_modules/rollup": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.8.0.tgz", - "integrity": "sha512-NpsklK2fach5CdI+PScmlE5R4Ao/FSWtF7LkoIrHDxPACY/xshNasPsbpG0VVHxUTbf74tJbVT4PrP8JsJ6ZDA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", + "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, "bin": { "rollup": "dist/bin/rollup" }, @@ -11510,19 +12067,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.8.0", - "@rollup/rollup-android-arm64": "4.8.0", - "@rollup/rollup-darwin-arm64": "4.8.0", - "@rollup/rollup-darwin-x64": "4.8.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.8.0", - "@rollup/rollup-linux-arm64-gnu": "4.8.0", - "@rollup/rollup-linux-arm64-musl": "4.8.0", - "@rollup/rollup-linux-riscv64-gnu": "4.8.0", - "@rollup/rollup-linux-x64-gnu": "4.8.0", - "@rollup/rollup-linux-x64-musl": "4.8.0", - "@rollup/rollup-win32-arm64-msvc": "4.8.0", - "@rollup/rollup-win32-ia32-msvc": "4.8.0", - "@rollup/rollup-win32-x64-msvc": "4.8.0", + "@rollup/rollup-android-arm-eabi": "4.14.3", + "@rollup/rollup-android-arm64": "4.14.3", + "@rollup/rollup-darwin-arm64": "4.14.3", + "@rollup/rollup-darwin-x64": "4.14.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", + "@rollup/rollup-linux-arm-musleabihf": "4.14.3", + "@rollup/rollup-linux-arm64-gnu": "4.14.3", + "@rollup/rollup-linux-arm64-musl": "4.14.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", + "@rollup/rollup-linux-riscv64-gnu": "4.14.3", + "@rollup/rollup-linux-s390x-gnu": "4.14.3", + "@rollup/rollup-linux-x64-gnu": "4.14.3", + "@rollup/rollup-linux-x64-musl": "4.14.3", + "@rollup/rollup-win32-arm64-msvc": "4.14.3", + "@rollup/rollup-win32-ia32-msvc": "4.14.3", + "@rollup/rollup-win32-x64-msvc": "4.14.3", "fsevents": "~2.3.2" } }, @@ -11875,9 +12435,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -11891,11 +12451,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" - }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -11929,6 +12484,20 @@ "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==", "dev": true }, + "node_modules/stdin-discarder": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", + "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", + "dependencies": { + "bl": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -11942,6 +12511,22 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-6.1.0.tgz", + "integrity": "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^10.2.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", @@ -12033,7 +12618,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -12213,9 +12797,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -12286,18 +12870,18 @@ "dev": true }, "node_modules/tinypool": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.1.tgz", - "integrity": "sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", + "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", "dev": true, "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", - "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", "dev": true, "engines": { "node": ">=14.0.0" @@ -13310,6 +13894,11 @@ "querystring": "0.2.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==" + }, "node_modules/url/node_modules/punycode": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", @@ -13374,15 +13963,52 @@ "node": ">= 0.8" } }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/vite": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", - "integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==", + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz", + "integrity": "sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==", "dev": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" }, "bin": { "vite": "bin/vite.js" @@ -13430,9 +14056,9 @@ } }, "node_modules/vite-node": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.0.4.tgz", - "integrity": "sha512-9xQQtHdsz5Qn8hqbV7UKqkm8YkJhzT/zr41Dmt5N7AlD8hJXw/Z7y0QiD5I8lnTthV9Rvcvi0QW7PI0Fq83ZPg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.2.tgz", + "integrity": "sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -13517,9 +14143,9 @@ "dev": true }, "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.9.tgz", - "integrity": "sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "cpu": [ "arm" ], @@ -13533,9 +14159,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.9.tgz", - "integrity": "sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "cpu": [ "arm64" ], @@ -13549,9 +14175,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.9.tgz", - "integrity": "sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "cpu": [ "x64" ], @@ -13565,9 +14191,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.9.tgz", - "integrity": "sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "cpu": [ "arm64" ], @@ -13581,9 +14207,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.9.tgz", - "integrity": "sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "cpu": [ "x64" ], @@ -13597,9 +14223,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.9.tgz", - "integrity": "sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "cpu": [ "arm64" ], @@ -13613,9 +14239,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.9.tgz", - "integrity": "sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "cpu": [ "x64" ], @@ -13629,9 +14255,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.9.tgz", - "integrity": "sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "cpu": [ "arm" ], @@ -13645,9 +14271,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.9.tgz", - "integrity": "sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "cpu": [ "arm64" ], @@ -13661,9 +14287,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.9.tgz", - "integrity": "sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "cpu": [ "ia32" ], @@ -13677,9 +14303,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.9.tgz", - "integrity": "sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "cpu": [ "loong64" ], @@ -13693,9 +14319,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.9.tgz", - "integrity": "sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "cpu": [ "mips64el" ], @@ -13709,9 +14335,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.9.tgz", - "integrity": "sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "cpu": [ "ppc64" ], @@ -13725,9 +14351,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.9.tgz", - "integrity": "sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "cpu": [ "riscv64" ], @@ -13741,9 +14367,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.9.tgz", - "integrity": "sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "cpu": [ "s390x" ], @@ -13757,9 +14383,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.9.tgz", - "integrity": "sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "cpu": [ "x64" ], @@ -13773,9 +14399,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.9.tgz", - "integrity": "sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "cpu": [ "x64" ], @@ -13789,9 +14415,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.9.tgz", - "integrity": "sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "cpu": [ "x64" ], @@ -13805,9 +14431,9 @@ } }, "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.9.tgz", - "integrity": "sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "cpu": [ "x64" ], @@ -13821,9 +14447,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.9.tgz", - "integrity": "sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "cpu": [ "arm64" ], @@ -13837,9 +14463,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.9.tgz", - "integrity": "sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "cpu": [ "ia32" ], @@ -13853,9 +14479,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.9.tgz", - "integrity": "sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "cpu": [ "x64" ], @@ -13869,9 +14495,9 @@ } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.9.tgz", - "integrity": "sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dev": true, "hasInstallScript": true, "bin": { @@ -13881,42 +14507,43 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.19.9", - "@esbuild/android-arm64": "0.19.9", - "@esbuild/android-x64": "0.19.9", - "@esbuild/darwin-arm64": "0.19.9", - "@esbuild/darwin-x64": "0.19.9", - "@esbuild/freebsd-arm64": "0.19.9", - "@esbuild/freebsd-x64": "0.19.9", - "@esbuild/linux-arm": "0.19.9", - "@esbuild/linux-arm64": "0.19.9", - "@esbuild/linux-ia32": "0.19.9", - "@esbuild/linux-loong64": "0.19.9", - "@esbuild/linux-mips64el": "0.19.9", - "@esbuild/linux-ppc64": "0.19.9", - "@esbuild/linux-riscv64": "0.19.9", - "@esbuild/linux-s390x": "0.19.9", - "@esbuild/linux-x64": "0.19.9", - "@esbuild/netbsd-x64": "0.19.9", - "@esbuild/openbsd-x64": "0.19.9", - "@esbuild/sunos-x64": "0.19.9", - "@esbuild/win32-arm64": "0.19.9", - "@esbuild/win32-ia32": "0.19.9", - "@esbuild/win32-x64": "0.19.9" + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" } }, "node_modules/vitest": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.0.4.tgz", - "integrity": "sha512-s1GQHp/UOeWEo4+aXDOeFBJwFzL6mjycbQwwKWX2QcYfh/7tIerS59hWQ20mxzupTJluA2SdwiBuWwQHH67ckg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.2.tgz", + "integrity": "sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==", "dev": true, "dependencies": { - "@vitest/expect": "1.0.4", - "@vitest/runner": "1.0.4", - "@vitest/snapshot": "1.0.4", - "@vitest/spy": "1.0.4", - "@vitest/utils": "1.0.4", - "acorn-walk": "^8.3.0", + "@vitest/expect": "1.2.2", + "@vitest/runner": "1.2.2", + "@vitest/snapshot": "1.2.2", + "@vitest/spy": "1.2.2", + "@vitest/utils": "1.2.2", + "acorn-walk": "^8.3.2", "cac": "^6.7.14", "chai": "^4.3.10", "debug": "^4.3.4", @@ -13928,9 +14555,9 @@ "std-env": "^3.5.0", "strip-literal": "^1.3.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.1", + "tinypool": "^0.8.2", "vite": "^5.0.0", - "vite-node": "1.0.4", + "vite-node": "1.2.2", "why-is-node-running": "^2.2.2" }, "bin": { @@ -14449,9 +15076,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.22.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.22.0.tgz", - "integrity": "sha512-XQr8EwxPMzJGhoR+d/nRFWdi15VaZ+R5Uhssm+Xx5yS30xCpuutfKRm4rerE0SK9j2dWB5Z3FvDD0w8WMVGzkA==", + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.22.4.tgz", + "integrity": "sha512-2Ed5dJ+n/O3cU383xSY28cuVi0BCQhF8nYqWU5paEpl7fVdqdAmiLdqLyfblbNdfOFwFfi/mqU4O1pwc60iBhQ==", "peerDependencies": { "zod": "^3.22.4" } diff --git a/backend/package.json b/backend/package.json index ee8cafce3..85538d45a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,8 +24,8 @@ "migration:latest": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest", "migration:rollback": "knex --knexfile ./src/db/knexfile.ts migrate:rollback", "seed:new": "tsx ./scripts/create-seed-file.ts", - "seed:run": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", - "db:reset": "npm run migration:rollback -- --all && npm run migration:latest && npm run seed:run" + "seed": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", + "db:reset": "npm run migration:rollback -- --all && npm run migration:latest" }, "keywords": [], "author": "", @@ -67,21 +67,22 @@ "tsx": "^4.4.0", "typescript": "^5.3.2", "vite-tsconfig-paths": "^4.2.2", - "vitest": "^1.0.4" + "vitest": "^1.2.2" }, "dependencies": { - "@aws-sdk/client-secrets-manager": "^3.485.0", + "@aws-sdk/client-iam": "^3.525.0", + "@aws-sdk/client-secrets-manager": "^3.504.0", "@casl/ability": "^6.5.0", - "@fastify/cookie": "^9.2.0", - "@fastify/cors": "^8.4.1", + "@fastify/cookie": "^9.3.1", + "@fastify/cors": "^8.5.0", "@fastify/etag": "^5.1.0", "@fastify/formbody": "^7.4.0", "@fastify/helmet": "^11.1.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", "@fastify/session": "^10.7.0", - "@fastify/swagger": "^8.12.0", - "@fastify/swagger-ui": "^1.10.1", + "@fastify/swagger": "^8.14.0", + "@fastify/swagger-ui": "^2.1.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", @@ -90,39 +91,47 @@ "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", - "aws-sdk": "^2.1532.0", - "axios": "^1.6.2", + "aws-sdk": "^2.1553.0", + "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", - "bullmq": "^5.1.1", - "dotenv": "^16.3.1", - "fastify": "^4.24.3", + "bullmq": "^5.4.2", + "cassandra-driver": "^4.7.2", + "dotenv": "^16.4.1", + "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", + "google-auth-library": "^9.9.0", + "googleapis": "^137.1.0", "handlebars": "^4.7.8", "ioredis": "^5.3.2", "jmespath": "^0.16.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "knex": "^3.0.1", + "ldapjs": "^3.0.7", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", - "mysql2": "^3.6.5", + "ms": "^2.1.3", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", - "node-cache": "^5.1.2", - "nodemailer": "^6.9.7", + "nodemailer": "^6.9.9", + "ora": "^7.0.1", + "oracledb": "^6.4.0", "passport-github": "^1.1.0", "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-ldapauth": "^3.0.1", "pg": "^8.11.3", + "pg-query-stream": "^4.5.3", "picomatch": "^3.0.1", "pino": "^8.16.2", - "posthog-node": "^3.6.0", - "probot": "^12.3.3", + "posthog-node": "^3.6.2", + "probot": "^13.0.0", "smee-client": "^2.0.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", "uuid": "^9.0.1", "zod": "^3.22.4", - "zod-to-json-schema": "^3.22.0" + "zod-to-json-schema": "^3.22.4" } } diff --git a/backend/scripts/create-backend-file.ts b/backend/scripts/create-backend-file.ts index b821aba21..fb71994ce 100644 --- a/backend/scripts/create-backend-file.ts +++ b/backend/scripts/create-backend-file.ts @@ -103,11 +103,15 @@ export const ${dalName} = (db: TDbClient) => { `import { z } from "zod"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { readLimit } from "@app/server/config/rateLimiter"; export const register${pascalCase}Router = async (server: FastifyZodProvider) => { server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, schema: { params: z.object({}), response: { diff --git a/backend/scripts/create-migration.ts b/backend/scripts/create-migration.ts index f4017b12b..59040a37a 100644 --- a/backend/scripts/create-migration.ts +++ b/backend/scripts/create-migration.ts @@ -7,10 +7,10 @@ const prompt = promptSync({ sigint: true }); const migrationName = prompt("Enter name for migration: "); +// Remove spaces from migration name and replace with hyphens +const formattedMigrationName = migrationName.replace(/\s+/g, "-"); + execSync( - `npx knex migrate:make --knexfile ${path.join( - __dirname, - "../src/db/knexfile.ts" - )} -x ts ${migrationName}`, + `npx knex migrate:make --knexfile ${path.join(__dirname, "../src/db/knexfile.ts")} -x ts ${formattedMigrationName}`, { stdio: "inherit" } ); diff --git a/backend/scripts/create-seed-file.ts b/backend/scripts/create-seed-file.ts index 25faf94c3..c79ea0846 100644 --- a/backend/scripts/create-seed-file.ts +++ b/backend/scripts/create-seed-file.ts @@ -7,11 +7,10 @@ import promptSync from "prompt-sync"; const prompt = promptSync({ sigint: true }); const migrationName = prompt("Enter name for seedfile: "); -const fileCounter = readdirSync(path.join(__dirname, "../src/db/seed")).length || 1; +const fileCounter = readdirSync(path.join(__dirname, "../src/db/seeds")).length || 1; execSync( - `npx knex seed:make --knexfile ${path.join( - __dirname, - "../src/db/knexfile.ts" - )} -x ts ${fileCounter}-${migrationName}`, + `npx knex seed:make --knexfile ${path.join(__dirname, "../src/db/knexfile.ts")} -x ts ${ + fileCounter + 1 + }-${migrationName}`, { stdio: "inherit" } ); diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 5d51a5164..8c913991f 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -3,13 +3,9 @@ import dotenv from "dotenv"; import path from "path"; import knex from "knex"; import { writeFileSync } from "fs"; -import promptSync from "prompt-sync"; - -const prompt = promptSync({ sigint: true }); dotenv.config({ - path: path.join(__dirname, "../.env"), - debug: true + path: path.join(__dirname, "../../.env.migration") }); const db = knex({ @@ -48,7 +44,7 @@ const getZodDefaultValue = (type: unknown, value: string | number | boolean | Ob if (!value || value === "null") return; switch (type) { case "uuid": - return; + return `.default("00000000-0000-0000-0000-000000000000")`; case "character varying": { if (value === "gen_random_uuid()") return; if (typeof value === "string" && value.includes("::")) { @@ -94,17 +90,7 @@ const main = async () => { .orderBy("table_name") ).filter((el) => !el.tableName.includes("_migrations")); - console.log("Select a table to generate schema"); - console.table(tables); - console.log("all: all tables"); - const selectedTables = prompt("Type table numbers comma seperated: "); - const tableNumbers = - selectedTables !== "all" ? selectedTables.split(",").map((el) => Number(el)) : []; - for (let i = 0; i < tables.length; i += 1) { - // skip if not desired table - if (selectedTables !== "all" && !tableNumbers.includes(i)) continue; - const { tableName } = tables[i]; const columns = await db(tableName).columnInfo(); const columnNames = Object.keys(columns); @@ -114,7 +100,8 @@ const main = async () => { const columnName = columnNames[colNum]; const colInfo = columns[columnName]; let ztype = getZodPrimitiveType(colInfo.type); - if (colInfo.defaultValue) { + // don't put optional on id + if (colInfo.defaultValue && columnName !== "id") { const { defaultValue } = colInfo; const zSchema = getZodDefaultValue(colInfo.type, defaultValue); if (zSchema) { @@ -124,16 +111,17 @@ const main = async () => { if (colInfo.nullable) { ztype = ztype.concat(".nullable().optional()"); } - schema = schema.concat(`${!schema ? "\n" : ""} ${columnName}: ${ztype},\n`); + schema = schema.concat( + `${!schema ? "\n" : ""} ${columnName}: ${ztype}${colNum === columnNames.length - 1 ? "" : ","}\n` + ); } const dashcase = tableName.split("_").join("-"); const pascalCase = tableName .split("_") - .reduce( - (prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, - "" - ); + .reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, ""); + + // the insert and update are changed to zod input type to use default cases writeFileSync( path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`), `// Code generated by automation script, DO NOT EDIT. @@ -148,19 +136,10 @@ import { TImmutableDBKeys } from "./models"; export const ${pascalCase}Schema = z.object({${schema}}); export type T${pascalCase} = z.infer; -export type T${pascalCase}Insert = Omit; -export type T${pascalCase}Update = Partial>; +export type T${pascalCase}Insert = Omit, TImmutableDBKeys>; +export type T${pascalCase}Update = Partial, TImmutableDBKeys>>; ` ); - - // const file = readFileSync(path.join(__dirname, "../src/db/schemas/index.ts"), "utf8"); - // if (!file.includes(`export * from "./${dashcase};"`)) { - // appendFileSync( - // path.join(__dirname, "../src/db/schemas/index.ts"), - // `\nexport * from "./${dashcase}";`, - // "utf8" - // ); - // } } process.exit(0); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index af6bf8b93..4776e26dc 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -1,11 +1,21 @@ import "fastify"; import { TUsers } from "@app/db/schemas"; +import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; +import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; +import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; +import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; +import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; +import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; @@ -17,10 +27,14 @@ import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; -import { ActorType } from "@app/services/auth/auth-type"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; @@ -51,13 +65,16 @@ declare module "fastify" { // used for mfa session authentication mfa: { userId: string; + orgId?: string; user: TUsers; }; // identity injection. depending on which kinda of token the information is filled in auth auth: TAuthMode; permission: { + authMethod: ActorAuthMethod; type: ActorType; id: string; + orgId: string; }; // passport data passportUser: { @@ -66,6 +83,7 @@ declare module "fastify" { }; auditLogInfo: Pick; ssoConfig: Awaited>; + ldapConfig: Awaited>; } interface FastifyInstance { @@ -79,6 +97,8 @@ declare module "fastify" { orgRole: TOrgRoleServiceFactory; superAdmin: TSuperAdminServiceFactory; user: TUserServiceFactory; + group: TGroupServiceFactory; + groupProject: TGroupProjectServiceFactory; apiKey: TApiKeyServiceFactory; project: TProjectServiceFactory; projectMembership: TProjectMembershipServiceFactory; @@ -98,17 +118,29 @@ declare module "fastify" { identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityUa: TIdentityUaServiceFactory; + identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; + identityGcpAuth: TIdentityGcpAuthServiceFactory; + identityAwsAuth: TIdentityAwsAuthServiceFactory; + accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; + accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; secretApprovalRequest: TSecretApprovalRequestServiceFactory; secretRotation: TSecretRotationServiceFactory; snapshot: TSecretSnapshotServiceFactory; saml: TSamlConfigServiceFactory; + scim: TScimServiceFactory; + ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; + auditLogStream: TAuditLogStreamServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; secretBlindIndex: TSecretBlindIndexServiceFactory; telemetry: TTelemetryServiceFactory; + dynamicSecret: TDynamicSecretServiceFactory; + dynamicSecretLease: TDynamicSecretLeaseServiceFactory; + projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; + identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5bf563810..291197b0b 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -2,11 +2,26 @@ import { Knex } from "knex"; import { TableName, + TAccessApprovalPolicies, + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate, + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate, + TAccessApprovalRequestsUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, TAuditLogs, TAuditLogsInsert, + TAuditLogStreams, + TAuditLogStreamsInsert, + TAuditLogStreamsUpdate, TAuditLogsUpdate, TAuthTokens, TAuthTokenSessions, @@ -17,21 +32,51 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, + TDynamicSecretLeases, + TDynamicSecretLeasesInsert, + TDynamicSecretLeasesUpdate, + TDynamicSecrets, + TDynamicSecretsInsert, + TDynamicSecretsUpdate, TGitAppInstallSessions, TGitAppInstallSessionsInsert, TGitAppInstallSessionsUpdate, TGitAppOrg, TGitAppOrgInsert, TGitAppOrgUpdate, + TGroupProjectMembershipRoles, + TGroupProjectMembershipRolesInsert, + TGroupProjectMembershipRolesUpdate, + TGroupProjectMemberships, + TGroupProjectMembershipsInsert, + TGroupProjectMembershipsUpdate, + TGroups, + TGroupsInsert, + TGroupsUpdate, TIdentities, TIdentitiesInsert, TIdentitiesUpdate, TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate, + TIdentityGcpAuths, + TIdentityGcpAuthsInsert, + TIdentityGcpAuthsUpdate, + TIdentityKubernetesAuths, + TIdentityKubernetesAuthsInsert, + TIdentityKubernetesAuthsUpdate, TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, + TIdentityProjectAdditionalPrivilege, + TIdentityProjectAdditionalPrivilegeInsert, + TIdentityProjectAdditionalPrivilegeUpdate, + TIdentityProjectMembershipRole, + TIdentityProjectMembershipRoleInsert, + TIdentityProjectMembershipRoleUpdate, TIdentityProjectMemberships, TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate, @@ -50,6 +95,12 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TLdapConfigs, + TLdapConfigsInsert, + TLdapConfigsUpdate, + TLdapGroupMaps, + TLdapGroupMapsInsert, + TLdapGroupMapsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, @@ -80,9 +131,18 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TProjectUserAdditionalPrivilege, + TProjectUserAdditionalPrivilegeInsert, + TProjectUserAdditionalPrivilegeUpdate, + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, + TScimTokens, + TScimTokensInsert, + TScimTokensUpdate, TSecretApprovalPolicies, TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, @@ -158,9 +218,15 @@ import { TUserActions, TUserActionsInsert, TUserActionsUpdate, + TUserAliases, + TUserAliasesInsert, + TUserAliasesUpdate, TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, + TUserGroupMembership, + TUserGroupMembershipInsert, + TUserGroupMembershipUpdate, TUsers, TUsersInsert, TUsersUpdate, @@ -168,10 +234,28 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; +import { TSecretReferences, TSecretReferencesInsert, TSecretReferencesUpdate } from "@app/db/schemas/secret-references"; declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; + [TableName.Groups]: Knex.CompositeTableType; + [TableName.UserGroupMembership]: Knex.CompositeTableType< + TUserGroupMembership, + TUserGroupMembershipInsert, + TUserGroupMembershipUpdate + >; + [TableName.GroupProjectMembership]: Knex.CompositeTableType< + TGroupProjectMemberships, + TGroupProjectMembershipsInsert, + TGroupProjectMembershipsUpdate + >; + [TableName.GroupProjectMembershipRole]: Knex.CompositeTableType< + TGroupProjectMembershipRoles, + TGroupProjectMembershipRolesInsert, + TGroupProjectMembershipRolesUpdate + >; + [TableName.UserAliases]: Knex.CompositeTableType; [TableName.UserEncryptionKey]: Knex.CompositeTableType< TUserEncryptionKeys, TUserEncryptionKeysInsert, @@ -211,9 +295,24 @@ declare module "knex/types/tables" { TProjectEnvironmentsUpdate >; [TableName.ProjectBot]: Knex.CompositeTableType; + [TableName.ProjectUserMembershipRole]: Knex.CompositeTableType< + TProjectUserMembershipRoles, + TProjectUserMembershipRolesInsert, + TProjectUserMembershipRolesUpdate + >; [TableName.ProjectRoles]: Knex.CompositeTableType; + [TableName.ProjectUserAdditionalPrivilege]: Knex.CompositeTableType< + TProjectUserAdditionalPrivilege, + TProjectUserAdditionalPrivilegeInsert, + TProjectUserAdditionalPrivilegeUpdate + >; [TableName.ProjectKeys]: Knex.CompositeTableType; [TableName.Secret]: Knex.CompositeTableType; + [TableName.SecretReference]: Knex.CompositeTableType< + TSecretReferences, + TSecretReferencesInsert, + TSecretReferencesUpdate + >; [TableName.SecretBlindIndex]: Knex.CompositeTableType< TSecretBlindIndexes, TSecretBlindIndexesInsert, @@ -242,6 +341,21 @@ declare module "knex/types/tables" { TIdentityUniversalAuthsInsert, TIdentityUniversalAuthsUpdate >; + [TableName.IdentityKubernetesAuth]: Knex.CompositeTableType< + TIdentityKubernetesAuths, + TIdentityKubernetesAuthsInsert, + TIdentityKubernetesAuthsUpdate + >; + [TableName.IdentityGcpAuth]: Knex.CompositeTableType< + TIdentityGcpAuths, + TIdentityGcpAuthsInsert, + TIdentityGcpAuthsUpdate + >; + [TableName.IdentityAwsAuth]: Knex.CompositeTableType< + TIdentityAwsAuths, + TIdentityAwsAuthsInsert, + TIdentityAwsAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, @@ -262,6 +376,42 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [TableName.IdentityProjectMembershipRole]: Knex.CompositeTableType< + TIdentityProjectMembershipRole, + TIdentityProjectMembershipRoleInsert, + TIdentityProjectMembershipRoleUpdate + >; + [TableName.IdentityProjectAdditionalPrivilege]: Knex.CompositeTableType< + TIdentityProjectAdditionalPrivilege, + TIdentityProjectAdditionalPrivilegeInsert, + TIdentityProjectAdditionalPrivilegeUpdate + >; + + [TableName.AccessApprovalPolicy]: Knex.CompositeTableType< + TAccessApprovalPolicies, + TAccessApprovalPoliciesInsert, + TAccessApprovalPoliciesUpdate + >; + + [TableName.AccessApprovalPolicyApprover]: Knex.CompositeTableType< + TAccessApprovalPoliciesApprovers, + TAccessApprovalPoliciesApproversInsert, + TAccessApprovalPoliciesApproversUpdate + >; + + [TableName.AccessApprovalRequest]: Knex.CompositeTableType< + TAccessApprovalRequests, + TAccessApprovalRequestsInsert, + TAccessApprovalRequestsUpdate + >; + + [TableName.AccessApprovalRequestReviewer]: Knex.CompositeTableType< + TAccessApprovalRequestsReviewers, + TAccessApprovalRequestsReviewersInsert, + TAccessApprovalRequestsReviewersUpdate + >; + + [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, TSecretApprovalPoliciesInsert, @@ -313,9 +463,22 @@ declare module "knex/types/tables" { TSecretSnapshotFoldersInsert, TSecretSnapshotFoldersUpdate >; + [TableName.DynamicSecret]: Knex.CompositeTableType; + [TableName.DynamicSecretLease]: Knex.CompositeTableType< + TDynamicSecretLeases, + TDynamicSecretLeasesInsert, + TDynamicSecretLeasesUpdate + >; [TableName.SamlConfig]: Knex.CompositeTableType; + [TableName.LdapConfig]: Knex.CompositeTableType; + [TableName.LdapGroupMap]: Knex.CompositeTableType; [TableName.OrgBot]: Knex.CompositeTableType; [TableName.AuditLog]: Knex.CompositeTableType; + [TableName.AuditLogStream]: Knex.CompositeTableType< + TAuditLogStreams, + TAuditLogStreamsInsert, + TAuditLogStreamsUpdate + >; [TableName.GitAppInstallSession]: Knex.CompositeTableType< TGitAppInstallSessions, TGitAppInstallSessionsInsert, diff --git a/backend/src/cache/redis.ts b/backend/src/cache/redis.ts deleted file mode 100644 index 4e856fac1..000000000 --- a/backend/src/cache/redis.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Redis from "ioredis"; - -export const initRedisConnection = (redisUrl: string) => { - const redis = new Redis(redisUrl); - return redis; -}; diff --git a/backend/src/db/instance.ts b/backend/src/db/instance.ts index 2a321a3bc..bd4ce99c1 100644 --- a/backend/src/db/instance.ts +++ b/backend/src/db/instance.ts @@ -6,6 +6,13 @@ export const initDbConnection = ({ dbConnectionUri, dbRootCert }: { dbConnection client: "pg", connection: { connectionString: dbConnectionUri, + host: process.env.DB_HOST, + // @ts-expect-error I have no clue why only for the port there is a type error + // eslint-disable-next-line + port: process.env.DB_PORT, + user: process.env.DB_USER, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, ssl: dbRootCert ? { rejectUnauthorized: true, diff --git a/backend/src/db/knexfile.ts b/backend/src/db/knexfile.ts index ec7458da6..8af2b59ab 100644 --- a/backend/src/db/knexfile.ts +++ b/backend/src/db/knexfile.ts @@ -5,15 +5,31 @@ import dotenv from "dotenv"; import type { Knex } from "knex"; import path from "path"; -// Update with your config settings. +// Update with your config settings. . dotenv.config({ - path: path.join(__dirname, "../../.env"), - debug: true + path: path.join(__dirname, "../../../.env.migration") }); +dotenv.config({ + path: path.join(__dirname, "../../../.env") +}); + export default { development: { client: "postgres", - connection: process.env.DB_CONNECTION_URI, + connection: { + connectionString: process.env.DB_CONNECTION_URI, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + user: process.env.DB_USER, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_ROOT_CERT + ? { + rejectUnauthorized: true, + ca: Buffer.from(process.env.DB_ROOT_CERT, "base64").toString("ascii") + } + : false + }, pool: { min: 2, max: 10 @@ -27,7 +43,20 @@ export default { }, production: { client: "postgres", - connection: process.env.DB_CONNECTION_URI, + connection: { + connectionString: process.env.DB_CONNECTION_URI, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + user: process.env.DB_USER, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_ROOT_CERT + ? { + rejectUnauthorized: true, + ca: Buffer.from(process.env.DB_ROOT_CERT, "base64").toString("ascii") + } + : false + }, pool: { min: 2, max: 10 diff --git a/backend/src/db/migrations/20240204171758_org-based-auth.ts b/backend/src/db/migrations/20240204171758_org-based-auth.ts new file mode 100644 index 000000000..f2b2f913c --- /dev/null +++ b/backend/src/db/migrations/20240204171758_org-based-auth.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("authEnforced").defaultTo(false); + t.index("slug"); + }); + + await knex.schema.alterTable(TableName.SamlConfig, (t) => { + t.datetime("lastUsed"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("authEnforced"); + t.dropIndex("slug"); + }); + + await knex.schema.alterTable(TableName.SamlConfig, (t) => { + t.dropColumn("lastUsed"); + }); +} diff --git a/backend/src/db/migrations/20240208234120_scim-token.ts b/backend/src/db/migrations/20240208234120_scim-token.ts new file mode 100644 index 000000000..28121362a --- /dev/null +++ b/backend/src/db/migrations/20240208234120_scim-token.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ScimToken))) { + await knex.schema.createTable(TableName.ScimToken, (t) => { + t.string("id", 36).primary().defaultTo(knex.fn.uuid()); + t.bigInteger("ttlDays").defaultTo(365).notNullable(); + t.string("description").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await knex.schema.alterTable(TableName.Organization, (t) => { + t.boolean("scimEnabled").defaultTo(false); + }); + + await createOnUpdateTrigger(knex, TableName.ScimToken); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ScimToken); + await dropOnUpdateTrigger(knex, TableName.ScimToken); + await knex.schema.alterTable(TableName.Organization, (t) => { + t.dropColumn("scimEnabled"); + }); +} diff --git a/backend/src/db/migrations/20240216154123_ghost_users.ts b/backend/src/db/migrations/20240216154123_ghost_users.ts new file mode 100644 index 000000000..d0a840ee2 --- /dev/null +++ b/backend/src/db/migrations/20240216154123_ghost_users.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { ProjectVersion, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGhostUserColumn = await knex.schema.hasColumn(TableName.Users, "isGhost"); + const hasProjectVersionColumn = await knex.schema.hasColumn(TableName.Project, "version"); + + if (!hasGhostUserColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isGhost").defaultTo(false).notNullable(); + }); + } + + if (!hasProjectVersionColumn) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.integer("version").defaultTo(ProjectVersion.V1).notNullable(); + t.string("upgradeStatus").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGhostUserColumn = await knex.schema.hasColumn(TableName.Users, "isGhost"); + const hasProjectVersionColumn = await knex.schema.hasColumn(TableName.Project, "version"); + + if (hasGhostUserColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isGhost"); + }); + } + + if (hasProjectVersionColumn) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("version"); + t.dropColumn("upgradeStatus"); + }); + } +} diff --git a/backend/src/db/migrations/20240222201806_admin-signup-control.ts b/backend/src/db/migrations/20240222201806_admin-signup-control.ts new file mode 100644 index 000000000..c52f753c0 --- /dev/null +++ b/backend/src/db/migrations/20240222201806_admin-signup-control.ts @@ -0,0 +1,20 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const isTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isTablePresent) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.string("allowedSignUpDomain"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SuperAdmin, "allowedSignUpDomain")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("allowedSignUpDomain"); + }); + } +} diff --git a/backend/src/db/migrations/20240226094411_instance-id.ts b/backend/src/db/migrations/20240226094411_instance-id.ts new file mode 100644 index 000000000..094defdc8 --- /dev/null +++ b/backend/src/db/migrations/20240226094411_instance-id.ts @@ -0,0 +1,25 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const ADMIN_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.uuid("instanceId").notNullable().defaultTo(knex.fn.uuid()); + }); + + const superUserConfigExists = await knex(TableName.SuperAdmin).where("id", ADMIN_CONFIG_UUID).first(); + if (!superUserConfigExists) { + // eslint-disable-next-line + await knex(TableName.SuperAdmin).update({ id: ADMIN_CONFIG_UUID }).whereNotNull("id").limit(1); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("instanceId"); + }); +} diff --git a/backend/src/db/migrations/20240307232900_integration-last-used.ts b/backend/src/db/migrations/20240307232900_integration-last-used.ts new file mode 100644 index 000000000..c64c31881 --- /dev/null +++ b/backend/src/db/migrations/20240307232900_integration-last-used.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Integration, (t) => { + t.datetime("lastUsed"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Integration, (t) => { + t.dropColumn("lastUsed"); + }); +} diff --git a/backend/src/db/migrations/20240311210135_ldap-config.ts b/backend/src/db/migrations/20240311210135_ldap-config.ts new file mode 100644 index 000000000..93ac2c7ac --- /dev/null +++ b/backend/src/db/migrations/20240311210135_ldap-config.ts @@ -0,0 +1,68 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.LdapConfig))) { + await knex.schema.createTable(TableName.LdapConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable().unique(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.boolean("isActive").notNullable(); + t.string("url").notNullable(); + t.string("encryptedBindDN").notNullable(); + t.string("bindDNIV").notNullable(); + t.string("bindDNTag").notNullable(); + t.string("encryptedBindPass").notNullable(); + t.string("bindPassIV").notNullable(); + t.string("bindPassTag").notNullable(); + t.string("searchBase").notNullable(); + t.text("encryptedCACert").notNullable(); + t.string("caCertIV").notNullable(); + t.string("caCertTag").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.LdapConfig); + + if (!(await knex.schema.hasTable(TableName.UserAliases))) { + await knex.schema.createTable(TableName.UserAliases, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.string("username").notNullable(); + t.string("aliasType").notNullable(); + t.string("externalId").notNullable(); + t.specificType("emails", "text[]"); + t.uuid("orgId").nullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.UserAliases); + + await knex.schema.alterTable(TableName.Users, (t) => { + t.string("username").unique(); + t.string("email").nullable().alter(); + t.dropUnique(["email"]); + }); + + await knex(TableName.Users).update("username", knex.ref("email")); + + await knex.schema.alterTable(TableName.Users, (t) => { + t.string("username").notNullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.LdapConfig); + await knex.schema.dropTableIfExists(TableName.UserAliases); + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("username"); + // t.string("email").notNullable().alter(); + }); + await dropOnUpdateTrigger(knex, TableName.LdapConfig); +} diff --git a/backend/src/db/migrations/20240312162549_temp-roles.ts b/backend/src/db/migrations/20240312162549_temp-roles.ts new file mode 100644 index 000000000..ec78821fe --- /dev/null +++ b/backend/src/db/migrations/20240312162549_temp-roles.ts @@ -0,0 +1,50 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.ProjectUserMembershipRole); + if (!doesTableExist) { + await knex.schema.createTable(TableName.ProjectUserMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); + + const projectMemberships = await knex(TableName.ProjectMembership).select( + "id", + "role", + "createdAt", + "updatedAt", + knex.ref("roleId").withSchema(TableName.ProjectMembership).as("customRoleId") + ); + if (projectMemberships.length) + await knex.batchInsert( + TableName.ProjectUserMembershipRole, + projectMemberships.map((data) => ({ ...data, projectMembershipId: data.id })) + ); + // will be dropped later + // await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + // t.dropColumn("roleId"); + // t.dropColumn("role"); + // }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ProjectUserMembershipRole); + await dropOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole); +} diff --git a/backend/src/db/migrations/20240312162556_temp-role-identity.ts b/backend/src/db/migrations/20240312162556_temp-role-identity.ts new file mode 100644 index 000000000..dbd188d89 --- /dev/null +++ b/backend/src/db/migrations/20240312162556_temp-role-identity.ts @@ -0,0 +1,52 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.IdentityProjectMembershipRole); + if (!doesTableExist) { + await knex.schema.createTable(TableName.IdentityProjectMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId") + .references("id") + .inTable(TableName.IdentityProjectMembership) + .onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole); + + const identityMemberships = await knex(TableName.IdentityProjectMembership).select( + "id", + "role", + "createdAt", + "updatedAt", + knex.ref("roleId").withSchema(TableName.IdentityProjectMembership).as("customRoleId") + ); + if (identityMemberships.length) + await knex.batchInsert( + TableName.IdentityProjectMembershipRole, + identityMemberships.map((data) => ({ ...data, projectMembershipId: data.id })) + ); + // await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => { + // t.dropColumn("roleId"); + // t.dropColumn("role"); + // }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole); +} diff --git a/backend/src/db/migrations/20240318164718_dynamic-secret.ts b/backend/src/db/migrations/20240318164718_dynamic-secret.ts new file mode 100644 index 000000000..743744a03 --- /dev/null +++ b/backend/src/db/migrations/20240318164718_dynamic-secret.ts @@ -0,0 +1,58 @@ +import { Knex } from "knex"; + +import { SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const doesTableExist = await knex.schema.hasTable(TableName.DynamicSecret); + if (!doesTableExist) { + await knex.schema.createTable(TableName.DynamicSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.integer("version").notNullable(); + t.string("type").notNullable(); + t.string("defaultTTL").notNullable(); + t.string("maxTTL"); + t.string("inputIV").notNullable(); + t.text("inputCiphertext").notNullable(); + t.string("inputTag").notNullable(); + t.string("algorithm").notNullable().defaultTo(SecretEncryptionAlgo.AES_256_GCM); + t.string("keyEncoding").notNullable().defaultTo(SecretKeyEncoding.UTF8); + t.uuid("folderId").notNullable(); + // for background process communication + t.string("status"); + t.string("statusDetails"); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.unique(["name", "folderId"]); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.DynamicSecret); + + const doesTableDynamicSecretLease = await knex.schema.hasTable(TableName.DynamicSecretLease); + if (!doesTableDynamicSecretLease) { + await knex.schema.createTable(TableName.DynamicSecretLease, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("version").notNullable(); + t.string("externalEntityId").notNullable(); + t.datetime("expireAt").notNullable(); + // for background process communication + t.string("status"); + t.string("statusDetails"); + t.uuid("dynamicSecretId").notNullable(); + t.foreign("dynamicSecretId").references("id").inTable(TableName.DynamicSecret).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.DynamicSecretLease); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.DynamicSecretLease); + await knex.schema.dropTableIfExists(TableName.DynamicSecretLease); + + await dropOnUpdateTrigger(knex, TableName.DynamicSecret); + await knex.schema.dropTableIfExists(TableName.DynamicSecret); +} diff --git a/backend/src/db/migrations/20240326172010_project-user-additional-privilege.ts b/backend/src/db/migrations/20240326172010_project-user-additional-privilege.ts new file mode 100644 index 000000000..0366ba507 --- /dev/null +++ b/backend/src/db/migrations/20240326172010_project-user-additional-privilege.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ProjectUserAdditionalPrivilege))) { + await knex.schema.createTable(TableName.ProjectUserAdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("slug", 60).notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.jsonb("permissions").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.ProjectUserAdditionalPrivilege); +} diff --git a/backend/src/db/migrations/20240326172011_machine-identity-additional-privilege.ts b/backend/src/db/migrations/20240326172011_machine-identity-additional-privilege.ts new file mode 100644 index 000000000..c59fc685a --- /dev/null +++ b/backend/src/db/migrations/20240326172011_machine-identity-additional-privilege.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityProjectAdditionalPrivilege))) { + await knex.schema.createTable(TableName.IdentityProjectAdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("slug", 60).notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId") + .references("id") + .inTable(TableName.IdentityProjectMembership) + .onDelete("CASCADE"); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.jsonb("permissions").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.IdentityProjectAdditionalPrivilege); +} diff --git a/backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts b/backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts new file mode 100644 index 000000000..b97c024de --- /dev/null +++ b/backend/src/db/migrations/20240405000045_org-memberships-unique-constraint.ts @@ -0,0 +1,112 @@ +import { Knex } from "knex"; +import { z } from "zod"; + +import { TableName, TOrgMemberships } from "../schemas"; + +const validateOrgMembership = (membershipToValidate: TOrgMemberships, firstMembership: TOrgMemberships) => { + const firstOrgId = firstMembership.orgId; + const firstUserId = firstMembership.userId; + + if (membershipToValidate.id === firstMembership.id) { + return; + } + + if (membershipToValidate.inviteEmail !== firstMembership.inviteEmail) { + throw new Error(`Invite emails are different for the same userId and orgId: ${firstUserId}, ${firstOrgId}`); + } + if (membershipToValidate.orgId !== firstMembership.orgId) { + throw new Error(`OrgIds are different for the same userId and orgId: ${firstUserId}, ${firstOrgId}`); + } + if (membershipToValidate.role !== firstMembership.role) { + throw new Error(`Roles are different for the same userId and orgId: ${firstUserId}, ${firstOrgId}`); + } + if (membershipToValidate.roleId !== firstMembership.roleId) { + throw new Error(`RoleIds are different for the same userId and orgId: ${firstUserId}, ${firstOrgId}`); + } + if (membershipToValidate.status !== firstMembership.status) { + throw new Error(`Statuses are different for the same userId and orgId: ${firstUserId}, ${firstOrgId}`); + } + if (membershipToValidate.userId !== firstMembership.userId) { + throw new Error(`UserIds are different for the same userId and orgId: ${firstUserId}, ${firstOrgId}`); + } +}; + +export async function up(knex: Knex): Promise { + const RowSchema = z.object({ + userId: z.string(), + orgId: z.string(), + cnt: z.string() + }); + + // Transactional find and delete duplicate rows + await knex.transaction(async (tx) => { + const duplicateRows = await tx(TableName.OrgMembership) + .select("userId", "orgId") // Select the userId and orgId so we can group by them + .whereNotNull("userId") // Ensure that the userId is not null + .count("* as cnt") // Count the number of rows for each userId and orgId, so we can make sure there are more than 1 row (a duplicate) + .groupBy("userId", "orgId") + .havingRaw("count(*) > ?", [1]); // Using havingRaw for direct SQL expressions + + // Parse the rows to ensure they are in the correct format, and for type safety + const parsedRows = RowSchema.array().parse(duplicateRows); + + // For each of the duplicate rows, loop through and find the actual memberships to delete + for (const row of parsedRows) { + const count = Number(row.cnt); + + // An extra check to ensure that the count is actually a number, and the number is greater than 2 + if (typeof count !== "number" || count < 2) { + // eslint-disable-next-line no-continue + continue; + } + + // Find all the organization memberships that have the same userId and orgId + // eslint-disable-next-line no-await-in-loop + const rowsToDelete = await tx(TableName.OrgMembership).where({ + userId: row.userId, + orgId: row.orgId + }); + + // Ensure that all the rows have exactly the same value, except id, createdAt, updatedAt + for (const rowToDelete of rowsToDelete) { + validateOrgMembership(rowToDelete, rowsToDelete[0]); + } + + // Find the row with the latest createdAt, which we will keep + + let lowestCreatedAt: number | null = null; + let latestCreatedRow: TOrgMemberships | null = null; + + for (const rowToDelete of rowsToDelete) { + if (lowestCreatedAt === null || rowToDelete.createdAt.getTime() < lowestCreatedAt) { + lowestCreatedAt = rowToDelete.createdAt.getTime(); + latestCreatedRow = rowToDelete; + } + } + if (!latestCreatedRow) { + throw new Error("Failed to find last created membership"); + } + + // Filter out the latest row from the rows to delete + const membershipIdsToDelete = rowsToDelete.map((r) => r.id).filter((id) => id !== latestCreatedRow!.id); + + // eslint-disable-next-line no-await-in-loop + const numberOfRowsDeleted = await tx(TableName.OrgMembership).whereIn("id", membershipIdsToDelete).delete(); + + // eslint-disable-next-line no-console + console.log( + `Deleted ${numberOfRowsDeleted} duplicate organization memberships for ${row.userId} and ${row.orgId}` + ); + } + }); + + await knex.schema.alterTable(TableName.OrgMembership, (table) => { + table.unique(["userId", "orgId"]); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.OrgMembership, (table) => { + table.dropUnique(["userId", "orgId"]); + }); +} diff --git a/backend/src/db/migrations/20240412174842_group.ts b/backend/src/db/migrations/20240412174842_group.ts new file mode 100644 index 000000000..53014dc53 --- /dev/null +++ b/backend/src/db/migrations/20240412174842_group.ts @@ -0,0 +1,82 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.Groups))) { + await knex.schema.createTable(TableName.Groups, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("name").notNullable(); + t.string("slug").notNullable(); + t.unique(["orgId", "slug"]); + t.string("role").notNullable(); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.OrgRoles); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.Groups); + + if (!(await knex.schema.hasTable(TableName.UserGroupMembership))) { + await knex.schema.createTable(TableName.UserGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); // link to user and link to groups cascade on groups + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.UserGroupMembership); + + if (!(await knex.schema.hasTable(TableName.GroupProjectMembership))) { + await knex.schema.createTable(TableName.GroupProjectMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.GroupProjectMembership); + + if (!(await knex.schema.hasTable(TableName.GroupProjectMembershipRole))) { + await knex.schema.createTable(TableName.GroupProjectMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.GroupProjectMembership).onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.GroupProjectMembershipRole); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.GroupProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.GroupProjectMembershipRole); + + await knex.schema.dropTableIfExists(TableName.UserGroupMembership); + await dropOnUpdateTrigger(knex, TableName.UserGroupMembership); + + await knex.schema.dropTableIfExists(TableName.GroupProjectMembership); + await dropOnUpdateTrigger(knex, TableName.GroupProjectMembership); + + await knex.schema.dropTableIfExists(TableName.Groups); + await dropOnUpdateTrigger(knex, TableName.Groups); +} diff --git a/backend/src/db/migrations/20240414192520_drop-role-roleid-project-membership.ts b/backend/src/db/migrations/20240414192520_drop-role-roleid-project-membership.ts new file mode 100644 index 000000000..2dd58c5d1 --- /dev/null +++ b/backend/src/db/migrations/20240414192520_drop-role-roleid-project-membership.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { ProjectMembershipRole, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesProjectRoleFieldExist = await knex.schema.hasColumn(TableName.ProjectMembership, "role"); + const doesProjectRoleIdFieldExist = await knex.schema.hasColumn(TableName.ProjectMembership, "roleId"); + await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + if (doesProjectRoleFieldExist) t.dropColumn("roleId"); + if (doesProjectRoleIdFieldExist) t.dropColumn("role"); + }); + + const doesIdentityProjectRoleFieldExist = await knex.schema.hasColumn(TableName.IdentityProjectMembership, "role"); + const doesIdentityProjectRoleIdFieldExist = await knex.schema.hasColumn( + TableName.IdentityProjectMembership, + "roleId" + ); + await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => { + if (doesIdentityProjectRoleFieldExist) t.dropColumn("roleId"); + if (doesIdentityProjectRoleIdFieldExist) t.dropColumn("role"); + }); +} + +export async function down(knex: Knex): Promise { + const doesProjectRoleFieldExist = await knex.schema.hasColumn(TableName.ProjectMembership, "role"); + const doesProjectRoleIdFieldExist = await knex.schema.hasColumn(TableName.ProjectMembership, "roleId"); + await knex.schema.alterTable(TableName.ProjectMembership, (t) => { + if (!doesProjectRoleFieldExist) t.string("role").defaultTo(ProjectMembershipRole.Member); + if (!doesProjectRoleIdFieldExist) { + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); + } + }); + + const doesIdentityProjectRoleFieldExist = await knex.schema.hasColumn(TableName.IdentityProjectMembership, "role"); + const doesIdentityProjectRoleIdFieldExist = await knex.schema.hasColumn( + TableName.IdentityProjectMembership, + "roleId" + ); + await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => { + if (!doesIdentityProjectRoleFieldExist) t.string("role").defaultTo(ProjectMembershipRole.Member); + if (!doesIdentityProjectRoleIdFieldExist) { + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.ProjectRoles); + } + }); +} diff --git a/backend/src/db/migrations/20240417032913_pending-group-addition.ts b/backend/src/db/migrations/20240417032913_pending-group-addition.ts new file mode 100644 index 000000000..70fe22727 --- /dev/null +++ b/backend/src/db/migrations/20240417032913_pending-group-addition.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.UserGroupMembership, (t) => { + t.boolean("isPending").notNullable().defaultTo(false); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.UserGroupMembership, (t) => { + t.dropColumn("isPending"); + }); +} diff --git a/backend/src/db/migrations/20240423023203_ldap-config-groups.ts b/backend/src/db/migrations/20240423023203_ldap-config-groups.ts new file mode 100644 index 000000000..dd4da5123 --- /dev/null +++ b/backend/src/db/migrations/20240423023203_ldap-config-groups.ts @@ -0,0 +1,34 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.LdapGroupMap))) { + await knex.schema.createTable(TableName.LdapGroupMap, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("ldapConfigId").notNullable(); + t.foreign("ldapConfigId").references("id").inTable(TableName.LdapConfig).onDelete("CASCADE"); + t.string("ldapGroupCN").notNullable(); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.unique(["ldapGroupCN", "groupId", "ldapConfigId"]); + }); + } + + await createOnUpdateTrigger(knex, TableName.LdapGroupMap); + + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.string("groupSearchBase").notNullable().defaultTo(""); + t.string("groupSearchFilter").notNullable().defaultTo(""); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.LdapGroupMap); + await dropOnUpdateTrigger(knex, TableName.LdapGroupMap); + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.dropColumn("groupSearchBase"); + t.dropColumn("groupSearchFilter"); + }); +} diff --git a/backend/src/db/migrations/20240424235842_user-search-filter.ts b/backend/src/db/migrations/20240424235842_user-search-filter.ts new file mode 100644 index 000000000..c078acf84 --- /dev/null +++ b/backend/src/db/migrations/20240424235842_user-search-filter.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.string("searchFilter").notNullable().defaultTo(""); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.LdapConfig, (t) => { + t.dropColumn("searchFilter"); + }); +} diff --git a/backend/src/db/migrations/20240429154610_audit-log-index.ts b/backend/src/db/migrations/20240429154610_audit-log-index.ts new file mode 100644 index 000000000..40a1cb24d --- /dev/null +++ b/backend/src/db/migrations/20240429154610_audit-log-index.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + const doesCreatedAtExist = await knex.schema.hasColumn(TableName.AuditLog, "createdAt"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist && doesCreatedAtExist) t.index(["projectId", "createdAt"]); + if (doesOrgIdExist && doesCreatedAtExist) t.index(["orgId", "createdAt"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + const doesCreatedAtExist = await knex.schema.hasColumn(TableName.AuditLog, "createdAt"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist && doesCreatedAtExist) t.dropIndex(["projectId", "createdAt"]); + if (doesOrgIdExist && doesCreatedAtExist) t.dropIndex(["orgId", "createdAt"]); + }); + } +} diff --git a/backend/src/db/migrations/20240503101144_audit-log-stream.ts b/backend/src/db/migrations/20240503101144_audit-log-stream.ts new file mode 100644 index 000000000..210ee1bfa --- /dev/null +++ b/backend/src/db/migrations/20240503101144_audit-log-stream.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AuditLogStream))) { + await knex.schema.createTable(TableName.AuditLogStream, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("url").notNullable(); + t.text("encryptedHeadersCiphertext"); + t.text("encryptedHeadersIV"); + t.text("encryptedHeadersTag"); + t.string("encryptedHeadersAlgorithm"); + t.string("encryptedHeadersKeyEncoding"); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.AuditLogStream); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.AuditLogStream); + await knex.schema.dropTableIfExists(TableName.AuditLogStream); +} diff --git a/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts new file mode 100644 index 000000000..410ee0f00 --- /dev/null +++ b/backend/src/db/migrations/20240507032811_trusted-saml-ldap-emails.ts @@ -0,0 +1,54 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const isUsersTablePresent = await knex.schema.hasTable(TableName.Users); + if (isUsersTablePresent) { + const hasIsEmailVerifiedColumn = await knex.schema.hasColumn(TableName.Users, "isEmailVerified"); + + if (!hasIsEmailVerifiedColumn) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + } + + // Backfilling the isEmailVerified to true where isAccepted is true + await knex(TableName.Users).update({ isEmailVerified: true }).where("isAccepted", true); + } + + const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases); + if (isUserAliasTablePresent) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.string("username").nullable().alter(); + }); + } + + const isSuperAdminTablePresent = await knex.schema.hasTable(TableName.SuperAdmin); + if (isSuperAdminTablePresent) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.boolean("trustSamlEmails").defaultTo(false); + t.boolean("trustLdapEmails").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Users, "isEmailVerified")) { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustSamlEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustSamlEmails"); + }); + } + + if (await knex.schema.hasColumn(TableName.SuperAdmin, "trustLdapEmails")) { + await knex.schema.alterTable(TableName.SuperAdmin, (t) => { + t.dropColumn("trustLdapEmails"); + }); + } +} diff --git a/backend/src/db/migrations/20240507162140_access-approval-policy.ts b/backend/src/db/migrations/20240507162140_access-approval-policy.ts new file mode 100644 index 000000000..feeecd25b --- /dev/null +++ b/backend/src/db/migrations/20240507162140_access-approval-policy.ts @@ -0,0 +1,41 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicy))) { + await knex.schema.createTable(TableName.AccessApprovalPolicy, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.integer("approvals").defaultTo(1).notNullable(); + t.string("secretPath"); + + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); + } + + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("approverId").notNullable(); + t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy); + + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy); +} diff --git a/backend/src/db/migrations/20240507162141_access.ts b/backend/src/db/migrations/20240507162141_access.ts new file mode 100644 index 000000000..901be9a78 --- /dev/null +++ b/backend/src/db/migrations/20240507162141_access.ts @@ -0,0 +1,51 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalRequest))) { + await knex.schema.createTable(TableName.AccessApprovalRequest, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + + t.uuid("privilegeId").nullable(); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + + t.uuid("requestedBy").notNullable(); + t.foreign("requestedBy").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + + // We use these values to create the actual privilege at a later point in time. + t.boolean("isTemporary").notNullable(); + t.string("temporaryRange").nullable(); + + t.jsonb("permissions").notNullable(); + + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalRequest); + + if (!(await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer))) { + await knex.schema.createTable(TableName.AccessApprovalRequestReviewer, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("member").notNullable(); + t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.string("status").notNullable(); + t.uuid("requestId").notNullable(); + t.foreign("requestId").references("id").inTable(TableName.AccessApprovalRequest).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer); + await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest); + + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest); +} diff --git a/backend/src/db/migrations/20240507210655_identity-aws-auth.ts b/backend/src/db/migrations/20240507210655_identity-aws-auth.ts new file mode 100644 index 000000000..f182425c3 --- /dev/null +++ b/backend/src/db/migrations/20240507210655_identity-aws-auth.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityAwsAuth))) { + await knex.schema.createTable(TableName.IdentityAwsAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); + t.string("stsEndpoint").notNullable(); + t.string("allowedPrincipalArns").notNullable(); + t.string("allowedAccountIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAwsAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAwsAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAwsAuth); +} diff --git a/backend/src/db/migrations/20240514041650_identity-gcp-auth.ts b/backend/src/db/migrations/20240514041650_identity-gcp-auth.ts new file mode 100644 index 000000000..8c80fed84 --- /dev/null +++ b/backend/src/db/migrations/20240514041650_identity-gcp-auth.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityGcpAuth))) { + await knex.schema.createTable(TableName.IdentityGcpAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); + t.string("allowedServiceAccounts").notNullable(); + t.string("allowedProjects").notNullable(); + t.string("allowedZones").notNullable(); // GCE only (fully qualified zone names) + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityGcpAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityGcpAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityGcpAuth); +} diff --git a/backend/src/db/migrations/20240514141809_inline-secret-reference-sync.ts b/backend/src/db/migrations/20240514141809_inline-secret-reference-sync.ts new file mode 100644 index 000000000..fa6fb4fea --- /dev/null +++ b/backend/src/db/migrations/20240514141809_inline-secret-reference-sync.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretReference))) { + await knex.schema.createTable(TableName.SecretReference, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("environment").notNullable(); + t.string("secretPath").notNullable(); + t.uuid("secretId").notNullable(); + t.foreign("secretId").references("id").inTable(TableName.Secret).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretReference); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretReference); + await dropOnUpdateTrigger(knex, TableName.SecretReference); +} diff --git a/backend/src/db/migrations/20240518142614_kubernetes-auth.ts b/backend/src/db/migrations/20240518142614_kubernetes-auth.ts new file mode 100644 index 000000000..dd281a3ad --- /dev/null +++ b/backend/src/db/migrations/20240518142614_kubernetes-auth.ts @@ -0,0 +1,36 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityKubernetesAuth))) { + await knex.schema.createTable(TableName.IdentityKubernetesAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("kubernetesHost").notNullable(); + t.text("encryptedCaCert").notNullable(); + t.string("caCertIV").notNullable(); + t.string("caCertTag").notNullable(); + t.text("encryptedTokenReviewerJwt").notNullable(); + t.string("tokenReviewerJwtIV").notNullable(); + t.string("tokenReviewerJwtTag").notNullable(); + t.string("allowedNamespaces").notNullable(); + t.string("allowedNames").notNullable(); + t.string("allowedAudience").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityKubernetesAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityKubernetesAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityKubernetesAuth); +} diff --git a/backend/src/db/migrations/20240520064127_add-integration-sync-status.ts b/backend/src/db/migrations/20240520064127_add-integration-sync-status.ts new file mode 100644 index 000000000..74b828714 --- /dev/null +++ b/backend/src/db/migrations/20240520064127_add-integration-sync-status.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIsSyncedColumn = await knex.schema.hasColumn(TableName.Integration, "isSynced"); + const hasSyncMessageColumn = await knex.schema.hasColumn(TableName.Integration, "syncMessage"); + const hasLastSyncJobId = await knex.schema.hasColumn(TableName.Integration, "lastSyncJobId"); + + await knex.schema.alterTable(TableName.Integration, (t) => { + if (!hasIsSyncedColumn) { + t.boolean("isSynced").nullable(); + } + + if (!hasSyncMessageColumn) { + t.text("syncMessage").nullable(); + } + + if (!hasLastSyncJobId) { + t.string("lastSyncJobId").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasIsSyncedColumn = await knex.schema.hasColumn(TableName.Integration, "isSynced"); + const hasSyncMessageColumn = await knex.schema.hasColumn(TableName.Integration, "syncMessage"); + const hasLastSyncJobId = await knex.schema.hasColumn(TableName.Integration, "lastSyncJobId"); + + await knex.schema.alterTable(TableName.Integration, (t) => { + if (hasIsSyncedColumn) { + t.dropColumn("isSynced"); + } + + if (hasSyncMessageColumn) { + t.dropColumn("syncMessage"); + } + + if (hasLastSyncJobId) { + t.dropColumn("lastSyncJobId"); + } + }); +} diff --git a/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts b/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts new file mode 100644 index 000000000..7b208f010 --- /dev/null +++ b/backend/src/db/migrations/20240522193447_index-audit-logs-project-id-org-id.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist) t.index("projectId"); + if (doesOrgIdExist) t.index("orgId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesOrgIdExist = await knex.schema.hasColumn(TableName.AuditLog, "orgId"); + const doesProjectIdExist = await knex.schema.hasColumn(TableName.AuditLog, "projectId"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesProjectIdExist) t.dropIndex("projectId"); + if (doesOrgIdExist) t.dropIndex("orgId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts b/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts new file mode 100644 index 000000000..59fe14145 --- /dev/null +++ b/backend/src/db/migrations/20240522203425_index-secret-snapshot-secrets-envid.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "envId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesEnvIdExist) t.index("envId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "envId"); + + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesEnvIdExist) t.dropIndex("envId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts b/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts new file mode 100644 index 000000000..f01c0d3cc --- /dev/null +++ b/backend/src/db/migrations/20240522204414_index-secret-version-envId.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SecretVersion, "envId"); + if (await knex.schema.hasTable(TableName.SecretVersion)) { + await knex.schema.alterTable(TableName.SecretVersion, (t) => { + if (doesEnvIdExist) t.index("envId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesEnvIdExist = await knex.schema.hasColumn(TableName.SecretVersion, "envId"); + + if (await knex.schema.hasTable(TableName.SecretVersion)) { + await knex.schema.alterTable(TableName.SecretVersion, (t) => { + if (doesEnvIdExist) t.dropIndex("envId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts b/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts new file mode 100644 index 000000000..7f200ed3e --- /dev/null +++ b/backend/src/db/migrations/20240522212706_secret-snapshot-secrets-index-on-snapshotId.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSnapshotIdExist) t.index("snapshotId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSnapshotIdExist) t.dropIndex("snapshotId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts b/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts new file mode 100644 index 000000000..ffb7c3336 --- /dev/null +++ b/backend/src/db/migrations/20240522221147_secret-snapshot-folder-index-on-snapshotId.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotFolder, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotFolder)) { + await knex.schema.alterTable(TableName.SnapshotFolder, (t) => { + if (doesSnapshotIdExist) t.index("snapshotId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSnapshotIdExist = await knex.schema.hasColumn(TableName.SnapshotFolder, "snapshotId"); + if (await knex.schema.hasTable(TableName.SnapshotFolder)) { + await knex.schema.alterTable(TableName.SnapshotFolder, (t) => { + if (doesSnapshotIdExist) t.dropIndex("snapshotId"); + }); + } +} diff --git a/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts b/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts new file mode 100644 index 000000000..f1225e264 --- /dev/null +++ b/backend/src/db/migrations/20240522225402_secrets-index-on-folder-id-user-id.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesFolderIdExist = await knex.schema.hasColumn(TableName.Secret, "folderId"); + const doesUserIdExist = await knex.schema.hasColumn(TableName.Secret, "userId"); + if (await knex.schema.hasTable(TableName.Secret)) { + await knex.schema.alterTable(TableName.Secret, (t) => { + if (doesFolderIdExist && doesUserIdExist) t.index(["folderId", "userId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesFolderIdExist = await knex.schema.hasColumn(TableName.Secret, "folderId"); + const doesUserIdExist = await knex.schema.hasColumn(TableName.Secret, "userId"); + + if (await knex.schema.hasTable(TableName.Secret)) { + await knex.schema.alterTable(TableName.Secret, (t) => { + if (doesUserIdExist && doesFolderIdExist) t.dropIndex(["folderId", "userId"]); + }); + } +} diff --git a/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts b/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts new file mode 100644 index 000000000..b6dbf3e74 --- /dev/null +++ b/backend/src/db/migrations/20240523003158_audit-log-add-expireAt-index.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesExpireAtExist = await knex.schema.hasColumn(TableName.AuditLog, "expiresAt"); + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesExpireAtExist) t.index("expiresAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesExpireAtExist = await knex.schema.hasColumn(TableName.AuditLog, "expiresAt"); + + if (await knex.schema.hasTable(TableName.AuditLog)) { + await knex.schema.alterTable(TableName.AuditLog, (t) => { + if (doesExpireAtExist) t.dropIndex("expiresAt"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts new file mode 100644 index 000000000..4ebbfa9ae --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-approvers.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalPoliciesApproversSchema = z.object({ + id: z.string().uuid(), + approverId: z.string().uuid(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesApprovers = z.infer; +export type TAccessApprovalPoliciesApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/pg-migrator/src/schemas/secret-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts similarity index 54% rename from pg-migrator/src/schemas/secret-approval-policies.ts rename to backend/src/db/schemas/access-approval-policies.ts index ec859bb4e..69068d23b 100644 --- a/pg-migrator/src/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -7,16 +7,18 @@ import { z } from "zod"; import { TImmutableDBKeys } from "./models"; -export const SecretApprovalPoliciesSchema = z.object({ +export const AccessApprovalPoliciesSchema = z.object({ id: z.string().uuid(), name: z.string(), - secretPath: z.string().nullable().optional(), approvals: z.number().default(1), + secretPath: z.string().nullable().optional(), envId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date(), + updatedAt: z.date() }); -export type TSecretApprovalPolicies = z.infer; -export type TSecretApprovalPoliciesInsert = Omit; -export type TSecretApprovalPoliciesUpdate = Partial>; +export type TAccessApprovalPolicies = z.infer; +export type TAccessApprovalPoliciesInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalPoliciesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests-reviewers.ts b/backend/src/db/schemas/access-approval-requests-reviewers.ts new file mode 100644 index 000000000..509fd7425 --- /dev/null +++ b/backend/src/db/schemas/access-approval-requests-reviewers.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalRequestsReviewersSchema = z.object({ + id: z.string().uuid(), + member: z.string().uuid(), + status: z.string(), + requestId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalRequestsReviewers = z.infer; +export type TAccessApprovalRequestsReviewersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalRequestsReviewersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts new file mode 100644 index 000000000..bd598bac6 --- /dev/null +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalRequestsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + privilegeId: z.string().uuid().nullable().optional(), + requestedBy: z.string().uuid(), + isTemporary: z.boolean(), + temporaryRange: z.string().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalRequests = z.infer; +export type TAccessApprovalRequestsInsert = Omit, TImmutableDBKeys>; +export type TAccessApprovalRequestsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/api-keys.ts b/backend/src/db/schemas/api-keys.ts index ff29a54e1..cf836fd88 100644 --- a/backend/src/db/schemas/api-keys.ts +++ b/backend/src/db/schemas/api-keys.ts @@ -19,5 +19,5 @@ export const ApiKeysSchema = z.object({ }); export type TApiKeys = z.infer; -export type TApiKeysInsert = Omit; -export type TApiKeysUpdate = Partial>; +export type TApiKeysInsert = Omit, TImmutableDBKeys>; +export type TApiKeysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/audit-log-streams.ts b/backend/src/db/schemas/audit-log-streams.ts new file mode 100644 index 000000000..901dd8d27 --- /dev/null +++ b/backend/src/db/schemas/audit-log-streams.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AuditLogStreamsSchema = z.object({ + id: z.string().uuid(), + url: z.string(), + encryptedHeadersCiphertext: z.string().nullable().optional(), + encryptedHeadersIV: z.string().nullable().optional(), + encryptedHeadersTag: z.string().nullable().optional(), + encryptedHeadersAlgorithm: z.string().nullable().optional(), + encryptedHeadersKeyEncoding: z.string().nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAuditLogStreams = z.infer; +export type TAuditLogStreamsInsert = Omit, TImmutableDBKeys>; +export type TAuditLogStreamsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/audit-logs.ts b/backend/src/db/schemas/audit-logs.ts index f7143bb57..b8906698b 100644 --- a/backend/src/db/schemas/audit-logs.ts +++ b/backend/src/db/schemas/audit-logs.ts @@ -24,5 +24,5 @@ export const AuditLogsSchema = z.object({ }); export type TAuditLogs = z.infer; -export type TAuditLogsInsert = Omit; -export type TAuditLogsUpdate = Partial>; +export type TAuditLogsInsert = Omit, TImmutableDBKeys>; +export type TAuditLogsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/auth-token-sessions.ts b/backend/src/db/schemas/auth-token-sessions.ts index 46ed7c201..3a9376c83 100644 --- a/backend/src/db/schemas/auth-token-sessions.ts +++ b/backend/src/db/schemas/auth-token-sessions.ts @@ -20,5 +20,5 @@ export const AuthTokenSessionsSchema = z.object({ }); export type TAuthTokenSessions = z.infer; -export type TAuthTokenSessionsInsert = Omit; -export type TAuthTokenSessionsUpdate = Partial>; +export type TAuthTokenSessionsInsert = Omit, TImmutableDBKeys>; +export type TAuthTokenSessionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index 9ae8eed44..dd8563b85 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -21,5 +21,5 @@ export const AuthTokensSchema = z.object({ }); export type TAuthTokens = z.infer; -export type TAuthTokensInsert = Omit; -export type TAuthTokensUpdate = Partial>; +export type TAuthTokensInsert = Omit, TImmutableDBKeys>; +export type TAuthTokensUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/backup-private-key.ts b/backend/src/db/schemas/backup-private-key.ts index 5930bbd4a..5a2148aa1 100644 --- a/backend/src/db/schemas/backup-private-key.ts +++ b/backend/src/db/schemas/backup-private-key.ts @@ -22,5 +22,5 @@ export const BackupPrivateKeySchema = z.object({ }); export type TBackupPrivateKey = z.infer; -export type TBackupPrivateKeyInsert = Omit; -export type TBackupPrivateKeyUpdate = Partial>; +export type TBackupPrivateKeyInsert = Omit, TImmutableDBKeys>; +export type TBackupPrivateKeyUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/dynamic-secret-leases.ts b/backend/src/db/schemas/dynamic-secret-leases.ts new file mode 100644 index 000000000..8c16bcb55 --- /dev/null +++ b/backend/src/db/schemas/dynamic-secret-leases.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const DynamicSecretLeasesSchema = z.object({ + id: z.string().uuid(), + version: z.number(), + externalEntityId: z.string(), + expireAt: z.date(), + status: z.string().nullable().optional(), + statusDetails: z.string().nullable().optional(), + dynamicSecretId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TDynamicSecretLeases = z.infer; +export type TDynamicSecretLeasesInsert = Omit, TImmutableDBKeys>; +export type TDynamicSecretLeasesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts new file mode 100644 index 000000000..b27da396c --- /dev/null +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const DynamicSecretsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + version: z.number(), + type: z.string(), + defaultTTL: z.string(), + maxTTL: z.string().nullable().optional(), + inputIV: z.string(), + inputCiphertext: z.string(), + inputTag: z.string(), + algorithm: z.string().default("aes-256-gcm"), + keyEncoding: z.string().default("utf8"), + folderId: z.string().uuid(), + status: z.string().nullable().optional(), + statusDetails: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TDynamicSecrets = z.infer; +export type TDynamicSecretsInsert = Omit, TImmutableDBKeys>; +export type TDynamicSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/git-app-install-sessions.ts b/backend/src/db/schemas/git-app-install-sessions.ts index 6c6db40ea..986ae9d8e 100644 --- a/backend/src/db/schemas/git-app-install-sessions.ts +++ b/backend/src/db/schemas/git-app-install-sessions.ts @@ -17,5 +17,5 @@ export const GitAppInstallSessionsSchema = z.object({ }); export type TGitAppInstallSessions = z.infer; -export type TGitAppInstallSessionsInsert = Omit; -export type TGitAppInstallSessionsUpdate = Partial>; +export type TGitAppInstallSessionsInsert = Omit, TImmutableDBKeys>; +export type TGitAppInstallSessionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/git-app-org.ts b/backend/src/db/schemas/git-app-org.ts index 57e0d474a..627df6b4c 100644 --- a/backend/src/db/schemas/git-app-org.ts +++ b/backend/src/db/schemas/git-app-org.ts @@ -17,5 +17,5 @@ export const GitAppOrgSchema = z.object({ }); export type TGitAppOrg = z.infer; -export type TGitAppOrgInsert = Omit; -export type TGitAppOrgUpdate = Partial>; +export type TGitAppOrgInsert = Omit, TImmutableDBKeys>; +export type TGitAppOrgUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/group-project-membership-roles.ts b/backend/src/db/schemas/group-project-membership-roles.ts new file mode 100644 index 000000000..d837ca8e7 --- /dev/null +++ b/backend/src/db/schemas/group-project-membership-roles.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GroupProjectMembershipRolesSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroupProjectMembershipRoles = z.infer; +export type TGroupProjectMembershipRolesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TGroupProjectMembershipRolesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/group-project-memberships.ts b/backend/src/db/schemas/group-project-memberships.ts new file mode 100644 index 000000000..7787a3574 --- /dev/null +++ b/backend/src/db/schemas/group-project-memberships.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GroupProjectMembershipsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroupProjectMemberships = z.infer; +export type TGroupProjectMembershipsInsert = Omit, TImmutableDBKeys>; +export type TGroupProjectMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/pg-migrator/src/schemas/org-roles.ts b/backend/src/db/schemas/groups.ts similarity index 51% rename from pg-migrator/src/schemas/org-roles.ts rename to backend/src/db/schemas/groups.ts index 9718cdb26..9733d253e 100644 --- a/pg-migrator/src/schemas/org-roles.ts +++ b/backend/src/db/schemas/groups.ts @@ -7,17 +7,17 @@ import { z } from "zod"; import { TImmutableDBKeys } from "./models"; -export const OrgRolesSchema = z.object({ +export const GroupsSchema = z.object({ id: z.string().uuid(), - name: z.string(), - description: z.string().nullable().optional(), - slug: z.string(), - permissions: z.unknown(), - createdAt: z.date(), - updatedAt: z.date(), orgId: z.string().uuid(), + name: z.string(), + slug: z.string(), + role: z.string(), + roleId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() }); -export type TOrgRoles = z.infer; -export type TOrgRolesInsert = Omit; -export type TOrgRolesUpdate = Partial>; +export type TGroups = z.infer; +export type TGroupsInsert = Omit, TImmutableDBKeys>; +export type TGroupsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index 005adf025..adf3a6ef2 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -16,5 +16,5 @@ export const IdentitiesSchema = z.object({ }); export type TIdentities = z.infer; -export type TIdentitiesInsert = Omit; -export type TIdentitiesUpdate = Partial>; +export type TIdentitiesInsert = Omit, TImmutableDBKeys>; +export type TIdentitiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-access-tokens.ts b/backend/src/db/schemas/identity-access-tokens.ts index cbd71e5c5..18dbb8193 100644 --- a/backend/src/db/schemas/identity-access-tokens.ts +++ b/backend/src/db/schemas/identity-access-tokens.ts @@ -23,5 +23,5 @@ export const IdentityAccessTokensSchema = z.object({ }); export type TIdentityAccessTokens = z.infer; -export type TIdentityAccessTokensInsert = Omit; -export type TIdentityAccessTokensUpdate = Partial>; +export type TIdentityAccessTokensInsert = Omit, TImmutableDBKeys>; +export type TIdentityAccessTokensUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-aws-auths.ts b/backend/src/db/schemas/identity-aws-auths.ts new file mode 100644 index 000000000..f4444b00f --- /dev/null +++ b/backend/src/db/schemas/identity-aws-auths.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityAwsAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + type: z.string(), + stsEndpoint: z.string(), + allowedPrincipalArns: z.string(), + allowedAccountIds: z.string() +}); + +export type TIdentityAwsAuths = z.infer; +export type TIdentityAwsAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAwsAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-gcp-auths.ts b/backend/src/db/schemas/identity-gcp-auths.ts new file mode 100644 index 000000000..65c7db837 --- /dev/null +++ b/backend/src/db/schemas/identity-gcp-auths.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityGcpAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + type: z.string(), + allowedServiceAccounts: z.string(), + allowedProjects: z.string(), + allowedZones: z.string() +}); + +export type TIdentityGcpAuths = z.infer; +export type TIdentityGcpAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityGcpAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts new file mode 100644 index 000000000..ed99dec86 --- /dev/null +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -0,0 +1,35 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityKubernetesAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + kubernetesHost: z.string(), + encryptedCaCert: z.string(), + caCertIV: z.string(), + caCertTag: z.string(), + encryptedTokenReviewerJwt: z.string(), + tokenReviewerJwtIV: z.string(), + tokenReviewerJwtTag: z.string(), + allowedNamespaces: z.string(), + allowedNames: z.string(), + allowedAudience: z.string() +}); + +export type TIdentityKubernetesAuths = z.infer; +export type TIdentityKubernetesAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityKubernetesAuthsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-org-memberships.ts b/backend/src/db/schemas/identity-org-memberships.ts index 647ec7124..2f29c52e4 100644 --- a/backend/src/db/schemas/identity-org-memberships.ts +++ b/backend/src/db/schemas/identity-org-memberships.ts @@ -18,5 +18,7 @@ export const IdentityOrgMembershipsSchema = z.object({ }); export type TIdentityOrgMemberships = z.infer; -export type TIdentityOrgMembershipsInsert = Omit; -export type TIdentityOrgMembershipsUpdate = Partial>; +export type TIdentityOrgMembershipsInsert = Omit, TImmutableDBKeys>; +export type TIdentityOrgMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-project-additional-privilege.ts b/backend/src/db/schemas/identity-project-additional-privilege.ts new file mode 100644 index 000000000..7a9dbe19e --- /dev/null +++ b/backend/src/db/schemas/identity-project-additional-privilege.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityProjectAdditionalPrivilegeSchema = z.object({ + id: z.string().uuid(), + slug: z.string(), + projectMembershipId: z.string().uuid(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityProjectAdditionalPrivilege = z.infer; +export type TIdentityProjectAdditionalPrivilegeInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectAdditionalPrivilegeUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-project-membership-role.ts b/backend/src/db/schemas/identity-project-membership-role.ts new file mode 100644 index 000000000..90a0a3538 --- /dev/null +++ b/backend/src/db/schemas/identity-project-membership-role.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityProjectMembershipRoleSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityProjectMembershipRole = z.infer; +export type TIdentityProjectMembershipRoleInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectMembershipRoleUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-project-memberships.ts b/backend/src/db/schemas/identity-project-memberships.ts index 866324c8b..2f17c36d8 100644 --- a/backend/src/db/schemas/identity-project-memberships.ts +++ b/backend/src/db/schemas/identity-project-memberships.ts @@ -9,8 +9,6 @@ import { TImmutableDBKeys } from "./models"; export const IdentityProjectMembershipsSchema = z.object({ id: z.string().uuid(), - role: z.string(), - roleId: z.string().uuid().nullable().optional(), projectId: z.string(), identityId: z.string().uuid(), createdAt: z.date(), @@ -18,5 +16,10 @@ export const IdentityProjectMembershipsSchema = z.object({ }); export type TIdentityProjectMemberships = z.infer; -export type TIdentityProjectMembershipsInsert = Omit; -export type TIdentityProjectMembershipsUpdate = Partial>; +export type TIdentityProjectMembershipsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-ua-client-secrets.ts b/backend/src/db/schemas/identity-ua-client-secrets.ts index 60f8d862f..bd549ca5b 100644 --- a/backend/src/db/schemas/identity-ua-client-secrets.ts +++ b/backend/src/db/schemas/identity-ua-client-secrets.ts @@ -23,5 +23,7 @@ export const IdentityUaClientSecretsSchema = z.object({ }); export type TIdentityUaClientSecrets = z.infer; -export type TIdentityUaClientSecretsInsert = Omit; -export type TIdentityUaClientSecretsUpdate = Partial>; +export type TIdentityUaClientSecretsInsert = Omit, TImmutableDBKeys>; +export type TIdentityUaClientSecretsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index 5a8f0c7ec..eeec2f666 100644 --- a/backend/src/db/schemas/identity-universal-auths.ts +++ b/backend/src/db/schemas/identity-universal-auths.ts @@ -21,5 +21,7 @@ export const IdentityUniversalAuthsSchema = z.object({ }); export type TIdentityUniversalAuths = z.infer; -export type TIdentityUniversalAuthsInsert = Omit; -export type TIdentityUniversalAuthsUpdate = Partial>; +export type TIdentityUniversalAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityUniversalAuthsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/incident-contacts.ts b/backend/src/db/schemas/incident-contacts.ts index 431bf05ab..23c8503b0 100644 --- a/backend/src/db/schemas/incident-contacts.ts +++ b/backend/src/db/schemas/incident-contacts.ts @@ -16,5 +16,5 @@ export const IncidentContactsSchema = z.object({ }); export type TIncidentContacts = z.infer; -export type TIncidentContactsInsert = Omit; -export type TIncidentContactsUpdate = Partial>; +export type TIncidentContactsInsert = Omit, TImmutableDBKeys>; +export type TIncidentContactsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 62b01ebd2..cffa4f492 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,19 +1,36 @@ +export * from "./access-approval-policies"; +export * from "./access-approval-policies-approvers"; +export * from "./access-approval-requests"; +export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; +export * from "./audit-log-streams"; export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./dynamic-secret-leases"; +export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; +export * from "./group-project-membership-roles"; +export * from "./group-project-memberships"; +export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; +export * from "./identity-aws-auths"; +export * from "./identity-gcp-auths"; +export * from "./identity-kubernetes-auths"; export * from "./identity-org-memberships"; +export * from "./identity-project-additional-privilege"; +export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; export * from "./identity-ua-client-secrets"; export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./ldap-configs"; +export * from "./ldap-group-maps"; export * from "./models"; export * from "./org-bots"; export * from "./org-memberships"; @@ -24,8 +41,11 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-user-additional-privilege"; +export * from "./project-user-membership-roles"; export * from "./projects"; export * from "./saml-configs"; +export * from "./scim-tokens"; export * from "./secret-approval-policies"; export * from "./secret-approval-policies-approvers"; export * from "./secret-approval-request-secret-tags"; @@ -51,6 +71,8 @@ export * from "./service-tokens"; export * from "./super-admin"; export * from "./trusted-ips"; export * from "./user-actions"; +export * from "./user-aliases"; export * from "./user-encryption-keys"; +export * from "./user-group-membership"; export * from "./users"; export * from "./webhooks"; diff --git a/backend/src/db/schemas/integration-auths.ts b/backend/src/db/schemas/integration-auths.ts index db602c0af..185beae36 100644 --- a/backend/src/db/schemas/integration-auths.ts +++ b/backend/src/db/schemas/integration-auths.ts @@ -33,5 +33,5 @@ export const IntegrationAuthsSchema = z.object({ }); export type TIntegrationAuths = z.infer; -export type TIntegrationAuthsInsert = Omit; -export type TIntegrationAuthsUpdate = Partial>; +export type TIntegrationAuthsInsert = Omit, TImmutableDBKeys>; +export type TIntegrationAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/integrations.ts b/backend/src/db/schemas/integrations.ts index 62f73d190..47cf9e627 100644 --- a/backend/src/db/schemas/integrations.ts +++ b/backend/src/db/schemas/integrations.ts @@ -27,9 +27,13 @@ export const IntegrationsSchema = z.object({ envId: z.string().uuid(), secretPath: z.string().default("/"), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + lastUsed: z.date().nullable().optional(), + isSynced: z.boolean().nullable().optional(), + syncMessage: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional() }); export type TIntegrations = z.infer; -export type TIntegrationsInsert = Omit; -export type TIntegrationsUpdate = Partial>; +export type TIntegrationsInsert = Omit, TImmutableDBKeys>; +export type TIntegrationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ldap-configs.ts b/backend/src/db/schemas/ldap-configs.ts new file mode 100644 index 000000000..86fd6acb6 --- /dev/null +++ b/backend/src/db/schemas/ldap-configs.ts @@ -0,0 +1,34 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const LdapConfigsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + isActive: z.boolean(), + url: z.string(), + encryptedBindDN: z.string(), + bindDNIV: z.string(), + bindDNTag: z.string(), + encryptedBindPass: z.string(), + bindPassIV: z.string(), + bindPassTag: z.string(), + searchBase: z.string(), + encryptedCACert: z.string(), + caCertIV: z.string(), + caCertTag: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + groupSearchBase: z.string().default(""), + groupSearchFilter: z.string().default(""), + searchFilter: z.string().default("") +}); + +export type TLdapConfigs = z.infer; +export type TLdapConfigsInsert = Omit, TImmutableDBKeys>; +export type TLdapConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ldap-group-maps.ts b/backend/src/db/schemas/ldap-group-maps.ts new file mode 100644 index 000000000..d51d151b8 --- /dev/null +++ b/backend/src/db/schemas/ldap-group-maps.ts @@ -0,0 +1,19 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const LdapGroupMapsSchema = z.object({ + id: z.string().uuid(), + ldapConfigId: z.string().uuid(), + ldapGroupCN: z.string(), + groupId: z.string().uuid() +}); + +export type TLdapGroupMaps = z.infer; +export type TLdapGroupMapsInsert = Omit, TImmutableDBKeys>; +export type TLdapGroupMapsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 4ef943bbe..28a6973b7 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,11 @@ import { z } from "zod"; export enum TableName { Users = "users", + Groups = "groups", + GroupProjectMembership = "group_project_memberships", + GroupProjectMembershipRole = "group_project_membership_roles", + UserGroupMembership = "user_group_membership", + UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", AuthTokens = "auth_tokens", AuthTokenSession = "auth_token_sessions", @@ -19,8 +24,11 @@ export enum TableName { Environment = "project_environments", ProjectMembership = "project_memberships", ProjectRoles = "project_roles", + ProjectUserAdditionalPrivilege = "project_user_additional_privilege", + ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", Secret = "secrets", + SecretReference = "secret_references", SecretBlindIndex = "secret_blind_indexes", SecretVersion = "secret_versions", SecretFolder = "secret_folders", @@ -37,9 +45,19 @@ export enum TableName { Identity = "identities", IdentityAccessToken = "identity_access_tokens", IdentityUniversalAuth = "identity_universal_auths", + IdentityKubernetesAuth = "identity_kubernetes_auths", + IdentityGcpAuth = "identity_gcp_auths", IdentityUaClientSecret = "identity_ua_client_secrets", + IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", + IdentityProjectMembershipRole = "identity_project_membership_role", + IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", + ScimToken = "scim_tokens", + AccessApprovalPolicy = "access_approval_policies", + AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalRequest = "access_approval_requests", + AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", @@ -49,11 +67,16 @@ export enum TableName { SecretRotation = "secret_rotations", SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", + LdapConfig = "ldap_configs", + LdapGroupMap = "ldap_group_maps", AuditLog = "audit_logs", + AuditLogStream = "audit_log_streams", GitAppInstallSession = "git_app_install_sessions", GitAppOrg = "git_app_org", SecretScanningGitRisk = "secret_scanning_git_risks", TrustedIps = "trusted_ips", + DynamicSecret = "dynamic_secrets", + DynamicSecretLease = "dynamic_secret_leases", // junction tables with tags JnSecretTag = "secret_tag_junction", SecretVersionTag = "secret_version_tag_junction" @@ -111,6 +134,20 @@ export enum SecretType { Personal = "personal" } -export enum IdentityAuthMethod { - Univeral = "universal-auth" +export enum ProjectVersion { + V1 = 1, + V2 = 2 +} + +export enum ProjectUpgradeStatus { + InProgress = "IN_PROGRESS", + // Completed -> Will be null if completed. So a completed status is not needed + Failed = "FAILED" +} + +export enum IdentityAuthMethod { + Univeral = "universal-auth", + KUBERNETES_AUTH = "kubernetes-auth", + GCP_AUTH = "gcp-auth", + AWS_AUTH = "aws-auth" } diff --git a/backend/src/db/schemas/org-bots.ts b/backend/src/db/schemas/org-bots.ts index b328f1aaf..77be907ec 100644 --- a/backend/src/db/schemas/org-bots.ts +++ b/backend/src/db/schemas/org-bots.ts @@ -27,5 +27,5 @@ export const OrgBotsSchema = z.object({ }); export type TOrgBots = z.infer; -export type TOrgBotsInsert = Omit; -export type TOrgBotsUpdate = Partial>; +export type TOrgBotsInsert = Omit, TImmutableDBKeys>; +export type TOrgBotsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index b2fffa117..585addb7c 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -20,5 +20,5 @@ export const OrgMembershipsSchema = z.object({ }); export type TOrgMemberships = z.infer; -export type TOrgMembershipsInsert = Omit; -export type TOrgMembershipsUpdate = Partial>; +export type TOrgMembershipsInsert = Omit, TImmutableDBKeys>; +export type TOrgMembershipsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/org-roles.ts b/backend/src/db/schemas/org-roles.ts index 72b582f96..ca01c6574 100644 --- a/backend/src/db/schemas/org-roles.ts +++ b/backend/src/db/schemas/org-roles.ts @@ -19,5 +19,5 @@ export const OrgRolesSchema = z.object({ }); export type TOrgRoles = z.infer; -export type TOrgRolesInsert = Omit; -export type TOrgRolesUpdate = Partial>; +export type TOrgRolesInsert = Omit, TImmutableDBKeys>; +export type TOrgRolesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index e0f70d1c0..f2933af86 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -13,9 +13,11 @@ export const OrganizationsSchema = z.object({ customerId: z.string().nullable().optional(), slug: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + authEnforced: z.boolean().default(false).nullable().optional(), + scimEnabled: z.boolean().default(false).nullable().optional() }); export type TOrganizations = z.infer; -export type TOrganizationsInsert = Omit; -export type TOrganizationsUpdate = Partial>; +export type TOrganizationsInsert = Omit, TImmutableDBKeys>; +export type TOrganizationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-bots.ts b/backend/src/db/schemas/project-bots.ts index c68576943..1fa59eb78 100644 --- a/backend/src/db/schemas/project-bots.ts +++ b/backend/src/db/schemas/project-bots.ts @@ -26,5 +26,5 @@ export const ProjectBotsSchema = z.object({ }); export type TProjectBots = z.infer; -export type TProjectBotsInsert = Omit; -export type TProjectBotsUpdate = Partial>; +export type TProjectBotsInsert = Omit, TImmutableDBKeys>; +export type TProjectBotsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-environments.ts b/backend/src/db/schemas/project-environments.ts index 8b95dbba0..76556b7e9 100644 --- a/backend/src/db/schemas/project-environments.ts +++ b/backend/src/db/schemas/project-environments.ts @@ -18,5 +18,5 @@ export const ProjectEnvironmentsSchema = z.object({ }); export type TProjectEnvironments = z.infer; -export type TProjectEnvironmentsInsert = Omit; -export type TProjectEnvironmentsUpdate = Partial>; +export type TProjectEnvironmentsInsert = Omit, TImmutableDBKeys>; +export type TProjectEnvironmentsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-keys.ts b/backend/src/db/schemas/project-keys.ts index 720cd79bf..924918b12 100644 --- a/backend/src/db/schemas/project-keys.ts +++ b/backend/src/db/schemas/project-keys.ts @@ -19,5 +19,5 @@ export const ProjectKeysSchema = z.object({ }); export type TProjectKeys = z.infer; -export type TProjectKeysInsert = Omit; -export type TProjectKeysUpdate = Partial>; +export type TProjectKeysInsert = Omit, TImmutableDBKeys>; +export type TProjectKeysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-memberships.ts b/backend/src/db/schemas/project-memberships.ts index b9f191a84..e522d6280 100644 --- a/backend/src/db/schemas/project-memberships.ts +++ b/backend/src/db/schemas/project-memberships.ts @@ -9,14 +9,12 @@ import { TImmutableDBKeys } from "./models"; export const ProjectMembershipsSchema = z.object({ id: z.string().uuid(), - role: z.string(), createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid(), - projectId: z.string(), - roleId: z.string().uuid().nullable().optional() + projectId: z.string() }); export type TProjectMemberships = z.infer; -export type TProjectMembershipsInsert = Omit; -export type TProjectMembershipsUpdate = Partial>; +export type TProjectMembershipsInsert = Omit, TImmutableDBKeys>; +export type TProjectMembershipsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-roles.ts b/backend/src/db/schemas/project-roles.ts index 1946ab5e1..e10f6fd4c 100644 --- a/backend/src/db/schemas/project-roles.ts +++ b/backend/src/db/schemas/project-roles.ts @@ -19,5 +19,5 @@ export const ProjectRolesSchema = z.object({ }); export type TProjectRoles = z.infer; -export type TProjectRolesInsert = Omit; -export type TProjectRolesUpdate = Partial>; +export type TProjectRolesInsert = Omit, TImmutableDBKeys>; +export type TProjectRolesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/project-user-additional-privilege.ts b/backend/src/db/schemas/project-user-additional-privilege.ts new file mode 100644 index 000000000..0fd0e5faa --- /dev/null +++ b/backend/src/db/schemas/project-user-additional-privilege.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectUserAdditionalPrivilegeSchema = z.object({ + id: z.string().uuid(), + slug: z.string(), + projectMembershipId: z.string().uuid(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectUserAdditionalPrivilege = z.infer; +export type TProjectUserAdditionalPrivilegeInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectUserAdditionalPrivilegeUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/project-user-membership-roles.ts b/backend/src/db/schemas/project-user-membership-roles.ts new file mode 100644 index 000000000..bc7b67208 --- /dev/null +++ b/backend/src/db/schemas/project-user-membership-roles.ts @@ -0,0 +1,31 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectUserMembershipRolesSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectUserMembershipRoles = z.infer; +export type TProjectUserMembershipRolesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectUserMembershipRolesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 296fa421e..3965e24c0 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -14,9 +14,11 @@ export const ProjectsSchema = z.object({ autoCapitalization: z.boolean().default(true).nullable().optional(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + version: z.number().default(1), + upgradeStatus: z.string().nullable().optional() }); export type TProjects = z.infer; -export type TProjectsInsert = Omit; -export type TProjectsUpdate = Partial>; +export type TProjectsInsert = Omit, TImmutableDBKeys>; +export type TProjectsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/saml-configs.ts b/backend/src/db/schemas/saml-configs.ts index 4633384b9..67171469a 100644 --- a/backend/src/db/schemas/saml-configs.ts +++ b/backend/src/db/schemas/saml-configs.ts @@ -22,9 +22,10 @@ export const SamlConfigsSchema = z.object({ certTag: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - orgId: z.string().uuid() + orgId: z.string().uuid(), + lastUsed: z.date().nullable().optional() }); export type TSamlConfigs = z.infer; -export type TSamlConfigsInsert = Omit; -export type TSamlConfigsUpdate = Partial>; +export type TSamlConfigsInsert = Omit, TImmutableDBKeys>; +export type TSamlConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/scim-tokens.ts b/backend/src/db/schemas/scim-tokens.ts new file mode 100644 index 000000000..ab6e10d27 --- /dev/null +++ b/backend/src/db/schemas/scim-tokens.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ScimTokensSchema = z.object({ + id: z.string(), + ttlDays: z.coerce.number().default(365), + description: z.string(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TScimTokens = z.infer; +export type TScimTokensInsert = Omit, TImmutableDBKeys>; +export type TScimTokensUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-approval-policies-approvers.ts b/backend/src/db/schemas/secret-approval-policies-approvers.ts index 503299d30..12a3119e6 100644 --- a/backend/src/db/schemas/secret-approval-policies-approvers.ts +++ b/backend/src/db/schemas/secret-approval-policies-approvers.ts @@ -16,5 +16,10 @@ export const SecretApprovalPoliciesApproversSchema = z.object({ }); export type TSecretApprovalPoliciesApprovers = z.infer; -export type TSecretApprovalPoliciesApproversInsert = Omit; -export type TSecretApprovalPoliciesApproversUpdate = Partial>; +export type TSecretApprovalPoliciesApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalPoliciesApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 6c331f1b1..d907ef1e0 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -18,5 +18,7 @@ export const SecretApprovalPoliciesSchema = z.object({ }); export type TSecretApprovalPolicies = z.infer; -export type TSecretApprovalPoliciesInsert = Omit; -export type TSecretApprovalPoliciesUpdate = Partial>; +export type TSecretApprovalPoliciesInsert = Omit, TImmutableDBKeys>; +export type TSecretApprovalPoliciesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-approval-request-secret-tags.ts b/backend/src/db/schemas/secret-approval-request-secret-tags.ts index f5e7ba632..2851321e2 100644 --- a/backend/src/db/schemas/secret-approval-request-secret-tags.ts +++ b/backend/src/db/schemas/secret-approval-request-secret-tags.ts @@ -16,5 +16,10 @@ export const SecretApprovalRequestSecretTagsSchema = z.object({ }); export type TSecretApprovalRequestSecretTags = z.infer; -export type TSecretApprovalRequestSecretTagsInsert = Omit; -export type TSecretApprovalRequestSecretTagsUpdate = Partial>; +export type TSecretApprovalRequestSecretTagsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalRequestSecretTagsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-approval-requests-reviewers.ts b/backend/src/db/schemas/secret-approval-requests-reviewers.ts index a3657f1f9..f3ff88047 100644 --- a/backend/src/db/schemas/secret-approval-requests-reviewers.ts +++ b/backend/src/db/schemas/secret-approval-requests-reviewers.ts @@ -17,5 +17,10 @@ export const SecretApprovalRequestsReviewersSchema = z.object({ }); export type TSecretApprovalRequestsReviewers = z.infer; -export type TSecretApprovalRequestsReviewersInsert = Omit; -export type TSecretApprovalRequestsReviewersUpdate = Partial>; +export type TSecretApprovalRequestsReviewersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalRequestsReviewersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-approval-requests-secrets.ts b/backend/src/db/schemas/secret-approval-requests-secrets.ts index 810a4f2cf..b795b47b4 100644 --- a/backend/src/db/schemas/secret-approval-requests-secrets.ts +++ b/backend/src/db/schemas/secret-approval-requests-secrets.ts @@ -35,5 +35,10 @@ export const SecretApprovalRequestsSecretsSchema = z.object({ }); export type TSecretApprovalRequestsSecrets = z.infer; -export type TSecretApprovalRequestsSecretsInsert = Omit; -export type TSecretApprovalRequestsSecretsUpdate = Partial>; +export type TSecretApprovalRequestsSecretsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalRequestsSecretsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-approval-requests.ts b/backend/src/db/schemas/secret-approval-requests.ts index 590c283f5..6ee97fbb6 100644 --- a/backend/src/db/schemas/secret-approval-requests.ts +++ b/backend/src/db/schemas/secret-approval-requests.ts @@ -22,5 +22,7 @@ export const SecretApprovalRequestsSchema = z.object({ }); export type TSecretApprovalRequests = z.infer; -export type TSecretApprovalRequestsInsert = Omit; -export type TSecretApprovalRequestsUpdate = Partial>; +export type TSecretApprovalRequestsInsert = Omit, TImmutableDBKeys>; +export type TSecretApprovalRequestsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-blind-indexes.ts b/backend/src/db/schemas/secret-blind-indexes.ts index fa919babd..474e0aa54 100644 --- a/backend/src/db/schemas/secret-blind-indexes.ts +++ b/backend/src/db/schemas/secret-blind-indexes.ts @@ -20,5 +20,5 @@ export const SecretBlindIndexesSchema = z.object({ }); export type TSecretBlindIndexes = z.infer; -export type TSecretBlindIndexesInsert = Omit; -export type TSecretBlindIndexesUpdate = Partial>; +export type TSecretBlindIndexesInsert = Omit, TImmutableDBKeys>; +export type TSecretBlindIndexesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-folder-versions.ts b/backend/src/db/schemas/secret-folder-versions.ts index 8c550d065..8bef6e83f 100644 --- a/backend/src/db/schemas/secret-folder-versions.ts +++ b/backend/src/db/schemas/secret-folder-versions.ts @@ -18,5 +18,5 @@ export const SecretFolderVersionsSchema = z.object({ }); export type TSecretFolderVersions = z.infer; -export type TSecretFolderVersionsInsert = Omit; -export type TSecretFolderVersionsUpdate = Partial>; +export type TSecretFolderVersionsInsert = Omit, TImmutableDBKeys>; +export type TSecretFolderVersionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index 648238b0a..0f9684d0e 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -18,5 +18,5 @@ export const SecretFoldersSchema = z.object({ }); export type TSecretFolders = z.infer; -export type TSecretFoldersInsert = Omit; -export type TSecretFoldersUpdate = Partial>; +export type TSecretFoldersInsert = Omit, TImmutableDBKeys>; +export type TSecretFoldersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-imports.ts b/backend/src/db/schemas/secret-imports.ts index 9c1ee905f..9d42d8da5 100644 --- a/backend/src/db/schemas/secret-imports.ts +++ b/backend/src/db/schemas/secret-imports.ts @@ -19,5 +19,5 @@ export const SecretImportsSchema = z.object({ }); export type TSecretImports = z.infer; -export type TSecretImportsInsert = Omit; -export type TSecretImportsUpdate = Partial>; +export type TSecretImportsInsert = Omit, TImmutableDBKeys>; +export type TSecretImportsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-references.ts b/backend/src/db/schemas/secret-references.ts new file mode 100644 index 000000000..b3e6a8629 --- /dev/null +++ b/backend/src/db/schemas/secret-references.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretReferencesSchema = z.object({ + id: z.string().uuid(), + environment: z.string(), + secretPath: z.string(), + secretId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretReferences = z.infer; +export type TSecretReferencesInsert = Omit, TImmutableDBKeys>; +export type TSecretReferencesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-rotation-outputs.ts b/backend/src/db/schemas/secret-rotation-outputs.ts index 3b594365a..3ac5c2c9e 100644 --- a/backend/src/db/schemas/secret-rotation-outputs.ts +++ b/backend/src/db/schemas/secret-rotation-outputs.ts @@ -15,5 +15,5 @@ export const SecretRotationOutputsSchema = z.object({ }); export type TSecretRotationOutputs = z.infer; -export type TSecretRotationOutputsInsert = Omit; -export type TSecretRotationOutputsUpdate = Partial>; +export type TSecretRotationOutputsInsert = Omit, TImmutableDBKeys>; +export type TSecretRotationOutputsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-rotations.ts b/backend/src/db/schemas/secret-rotations.ts index 4c65712fa..b491edc46 100644 --- a/backend/src/db/schemas/secret-rotations.ts +++ b/backend/src/db/schemas/secret-rotations.ts @@ -26,5 +26,5 @@ export const SecretRotationsSchema = z.object({ }); export type TSecretRotations = z.infer; -export type TSecretRotationsInsert = Omit; -export type TSecretRotationsUpdate = Partial>; +export type TSecretRotationsInsert = Omit, TImmutableDBKeys>; +export type TSecretRotationsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-scanning-git-risks.ts b/backend/src/db/schemas/secret-scanning-git-risks.ts index 85cfcd376..08ba690e4 100644 --- a/backend/src/db/schemas/secret-scanning-git-risks.ts +++ b/backend/src/db/schemas/secret-scanning-git-risks.ts @@ -42,5 +42,7 @@ export const SecretScanningGitRisksSchema = z.object({ }); export type TSecretScanningGitRisks = z.infer; -export type TSecretScanningGitRisksInsert = Omit; -export type TSecretScanningGitRisksUpdate = Partial>; +export type TSecretScanningGitRisksInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningGitRisksUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-snapshot-folders.ts b/backend/src/db/schemas/secret-snapshot-folders.ts index acf11ab0a..3e2853cbe 100644 --- a/backend/src/db/schemas/secret-snapshot-folders.ts +++ b/backend/src/db/schemas/secret-snapshot-folders.ts @@ -17,5 +17,5 @@ export const SecretSnapshotFoldersSchema = z.object({ }); export type TSecretSnapshotFolders = z.infer; -export type TSecretSnapshotFoldersInsert = Omit; -export type TSecretSnapshotFoldersUpdate = Partial>; +export type TSecretSnapshotFoldersInsert = Omit, TImmutableDBKeys>; +export type TSecretSnapshotFoldersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-snapshot-secrets.ts b/backend/src/db/schemas/secret-snapshot-secrets.ts index 6a83d1155..121c5d56e 100644 --- a/backend/src/db/schemas/secret-snapshot-secrets.ts +++ b/backend/src/db/schemas/secret-snapshot-secrets.ts @@ -17,5 +17,5 @@ export const SecretSnapshotSecretsSchema = z.object({ }); export type TSecretSnapshotSecrets = z.infer; -export type TSecretSnapshotSecretsInsert = Omit; -export type TSecretSnapshotSecretsUpdate = Partial>; +export type TSecretSnapshotSecretsInsert = Omit, TImmutableDBKeys>; +export type TSecretSnapshotSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-snapshots.ts b/backend/src/db/schemas/secret-snapshots.ts index ed255cb77..7f070075f 100644 --- a/backend/src/db/schemas/secret-snapshots.ts +++ b/backend/src/db/schemas/secret-snapshots.ts @@ -17,5 +17,5 @@ export const SecretSnapshotsSchema = z.object({ }); export type TSecretSnapshots = z.infer; -export type TSecretSnapshotsInsert = Omit; -export type TSecretSnapshotsUpdate = Partial>; +export type TSecretSnapshotsInsert = Omit, TImmutableDBKeys>; +export type TSecretSnapshotsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-tag-junction.ts b/backend/src/db/schemas/secret-tag-junction.ts index 1d25574c5..d14384fab 100644 --- a/backend/src/db/schemas/secret-tag-junction.ts +++ b/backend/src/db/schemas/secret-tag-junction.ts @@ -14,5 +14,5 @@ export const SecretTagJunctionSchema = z.object({ }); export type TSecretTagJunction = z.infer; -export type TSecretTagJunctionInsert = Omit; -export type TSecretTagJunctionUpdate = Partial>; +export type TSecretTagJunctionInsert = Omit, TImmutableDBKeys>; +export type TSecretTagJunctionUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-tags.ts b/backend/src/db/schemas/secret-tags.ts index 622c29bd3..f94e1e262 100644 --- a/backend/src/db/schemas/secret-tags.ts +++ b/backend/src/db/schemas/secret-tags.ts @@ -19,5 +19,5 @@ export const SecretTagsSchema = z.object({ }); export type TSecretTags = z.infer; -export type TSecretTagsInsert = Omit; -export type TSecretTagsUpdate = Partial>; +export type TSecretTagsInsert = Omit, TImmutableDBKeys>; +export type TSecretTagsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-version-tag-junction.ts b/backend/src/db/schemas/secret-version-tag-junction.ts index 2c9a24fee..b36e28c72 100644 --- a/backend/src/db/schemas/secret-version-tag-junction.ts +++ b/backend/src/db/schemas/secret-version-tag-junction.ts @@ -14,5 +14,7 @@ export const SecretVersionTagJunctionSchema = z.object({ }); export type TSecretVersionTagJunction = z.infer; -export type TSecretVersionTagJunctionInsert = Omit; -export type TSecretVersionTagJunctionUpdate = Partial>; +export type TSecretVersionTagJunctionInsert = Omit, TImmutableDBKeys>; +export type TSecretVersionTagJunctionUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-versions.ts b/backend/src/db/schemas/secret-versions.ts index d1675e3d2..d60db9b75 100644 --- a/backend/src/db/schemas/secret-versions.ts +++ b/backend/src/db/schemas/secret-versions.ts @@ -36,5 +36,5 @@ export const SecretVersionsSchema = z.object({ }); export type TSecretVersions = z.infer; -export type TSecretVersionsInsert = Omit; -export type TSecretVersionsUpdate = Partial>; +export type TSecretVersionsInsert = Omit, TImmutableDBKeys>; +export type TSecretVersionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secrets.ts b/backend/src/db/schemas/secrets.ts index 3fe5ad8d8..f261c40bb 100644 --- a/backend/src/db/schemas/secrets.ts +++ b/backend/src/db/schemas/secrets.ts @@ -34,5 +34,5 @@ export const SecretsSchema = z.object({ }); export type TSecrets = z.infer; -export type TSecretsInsert = Omit; -export type TSecretsUpdate = Partial>; +export type TSecretsInsert = Omit, TImmutableDBKeys>; +export type TSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/service-tokens.ts b/backend/src/db/schemas/service-tokens.ts index 24720f3e4..720c8fd6f 100644 --- a/backend/src/db/schemas/service-tokens.ts +++ b/backend/src/db/schemas/service-tokens.ts @@ -25,5 +25,5 @@ export const ServiceTokensSchema = z.object({ }); export type TServiceTokens = z.infer; -export type TServiceTokensInsert = Omit; -export type TServiceTokensUpdate = Partial>; +export type TServiceTokensInsert = Omit, TImmutableDBKeys>; +export type TServiceTokensUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 13bf45e7b..417d4e05e 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -12,9 +12,13 @@ export const SuperAdminSchema = z.object({ initialized: z.boolean().default(false).nullable().optional(), allowSignUp: z.boolean().default(true).nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + allowedSignUpDomain: z.string().nullable().optional(), + instanceId: z.string().uuid().default("00000000-0000-0000-0000-000000000000"), + trustSamlEmails: z.boolean().default(false).nullable().optional(), + trustLdapEmails: z.boolean().default(false).nullable().optional() }); export type TSuperAdmin = z.infer; -export type TSuperAdminInsert = Omit; -export type TSuperAdminUpdate = Partial>; +export type TSuperAdminInsert = Omit, TImmutableDBKeys>; +export type TSuperAdminUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/trusted-ips.ts b/backend/src/db/schemas/trusted-ips.ts index 6d9018db3..f9973a843 100644 --- a/backend/src/db/schemas/trusted-ips.ts +++ b/backend/src/db/schemas/trusted-ips.ts @@ -20,5 +20,5 @@ export const TrustedIpsSchema = z.object({ }); export type TTrustedIps = z.infer; -export type TTrustedIpsInsert = Omit; -export type TTrustedIpsUpdate = Partial>; +export type TTrustedIpsInsert = Omit, TImmutableDBKeys>; +export type TTrustedIpsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/user-actions.ts b/backend/src/db/schemas/user-actions.ts index eaa03ba98..89d269847 100644 --- a/backend/src/db/schemas/user-actions.ts +++ b/backend/src/db/schemas/user-actions.ts @@ -16,5 +16,5 @@ export const UserActionsSchema = z.object({ }); export type TUserActions = z.infer; -export type TUserActionsInsert = Omit; -export type TUserActionsUpdate = Partial>; +export type TUserActionsInsert = Omit, TImmutableDBKeys>; +export type TUserActionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts new file mode 100644 index 000000000..14147abf8 --- /dev/null +++ b/backend/src/db/schemas/user-aliases.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserAliasesSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + username: z.string().nullable().optional(), + aliasType: z.string(), + externalId: z.string(), + emails: z.string().array().nullable().optional(), + orgId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserAliases = z.infer; +export type TUserAliasesInsert = Omit, TImmutableDBKeys>; +export type TUserAliasesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/user-encryption-keys.ts b/backend/src/db/schemas/user-encryption-keys.ts index 8f35b09fe..693b73b4c 100644 --- a/backend/src/db/schemas/user-encryption-keys.ts +++ b/backend/src/db/schemas/user-encryption-keys.ts @@ -25,5 +25,5 @@ export const UserEncryptionKeysSchema = z.object({ }); export type TUserEncryptionKeys = z.infer; -export type TUserEncryptionKeysInsert = Omit; -export type TUserEncryptionKeysUpdate = Partial>; +export type TUserEncryptionKeysInsert = Omit, TImmutableDBKeys>; +export type TUserEncryptionKeysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/user-group-membership.ts b/backend/src/db/schemas/user-group-membership.ts new file mode 100644 index 000000000..6b5fccd46 --- /dev/null +++ b/backend/src/db/schemas/user-group-membership.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserGroupMembershipSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + isPending: z.boolean().default(false) +}); + +export type TUserGroupMembership = z.infer; +export type TUserGroupMembershipInsert = Omit, TImmutableDBKeys>; +export type TUserGroupMembershipUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 4a29de510..d5a4d5b49 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -9,7 +9,7 @@ import { TImmutableDBKeys } from "./models"; export const UsersSchema = z.object({ id: z.string().uuid(), - email: z.string(), + email: z.string().nullable().optional(), authMethods: z.string().array().nullable().optional(), superAdmin: z.boolean().default(false).nullable().optional(), firstName: z.string().nullable().optional(), @@ -19,9 +19,12 @@ export const UsersSchema = z.object({ mfaMethods: z.string().array().nullable().optional(), devices: z.unknown().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isGhost: z.boolean().default(false), + username: z.string(), + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUsers = z.infer; -export type TUsersInsert = Omit; -export type TUsersUpdate = Partial>; +export type TUsersInsert = Omit, TImmutableDBKeys>; +export type TUsersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/webhooks.ts b/backend/src/db/schemas/webhooks.ts index 7abfb3772..44aa8c5da 100644 --- a/backend/src/db/schemas/webhooks.ts +++ b/backend/src/db/schemas/webhooks.ts @@ -25,5 +25,5 @@ export const WebhooksSchema = z.object({ }); export type TWebhooks = z.infer; -export type TWebhooksInsert = Omit; -export type TWebhooksUpdate = Partial>; +export type TWebhooksInsert = Omit, TImmutableDBKeys>; +export type TWebhooksUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/seed-data.ts b/backend/src/db/seed-data.ts index bb57d5bb4..5f4ea1b4f 100644 --- a/backend/src/db/seed-data.ts +++ b/backend/src/db/seed-data.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-mutable-exports */ import crypto from "node:crypto"; import argon2, { argon2id } from "argon2"; @@ -6,17 +7,22 @@ import nacl from "tweetnacl"; import { encodeBase64 } from "tweetnacl-util"; import { + decryptAsymmetric, // decryptAsymmetric, - decryptSymmetric, + decryptSymmetric128BitHexKeyUTF8, encryptAsymmetric, - encryptSymmetric + encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; -import { TUserEncryptionKeys } from "./schemas"; +import { TSecrets, TUserEncryptionKeys } from "./schemas"; + +export let userPrivateKey: string | undefined; +export let userPublicKey: string | undefined; export const seedData1 = { id: "3dafd81d-4388-432b-a4c5-f735616868c1", - email: "test@localhost.local", + username: process.env.TEST_USER_USERNAME || "test@localhost.local", + email: process.env.TEST_USER_EMAIL || "test@localhost.local", password: process.env.TEST_USER_PASSWORD || "testInfisical@1", organization: { id: "180870b7-f464-4740-8ffe-9d11c9245ea7", @@ -31,8 +37,22 @@ export const seedData1 = { name: "Development", slug: "dev" }, + machineIdentity: { + id: "88fa7aed-9288-401e-a4c9-fa9430be62a0", + name: "mac1", + clientCredentials: { + id: "3f6135db-f237-421d-af66-a8f4e80d443b", + secret: "da35a5a5a7b57f977a9a73394506e878a7175d06606df43dc93e1472b10cf339" + } + }, token: { id: "a9dfafba-a3b7-42e3-8618-91abb702fd36" + }, + + // We set these values during user creation, and later re-use them during project seeding. + encryptionKeys: { + publicKey: "", + privateKey: "" } }; @@ -73,7 +93,7 @@ export const generateUserSrpKeys = async (password: string) => { ciphertext: encryptedPrivateKey, iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag - } = encryptSymmetric(privateKey, key.toString("base64")); + } = encryptSymmetric128BitHexKeyUTF8(privateKey, key); // create the protected key by encrypting the symmetric key // [key] with the derived key @@ -81,7 +101,7 @@ export const generateUserSrpKeys = async (password: string) => { ciphertext: protectedKey, iv: protectedKeyIV, tag: protectedKeyTag - } = encryptSymmetric(key.toString("hex"), derivedKey.toString("base64")); + } = encryptSymmetric128BitHexKeyUTF8(key.toString("hex"), derivedKey); return { protectedKey, @@ -107,32 +127,102 @@ export const getUserPrivateKey = async (password: string, user: TUserEncryptionK raw: true }); if (!derivedKey) throw new Error("Failed to derive key from password"); - const key = decryptSymmetric({ + + const key = decryptSymmetric128BitHexKeyUTF8({ ciphertext: user.protectedKey as string, iv: user.protectedKeyIV as string, tag: user.protectedKeyTag as string, - key: derivedKey.toString("base64") + key: derivedKey }); - const privateKey = decryptSymmetric({ + + const privateKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: user.encryptedPrivateKey, iv: user.iv, tag: user.tag, - key + key: Buffer.from(key, "hex") }); return privateKey; }; -export const buildUserProjectKey = async (privateKey: string, publickey: string) => { +export const buildUserProjectKey = (privateKey: string, publickey: string) => { const randomBytes = crypto.randomBytes(16).toString("hex"); const { nonce, ciphertext } = encryptAsymmetric(randomBytes, publickey, privateKey); return { nonce, ciphertext }; }; -// export const getUserProjectKey = async (privateKey: string) => { -// const key = decryptAsymmetric({ -// ciphertext: decryptFileKey.encryptedKey, -// nonce: decryptFileKey.nonce, -// publicKey: decryptFileKey.sender.publicKey, -// privateKey: PRIVATE_KEY -// }); -// }; +export const getUserProjectKey = async (privateKey: string, ciphertext: string, nonce: string, publicKey: string) => { + return decryptAsymmetric({ + ciphertext, + nonce, + publicKey, + privateKey + }); +}; + +export const encryptSecret = (encKey: string, key: string, value?: string, comment?: string) => { + // encrypt key + const { + ciphertext: secretKeyCiphertext, + iv: secretKeyIV, + tag: secretKeyTag + } = encryptSymmetric128BitHexKeyUTF8(key, encKey); + + // encrypt value + const { + ciphertext: secretValueCiphertext, + iv: secretValueIV, + tag: secretValueTag + } = encryptSymmetric128BitHexKeyUTF8(value ?? "", encKey); + + // encrypt comment + const { + ciphertext: secretCommentCiphertext, + iv: secretCommentIV, + tag: secretCommentTag + } = encryptSymmetric128BitHexKeyUTF8(comment ?? "", encKey); + + return { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + }; +}; + +export const decryptSecret = (decryptKey: string, encSecret: TSecrets) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ + key: decryptKey, + ciphertext: encSecret.secretKeyCiphertext, + tag: encSecret.secretKeyTag, + iv: encSecret.secretKeyIV + }); + + const secretValue = decryptSymmetric128BitHexKeyUTF8({ + key: decryptKey, + ciphertext: encSecret.secretValueCiphertext, + tag: encSecret.secretValueTag, + iv: encSecret.secretValueIV + }); + + const secretComment = + encSecret.secretCommentIV && encSecret.secretCommentTag && encSecret.secretCommentCiphertext + ? decryptSymmetric128BitHexKeyUTF8({ + key: decryptKey, + ciphertext: encSecret.secretCommentCiphertext, + tag: encSecret.secretCommentTag, + iv: encSecret.secretCommentIV + }) + : ""; + + return { + key: secretKey, + value: secretValue, + comment: secretComment, + version: encSecret.version + }; +}; diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index ca0042a98..86cd2be34 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -9,13 +9,20 @@ export async function seed(knex: Knex): Promise { await knex(TableName.Users).del(); await knex(TableName.UserEncryptionKey).del(); await knex(TableName.SuperAdmin).del(); - await knex(TableName.SuperAdmin).insert([{ initialized: true, allowSignUp: true }]); + + await knex(TableName.SuperAdmin).insert([ + // eslint-disable-next-line + // @ts-ignore + { id: "00000000-0000-0000-0000-000000000000", initialized: true, allowSignUp: true } + ]); // Inserts seed entries const [user] = await knex(TableName.Users) .insert([ { - // @ts-expect-error exluded type id needs to be inserted here to keep it testable + // eslint-disable-next-line + // @ts-ignore id: seedData1.id, + username: seedData1.username, email: seedData1.email, superAdmin: true, firstName: "test", @@ -48,7 +55,8 @@ export async function seed(knex: Knex): Promise { ]); await knex(TableName.AuthTokenSession).insert({ - // @ts-expect-error exluded type id needs to be inserted here to keep it testable + // eslint-disable-next-line + // @ts-ignore id: seedData1.token.id, userId: seedData1.id, ip: "151.196.220.213", diff --git a/backend/src/db/seeds/2-org.ts b/backend/src/db/seeds/2-org.ts index a9c3ec3c7..ba2f65a36 100644 --- a/backend/src/db/seeds/2-org.ts +++ b/backend/src/db/seeds/2-org.ts @@ -14,7 +14,8 @@ export async function seed(knex: Knex): Promise { const [org] = await knex(TableName.Organization) .insert([ { - // @ts-expect-error exluded type id needs to be inserted here to keep it testable + // eslint-disable-next-line + // @ts-ignore id: seedData1.organization.id, name: "infisical", slug: "infisical", diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index 7818d5831..934130494 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -1,7 +1,11 @@ +import crypto from "node:crypto"; + import { Knex } from "knex"; -import { OrgMembershipRole, TableName } from "../schemas"; -import { seedData1 } from "../seed-data"; +import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; + +import { ProjectMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ { name: "Development", slug: "dev" }, @@ -20,21 +24,37 @@ export async function seed(knex: Knex): Promise { name: seedData1.project.name, orgId: seedData1.organization.id, slug: "first-project", - // @ts-expect-error exluded type id needs to be inserted here to keep it testable + // eslint-disable-next-line + // @ts-ignore id: seedData1.project.id }) .returning("*"); - // await knex(TableName.ProjectKeys).insert({ - // projectId: project.id, - // senderId: seedData1.id - // }); - - await knex(TableName.ProjectMembership).insert({ - projectId: project.id, - role: OrgMembershipRole.Admin, - userId: seedData1.id + const projectMembership = await knex(TableName.ProjectMembership) + .insert({ + projectId: project.id, + userId: seedData1.id + }) + .returning("*"); + await knex(TableName.ProjectUserMembershipRole).insert({ + role: ProjectMembershipRole.Admin, + projectMembershipId: projectMembership[0].id }); + + const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first(); + if (!user) throw new Error("User not found"); + + const userPrivateKey = await getUserPrivateKey(seedData1.password, user); + const projectKey = buildUserProjectKey(userPrivateKey, user.publicKey); + await knex(TableName.ProjectKeys).insert({ + projectId: project.id, + nonce: projectKey.nonce, + encryptedKey: projectKey.ciphertext, + receiverId: seedData1.id, + senderId: seedData1.id + }); + + // create default environments and default folders const envs = await knex(TableName.Environment) .insert( DEFAULT_PROJECT_ENVS.map(({ name, slug }, index) => ({ @@ -46,4 +66,19 @@ export async function seed(knex: Knex): Promise { ) .returning("*"); await knex(TableName.SecretFolder).insert(envs.map(({ id }) => ({ name: "root", envId: id, parentId: null }))); + + // save secret secret blind index + const encKey = process.env.ENCRYPTION_KEY; + if (!encKey) throw new Error("Missing ENCRYPTION_KEY"); + const salt = crypto.randomBytes(16).toString("base64"); + const secretBlindIndex = encryptSymmetric128BitHexKeyUTF8(salt, encKey); + // insert secret blind index for project + await knex(TableName.SecretBlindIndex).insert({ + projectId: project.id, + encryptedSaltCipherText: secretBlindIndex.ciphertext, + saltIV: secretBlindIndex.iv, + saltTag: secretBlindIndex.tag, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + }); } diff --git a/backend/src/db/seeds/4-machine-identity.ts b/backend/src/db/seeds/4-machine-identity.ts new file mode 100644 index 000000000..662232e02 --- /dev/null +++ b/backend/src/db/seeds/4-machine-identity.ts @@ -0,0 +1,89 @@ +import bcrypt from "bcrypt"; +import { Knex } from "knex"; + +import { IdentityAuthMethod, OrgMembershipRole, ProjectMembershipRole, TableName } from "../schemas"; +import { seedData1 } from "../seed-data"; + +export async function seed(knex: Knex): Promise { + // Deletes ALL existing entries + await knex(TableName.Identity).del(); + await knex(TableName.IdentityOrgMembership).del(); + + // Inserts seed entries + await knex(TableName.Identity).insert([ + { + // eslint-disable-next-line + // @ts-ignore + id: seedData1.machineIdentity.id, + name: seedData1.machineIdentity.name, + authMethod: IdentityAuthMethod.Univeral + } + ]); + const identityUa = await knex(TableName.IdentityUniversalAuth) + .insert([ + { + identityId: seedData1.machineIdentity.id, + clientId: seedData1.machineIdentity.clientCredentials.id, + clientSecretTrustedIps: JSON.stringify([ + { + type: "ipv4", + prefix: 0, + ipAddress: "0.0.0.0" + }, + { + type: "ipv6", + prefix: 0, + ipAddress: "::" + } + ]), + accessTokenTrustedIps: JSON.stringify([ + { + type: "ipv4", + prefix: 0, + ipAddress: "0.0.0.0" + }, + { + type: "ipv6", + prefix: 0, + ipAddress: "::" + } + ]), + accessTokenTTL: 2592000, + accessTokenMaxTTL: 2592000, + accessTokenNumUsesLimit: 0 + } + ]) + .returning("*"); + const clientSecretHash = await bcrypt.hash(seedData1.machineIdentity.clientCredentials.secret, 10); + await knex(TableName.IdentityUaClientSecret).insert([ + { + identityUAId: identityUa[0].id, + description: "", + clientSecretTTL: 0, + clientSecretNumUses: 0, + clientSecretNumUsesLimit: 0, + clientSecretPrefix: seedData1.machineIdentity.clientCredentials.secret.slice(0, 4), + clientSecretHash, + isClientSecretRevoked: false + } + ]); + await knex(TableName.IdentityOrgMembership).insert([ + { + identityId: seedData1.machineIdentity.id, + orgId: seedData1.organization.id, + role: OrgMembershipRole.Admin + } + ]); + + const identityProjectMembership = await knex(TableName.IdentityProjectMembership) + .insert({ + identityId: seedData1.machineIdentity.id, + projectId: seedData1.project.id + }) + .returning("*"); + + await knex(TableName.IdentityProjectMembershipRole).insert({ + role: ProjectMembershipRole.Admin, + projectMembershipId: identityProjectMembership[0].id + }); +} diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts new file mode 100644 index 000000000..3b8949d3b --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -0,0 +1,168 @@ +import { nanoid } from "nanoid"; +import { z } from "zod"; + +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z + .object({ + projectSlug: z.string().trim(), + name: z.string().optional(), + secretPath: z.string().trim().default("/"), + environment: z.string(), + 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." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + projectSlug: req.body.projectSlug, + name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` + }); + return { approval }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + approvals: sapPubSchema.extend({ approvers: z.string().array(), secretPath: z.string().optional() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectSlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectSlug: req.query.projectSlug + }); + return { approvals }; + } + }); + + server.route({ + url: "/count", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string(), + envSlug: z.string() + }), + response: { + 200: z.object({ + count: z.number() + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { count } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + projectSlug: req.query.projectSlug, + actorOrgId: req.permission.orgId, + envSlug: req.query.envSlug + }); + return { count }; + } + }); + + server.route({ + url: "/:policyId", + method: "PATCH", + schema: { + params: z.object({ + policyId: z.string() + }), + body: z + .object({ + name: z.string().optional(), + secretPath: z + .string() + .trim() + .optional() + .transform((val) => (val === "" ? "/" : val)), + 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." + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({ + policyId: req.params.policyId, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + ...req.body + }); + } + }); + + server.route({ + url: "/:policyId", + method: "DELETE", + schema: { + params: z.object({ + policyId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + policyId: req.params.policyId + }); + return { approval }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts new file mode 100644 index 000000000..4b173cfa7 --- /dev/null +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas"; +import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + permissions: z.any().array(), + isTemporary: z.boolean(), + temporaryRange: z.string().optional() + }), + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + approval: AccessApprovalRequestsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.accessApprovalRequest.createAccessApprovalRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + permissions: req.body.permissions, + actorOrgId: req.permission.orgId, + projectSlug: req.query.projectSlug, + temporaryRange: req.body.temporaryRange, + isTemporary: req.body.isTemporary + }); + return { approval: request }; + } + }); + + server.route({ + url: "/count", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim() + }), + response: { + 200: z.object({ + pendingCount: z.number(), + finalizedCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { count } = await server.services.accessApprovalRequest.getCount({ + projectSlug: req.query.projectSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { ...count }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + projectSlug: z.string().trim(), + authorProjectMembershipId: z.string().trim().optional(), + envSlug: z.string().trim().optional() + }), + response: { + 200: z.object({ + requests: AccessApprovalRequestsSchema.extend({ + environmentName: z.string(), + isApproved: z.boolean(), + privilege: z + .object({ + membershipId: z.string(), + isTemporary: z.boolean(), + temporaryMode: z.string().nullish(), + temporaryRange: z.string().nullish(), + temporaryAccessStartTime: z.date().nullish(), + temporaryAccessEndTime: z.date().nullish(), + permissions: z.unknown() + }) + .nullable(), + policy: z.object({ + id: z.string(), + name: z.string(), + approvals: z.number(), + approvers: z.string().array(), + secretPath: z.string().nullish(), + envId: z.string() + }), + reviewers: z + .object({ + member: z.string(), + status: z.string() + }) + .array() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({ + projectSlug: req.query.projectSlug, + authorProjectMembershipId: req.query.authorProjectMembershipId, + envSlug: req.query.envSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { requests }; + } + }); + + server.route({ + url: "/:requestId/review", + method: "POST", + schema: { + params: z.object({ + requestId: z.string().trim() + }), + body: z.object({ + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + }), + response: { + 200: z.object({ + review: AccessApprovalRequestsReviewersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const review = await server.services.accessApprovalRequest.reviewAccessRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + requestId: req.params.requestId, + status: req.body.status + }); + + return { review }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/audit-log-stream-router.ts b/backend/src/ee/routes/v1/audit-log-stream-router.ts new file mode 100644 index 000000000..17bd9e64b --- /dev/null +++ b/backend/src/ee/routes/v1/audit-log-stream-router.ts @@ -0,0 +1,215 @@ +import { z } from "zod"; + +import { AUDIT_LOG_STREAMS } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedAuditLogStreamSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "Create an Audit Log Stream.", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + url: z.string().min(1).describe(AUDIT_LOG_STREAMS.CREATE.url), + headers: z + .object({ + key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.key), + value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.value) + }) + .describe(AUDIT_LOG_STREAMS.CREATE.headers.desc) + .array() + .optional() + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + url: req.body.url, + headers: req.body.headers + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + description: "Update an Audit Log Stream by ID.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().describe(AUDIT_LOG_STREAMS.UPDATE.id) + }), + body: z.object({ + url: z.string().optional().describe(AUDIT_LOG_STREAMS.UPDATE.url), + headers: z + .object({ + key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.key), + value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.value) + }) + .describe(AUDIT_LOG_STREAMS.UPDATE.headers.desc) + .array() + .optional() + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.updateById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.id, + url: req.body.url, + headers: req.body.headers + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + description: "Delete an Audit Log Stream by ID.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().describe(AUDIT_LOG_STREAMS.DELETE.id) + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.deleteById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.id + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get an Audit Log Stream by ID.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string().describe(AUDIT_LOG_STREAMS.GET_BY_ID.id) + }), + response: { + 200: z.object({ + auditLogStream: SanitizedAuditLogStreamSchema.extend({ + headers: z + .object({ + key: z.string(), + value: z.string() + }) + .array() + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStream = await server.services.auditLogStream.getById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.id + }); + + return { auditLogStream }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List Audit Log Streams.", + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: z.object({ + auditLogStreams: SanitizedAuditLogStreamSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const auditLogStreams = await server.services.auditLogStream.list({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { auditLogStreams }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts new file mode 100644 index 000000000..5ef9f7eeb --- /dev/null +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts @@ -0,0 +1,197 @@ +import ms from "ms"; +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; +import { daysToMillisecond } from "@app/lib/dates"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(), + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug), + ttl: z + .string() + .optional() + .describe(DYNAMIC_SECRET_LEASES.CREATE.ttl) + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path) + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema, + dynamicSecret: SanitizedDynamicSecretSchema, + data: z.unknown() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.dynamicSecretName, + ...req.body + }); + return { lease, data, dynamicSecret }; + } + }); + + server.route({ + method: "DELETE", + url: "/:leaseId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.leaseId) + }), + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.projectSlug), + path: z + .string() + .min(1) + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRET_LEASES.DELETE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.DELETE.environmentSlug), + isForced: z.boolean().default(false).describe(DYNAMIC_SECRET_LEASES.DELETE.isForced) + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const lease = await server.services.dynamicSecretLease.revokeLease({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + leaseId: req.params.leaseId, + ...req.body + }); + return { lease }; + } + }); + + server.route({ + method: "POST", + url: "/:leaseId/renew", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.leaseId) + }), + body: z.object({ + ttl: z + .string() + .describe(DYNAMIC_SECRET_LEASES.RENEW.ttl) + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.projectSlug), + path: z + .string() + .min(1) + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRET_LEASES.RENEW.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.ttl) + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const lease = await server.services.dynamicSecretLease.renewLease({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + leaseId: req.params.leaseId, + ...req.body + }); + return { lease }; + } + }); + + server.route({ + url: "/:leaseId", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + leaseId: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.leaseId) + }), + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.projectSlug), + path: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.GET_BY_LEASEID.environmentSlug) + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema.extend({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const lease = await server.services.dynamicSecretLease.getLeaseDetails({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + leaseId: req.params.leaseId, + ...req.query + }); + return { lease }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts new file mode 100644 index 000000000..049370743 --- /dev/null +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -0,0 +1,290 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models"; +import { DYNAMIC_SECRETS } from "@app/lib/api-docs"; +import { daysToMillisecond } from "@app/lib/dates"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.CREATE.projectSlug), + provider: DynamicSecretProviderSchema.describe(DYNAMIC_SECRETS.CREATE.provider), + defaultTTL: z + .string() + .describe(DYNAMIC_SECRETS.CREATE.defaultTTL) + .superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .describe(DYNAMIC_SECRETS.CREATE.maxTTL) + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }) + .nullable(), + path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), + environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), + name: z + .string() + .describe(DYNAMIC_SECRETS.CREATE.name) + .min(1) + .toLowerCase() + .max(64) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + method: "PATCH", + url: "/:name", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + name: z.string().toLowerCase().describe(DYNAMIC_SECRETS.UPDATE.name) + }), + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.UPDATE.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.UPDATE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.UPDATE.environmentSlug), + data: z.object({ + inputs: z.any().optional().describe(DYNAMIC_SECRETS.UPDATE.inputs), + defaultTTL: z + .string() + .describe(DYNAMIC_SECRETS.UPDATE.defaultTTL) + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .describe(DYNAMIC_SECRETS.UPDATE.maxTTL) + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }) + .nullable(), + newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional() + }) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.updateByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + path: req.body.path, + projectSlug: req.body.projectSlug, + environmentSlug: req.body.environmentSlug, + ...req.body.data + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + method: "DELETE", + url: "/:name", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + name: z.string().toLowerCase().describe(DYNAMIC_SECRETS.DELETE.name) + }), + body: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.DELETE.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.DELETE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.DELETE.environmentSlug), + isForced: z.boolean().default(false).describe(DYNAMIC_SECRETS.DELETE.isForced) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.deleteByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + ...req.body + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/:name", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + name: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.name) + }), + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.GET_BY_NAME.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.GET_BY_NAME.environmentSlug) + }), + response: { + 200: z.object({ + dynamicSecret: SanitizedDynamicSecretSchema.extend({ + inputs: z.unknown() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfg = await server.services.dynamicSecret.getDetails({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + ...req.query + }); + return { dynamicSecret: dynamicSecretCfg }; + } + }); + + server.route({ + url: "/", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST.projectSlug), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRETS.LIST.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST.environmentSlug) + }), + response: { + 200: z.object({ + dynamicSecrets: SanitizedDynamicSecretSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dynamicSecretCfgs = await server.services.dynamicSecret.list({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + return { dynamicSecrets: dynamicSecretCfgs }; + } + }); + + server.route({ + url: "/:name/leases", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + name: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.name) + }), + querystring: z.object({ + projectSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.projectSlug), + path: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRETS.LIST_LEAES_BY_NAME.environmentSlug) + }), + response: { + 200: z.object({ + leases: DynamicSecretLeasesSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const leases = await server.services.dynamicSecretLease.listLeases({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.params.name, + ...req.query + }); + return { leases }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts new file mode 100644 index 000000000..d267564f2 --- /dev/null +++ b/backend/src/ee/routes/v1/group-router.ts @@ -0,0 +1,220 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { GROUPS } from "@app/lib/api-docs"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerGroupRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(GROUPS.CREATE.slug), + role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(GROUPS.CREATE.role) + }), + response: { + 200: GroupsSchema + } + }, + handler: async (req) => { + const group = await server.services.group.createGroup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return group; + } + }); + + server.route({ + url: "/:currentSlug", + method: "PATCH", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + currentSlug: z.string().trim().describe(GROUPS.UPDATE.currentSlug) + }), + body: z + .object({ + name: z.string().trim().min(1).describe(GROUPS.UPDATE.name), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(GROUPS.UPDATE.slug), + role: z.string().trim().min(1).describe(GROUPS.UPDATE.role) + }) + .partial(), + response: { + 200: GroupsSchema + } + }, + handler: async (req) => { + const group = await server.services.group.updateGroup({ + currentSlug: req.params.currentSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return group; + } + }); + + server.route({ + url: "/:slug", + method: "DELETE", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.DELETE.slug) + }), + response: { + 200: GroupsSchema + } + }, + handler: async (req) => { + const group = await server.services.group.deleteGroup({ + groupSlug: req.params.slug, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return group; + } + }); + + server.route({ + method: "GET", + url: "/:slug/users", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.LIST_USERS.slug) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), + username: z.string().optional().describe(GROUPS.LIST_USERS.username) + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { users, totalCount } = await server.services.group.listGroupUsers({ + groupSlug: req.params.slug, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + return { users, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:slug/users/:username", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.ADD_USER.slug), + username: z.string().trim().describe(GROUPS.ADD_USER.username) + }), + response: { + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + } + }, + handler: async (req) => { + const user = await server.services.group.addUserToGroup({ + groupSlug: req.params.slug, + username: req.params.username, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return user; + } + }); + + server.route({ + method: "DELETE", + url: "/:slug/users/:username", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.DELETE_USER.slug), + username: z.string().trim().describe(GROUPS.DELETE_USER.username) + }), + response: { + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + } + }, + handler: async (req) => { + const user = await server.services.group.removeUserFromGroup({ + groupSlug: req.params.slug, + username: req.params.username, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return user; + } + }); +}; diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts new file mode 100644 index 000000000..9a1a91672 --- /dev/null +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -0,0 +1,316 @@ +import { packRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; +import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ProjectPermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/permanent", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create a permanent or a non expiry specific privilege for identity.", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + }), + response: { + 200: z.object({ + privilege: SanitizedIdentityPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + isTemporary: false, + permissions: JSON.stringify(packRules(req.body.permissions)) + }); + return { privilege }; + } + }); + + server.route({ + method: "POST", + url: "/temporary", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create a temporary or a expiring specific privilege for identity.", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + temporaryMode: z + .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) + }), + response: { + 200: z.object({ + privilege: SanitizedIdentityPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + isTemporary: true, + permissions: JSON.stringify(packRules(req.body.permissions)) + }); + return { privilege }; + } + }); + + server.route({ + method: "PATCH", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update a specific privilege of an identity.", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + // disallow empty string + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), + privilegeDetails: z + .object({ + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), + temporaryMode: z + .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime) + }) + .partial() + }), + response: { + 200: z.object({ + privilege: SanitizedIdentityPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const updatedInfo = req.body.privilegeDetails; + const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + slug: req.body.privilegeSlug, + identityId: req.body.identityId, + projectSlug: req.body.projectSlug, + data: { + ...updatedInfo, + permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined + } + }); + return { privilege }; + } + }); + + server.route({ + method: "DELETE", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete a specific privilege of an identity.", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.projectSlug) + }), + response: { + 200: z.object({ + privilege: SanitizedIdentityPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.deleteBySlug({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.body.privilegeSlug, + identityId: req.body.identityId, + projectSlug: req.body.projectSlug + }); + return { privilege }; + } + }); + + server.route({ + method: "GET", + url: "/:privilegeSlug", + config: { + rateLimit: readLimit + }, + schema: { + description: "Retrieve details of a specific privilege by privilege slug.", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug) + }), + querystring: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.projectSlug) + }), + response: { + 200: z.object({ + privilege: SanitizedIdentityPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsBySlug({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + slug: req.params.privilegeSlug, + ...req.query + }); + return { privilege }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List of a specific privilege of an identity in a project.", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug) + }), + response: { + 200: z.object({ + privileges: SanitizedIdentityPrivilegeSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privileges = await server.services.identityProjectAdditionalPrivilege.listIdentityProjectPrivileges({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + return { + privileges + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2ed439316..16e23eb88 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,8 +1,17 @@ +import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; +import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; +import { registerDynamicSecretRouter } from "./dynamic-secret-router"; +import { registerGroupRouter } from "./group-router"; +import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; +import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; import { registerSamlRouter } from "./saml-router"; +import { registerScimRouter } from "./scim-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router"; import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router"; @@ -11,6 +20,7 @@ import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; +import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization @@ -32,8 +42,31 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretRotationProviderRouter, { prefix: "/secret-rotation-providers" }); + + await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" }); + await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" }); + + await server.register( + async (dynamicSecretRouter) => { + await dynamicSecretRouter.register(registerDynamicSecretRouter); + await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" }); + }, + { prefix: "/dynamic-secrets" } + ); + await server.register(registerSamlRouter, { prefix: "/sso" }); + await server.register(registerScimRouter, { prefix: "/scim" }); + await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); + await server.register(registerGroupRouter, { prefix: "/groups" }); + await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); + await server.register( + async (privilegeRouter) => { + await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); + await privilegeRouter.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity" }); + }, + { prefix: "/additional-privilege" } + ); }; diff --git a/backend/src/ee/routes/v1/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts new file mode 100644 index 000000000..e146668c2 --- /dev/null +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -0,0 +1,371 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +// All the any rules are disabled because passport typesense with fastify is really poor + +import { IncomingMessage } from "node:http"; + +import { Authenticator } from "@fastify/passport"; +import fastifySession from "@fastify/session"; +import { FastifyRequest } from "fastify"; +import LdapStrategy from "passport-ldapauth"; +import { z } from "zod"; + +import { LdapConfigsSchema, LdapGroupMapsSchema } from "@app/db/schemas"; +import { TLDAPConfig } from "@app/ee/services/ldap-config/ldap-config-types"; +import { isValidLdapFilter, searchGroups } from "@app/ee/services/ldap-config/ldap-fns"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerLdapRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const passport = new Authenticator({ key: "ldap", userProperty: "passportUser" }); + await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + const getLdapPassportOpts = (req: FastifyRequest, done: any) => { + const { organizationSlug } = req.body as { + organizationSlug: string; + }; + + process.nextTick(async () => { + try { + const { opts, ldapConfig } = await server.services.ldap.bootLdap(organizationSlug); + req.ldapConfig = ldapConfig; + done(null, opts); + } catch (err) { + done(err); + } + }); + }; + + passport.use( + new LdapStrategy( + getLdapPassportOpts as any, + // eslint-disable-next-line + async (req: IncomingMessage, user, cb) => { + try { + if (!user.email) throw new BadRequestError({ message: "Invalid request. Missing email." }); + const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; + + let groups: { dn: string; cn: string }[] | undefined; + if (ldapConfig.groupSearchBase) { + const groupFilter = "(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"; + const groupSearchFilter = (ldapConfig.groupSearchFilter || groupFilter) + .replace(/{{\.Username}}/g, user.uid) + .replace(/{{\.UserDN}}/g, user.dn); + + if (!isValidLdapFilter(groupSearchFilter)) { + throw new Error("Generated LDAP search filter is invalid."); + } + + groups = await searchGroups(ldapConfig, groupSearchFilter, ldapConfig.groupSearchBase); + } + + const { isUserCompleted, providerAuthToken } = await server.services.ldap.ldapLogin({ + ldapConfigId: ldapConfig.id, + externalId: user.uidNumber, + username: user.uid, + firstName: user.givenName ?? user.cn ?? "", + lastName: user.sn ?? "", + email: user.mail, + groups, + relayState: ((req as unknown as FastifyRequest).body as { RelayState?: string }).RelayState, + orgId: (req as unknown as FastifyRequest).ldapConfig.organization + }); + + return cb(null, { isUserCompleted, providerAuthToken }); + } catch (error) { + logger.error(error); + return cb(error, false); + } + } + ) + ); + + server.route({ + url: "/login", + method: "POST", + schema: { + body: z.object({ + organizationSlug: z.string().trim() + }) + }, + preValidation: passport.authenticate("ldapauth", { + session: false + // failureFlash: true, + // failureRedirect: "/login/provider/error" + // this is due to zod type difference + }) as any, + handler: (req, res) => { + let nextUrl; + if (req.passportUser.isUserCompleted) { + nextUrl = `${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`; + } else { + nextUrl = `${appCfg.SITE_URL}/signup/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`; + } + + return res.status(200).send({ + nextUrl + }); + } + }); + + server.route({ + method: "GET", + url: "/config", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + querystring: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + id: z.string(), + organization: z.string(), + isActive: z.boolean(), + url: z.string(), + bindDN: z.string(), + bindPass: z.string(), + searchBase: z.string(), + searchFilter: z.string(), + groupSearchBase: z.string(), + groupSearchFilter: z.string(), + caCert: z.string() + }) + } + }, + handler: async (req) => { + const ldap = await server.services.ldap.getLdapCfgWithPermissionCheck({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.query.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return ldap; + } + }); + + server.route({ + method: "POST", + url: "/config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + organizationId: z.string().trim(), + isActive: z.boolean(), + url: z.string().trim(), + bindDN: z.string().trim(), + bindPass: z.string().trim(), + searchBase: z.string().trim(), + searchFilter: z.string().trim().default("(uid={{username}})"), + groupSearchBase: z.string().trim(), + groupSearchFilter: z + .string() + .trim() + .default("(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))"), + caCert: z.string().trim().default("") + }), + response: { + 200: LdapConfigsSchema + } + }, + handler: async (req) => { + const ldap = await server.services.ldap.createLdapCfg({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.body.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return ldap; + } + }); + + server.route({ + url: "/config", + method: "PATCH", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z + .object({ + isActive: z.boolean(), + url: z.string().trim(), + bindDN: z.string().trim(), + bindPass: z.string().trim(), + searchBase: z.string().trim(), + searchFilter: z.string().trim(), + groupSearchBase: z.string().trim(), + groupSearchFilter: z.string().trim(), + caCert: z.string().trim() + }) + .partial() + .merge(z.object({ organizationId: z.string() })), + response: { + 200: LdapConfigsSchema + } + }, + handler: async (req) => { + const ldap = await server.services.ldap.updateLdapCfg({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.body.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return ldap; + } + }); + + server.route({ + method: "GET", + url: "/config/:configId/group-maps", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim() + }), + response: { + 200: z.array( + z.object({ + id: z.string(), + ldapConfigId: z.string(), + ldapGroupCN: z.string(), + group: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + }) + ) + } + }, + handler: async (req) => { + const ldapGroupMaps = await server.services.ldap.getLdapGroupMaps({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId + }); + return ldapGroupMaps; + } + }); + + server.route({ + method: "POST", + url: "/config/:configId/group-maps", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim() + }), + body: z.object({ + ldapGroupCN: z.string().trim(), + groupSlug: z.string().trim() + }), + response: { + 200: LdapGroupMapsSchema + } + }, + handler: async (req) => { + const ldapGroupMap = await server.services.ldap.createLdapGroupMap({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId, + ...req.body + }); + return ldapGroupMap; + } + }); + + server.route({ + method: "DELETE", + url: "/config/:configId/group-maps/:groupMapId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim(), + groupMapId: z.string().trim() + }), + response: { + 200: LdapGroupMapsSchema + } + }, + handler: async (req) => { + const ldapGroupMap = await server.services.ldap.deleteLdapGroupMap({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId, + ldapGroupMapId: req.params.groupMapId + }); + return ldapGroupMap; + } + }); + + server.route({ + method: "POST", + url: "/config/:configId/test-connection", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + configId: z.string().trim() + }), + response: { + 200: z.boolean() + } + }, + handler: async (req) => { + const result = await server.services.ldap.testLDAPConnection({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ldapConfigId: req.params.configId + }); + return result; + } + }); +}; diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index e560fc842..fbf1af43b 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -3,13 +3,17 @@ // TODO(akhilmhdh): Fix this when licence service gets it type import { z } from "zod"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerLicenseRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:organizationId/plans/table", method: "GET", + url: "/:organizationId/plans/table", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ billingCycle: z.enum(["monthly", "yearly"]) }), params: z.object({ organizationId: z.string().trim() }), @@ -22,7 +26,9 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgPlansTableByBillCycle({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, billingCycle: req.query.billingCycle }); return data; @@ -30,8 +36,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/plan", method: "GET", + url: "/:organizationId/plan", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -43,6 +52,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const plan = await server.services.license.getOrgPlan({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return { plan }; @@ -50,8 +61,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/plans", method: "GET", + url: "/:organizationId/plans", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), querystring: z.object({ workspaceId: z.string().trim().optional() }), @@ -64,6 +78,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgPlan({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; @@ -71,8 +87,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/session/trial", method: "POST", + url: "/:organizationId/session/trial", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ success_url: z.string().trim() }), @@ -85,7 +104,9 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.startOrgTrial({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, success_url: req.body.success_url }); return data; @@ -95,6 +116,9 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:organizationId/customer-portal-session", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -106,6 +130,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.createOrganizationPortalSession({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; @@ -113,8 +139,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/plan/billing", method: "GET", + url: "/:organizationId/plan/billing", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -126,6 +155,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgBillingInfo({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; @@ -133,8 +164,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/plan/table", method: "GET", + url: "/:organizationId/plan/table", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -146,6 +180,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgPlanTable({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; @@ -153,8 +189,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details", method: "GET", + url: "/:organizationId/billing-details", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -166,6 +205,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgBillingDetails({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; @@ -173,8 +214,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details", method: "PATCH", + url: "/:organizationId/billing-details", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ @@ -190,6 +234,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.updateOrgBillingDetails({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, name: req.body.name, email: req.body.email @@ -199,8 +245,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details/payment-methods", method: "GET", + url: "/:organizationId/billing-details/payment-methods", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -212,6 +261,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgPmtMethods({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; @@ -219,8 +270,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details/payment-methods", method: "POST", + url: "/:organizationId/billing-details/payment-methods", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ @@ -236,6 +290,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.addOrgPmtMethods({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, success_url: req.body.success_url, cancel_url: req.body.cancel_url @@ -245,8 +301,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details/payment-methods/:pmtMethodId", method: "DELETE", + url: "/:organizationId/billing-details/payment-methods/:pmtMethodId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim(), @@ -261,6 +320,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.delOrgPmtMethods({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId, pmtMethodId: req.params.pmtMethodId }); @@ -269,8 +330,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details/tax-ids", method: "GET", + url: "/:organizationId/billing-details/tax-ids", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -284,6 +348,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgTaxIds({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId }); return data; @@ -291,8 +357,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details/tax-ids", method: "POST", + url: "/:organizationId/billing-details/tax-ids", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -310,6 +379,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.addOrgTaxId({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId, type: req.body.type, value: req.body.value @@ -319,8 +390,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/billing-details/tax-ids/:taxId", method: "DELETE", + url: "/:organizationId/billing-details/tax-ids/:taxId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim(), @@ -335,6 +409,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.delOrgTaxId({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId, taxId: req.params.taxId }); @@ -343,8 +419,11 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:organizationId/invoices", method: "GET", + url: "/:organizationId/invoices", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -358,15 +437,20 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgTaxInvoices({ actorId: req.permission.id, actor: req.permission.type, - orgId: req.params.organizationId + actorOrgId: req.permission.orgId, + orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod }); return data; } }); server.route({ - url: "/:organizationId/licenses", method: "GET", + url: "/:organizationId/licenses", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -380,6 +464,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const data = await server.services.license.getOrgLicenses({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); return data; diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 4890c97a5..380f61e23 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,6 +1,8 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,12 +10,25 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:organizationId/roles", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ - slug: z.string().trim(), + slug: z + .string() + .min(1) + .trim() + .refine( + (val) => !Object.keys(OrgMembershipRole).includes(val), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array() @@ -26,7 +41,13 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const role = await server.services.orgRole.createRole(req.permission.id, req.params.organizationId, req.body); + const role = await server.services.orgRole.createRole( + req.permission.id, + req.params.organizationId, + req.body, + req.permission.authMethod, + req.permission.orgId + ); return { role }; } }); @@ -34,13 +55,26 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", url: "/:organizationId/roles/:roleId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim(), roleId: z.string().trim() }), body: z.object({ - slug: z.string().trim().optional(), + slug: z + .string() + .trim() + .optional() + .refine( + (val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val), + "Please choose a different slug, the slug you have entered is reserved." + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), name: z.string().trim().optional(), description: z.string().trim().optional(), permissions: z.any().array() @@ -57,7 +91,9 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { req.permission.id, req.params.organizationId, req.params.roleId, - req.body + req.body, + req.permission.authMethod, + req.permission.orgId ); return { role }; } @@ -66,6 +102,9 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:organizationId/roles/:roleId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim(), @@ -82,7 +121,9 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { const role = await server.services.orgRole.deleteRole( req.permission.id, req.params.organizationId, - req.params.roleId + req.params.roleId, + req.permission.authMethod, + req.permission.orgId ); return { role }; } @@ -91,6 +132,9 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId/roles", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -107,7 +151,12 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const roles = await server.services.orgRole.listRoles(req.permission.id, req.params.organizationId); + const roles = await server.services.orgRole.listRoles( + req.permission.id, + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId + ); return { data: { roles } }; } }); @@ -115,6 +164,9 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId/permissions", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -130,7 +182,9 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const { permissions, membership } = await server.services.orgRole.getUserPermission( req.permission.id, - req.params.organizationId + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId ); return { permissions, membership }; } diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 86f2242e2..bb4d2fa8e 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,6 +9,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:projectId/roles", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -30,7 +34,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { req.permission.type, req.permission.id, req.params.projectId, - req.body + req.body, + req.permission.authMethod, + req.permission.orgId ); return { role }; } @@ -39,6 +45,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", url: "/:projectId/roles/:roleId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ projectId: z.string().trim(), @@ -63,7 +72,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { req.permission.id, req.params.projectId, req.params.roleId, - req.body + req.body, + req.permission.authMethod, + req.permission.orgId ); return { role }; } @@ -72,6 +83,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:projectId/roles/:roleId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ projectId: z.string().trim(), @@ -89,7 +103,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { req.permission.type, req.permission.id, req.params.projectId, - req.params.roleId + req.params.roleId, + req.permission.authMethod, + req.permission.orgId ); return { role }; } @@ -98,6 +114,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:projectId/roles", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -117,7 +136,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { const roles = await server.services.projectRole.listRoles( req.permission.type, req.permission.id, - req.params.projectId + req.params.projectId, + req.permission.authMethod, + req.permission.orgId ); return { data: { roles } }; } @@ -126,6 +147,9 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:projectId/permissions", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -133,7 +157,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ data: z.object({ - membership: ProjectMembershipsSchema, + membership: ProjectMembershipsSchema.extend({ + roles: z + .object({ + role: z.string() + }) + .array() + }), permissions: z.any().array() }) }) @@ -143,8 +173,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const { permissions, membership } = await server.services.projectRole.getUserPermission( req.permission.id, - req.params.projectId + req.params.projectId, + req.permission.authMethod, + req.permission.orgId ); + return { data: { permissions, membership } }; } }); diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index 3870123fd..9795aaf86 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { AuditLogsSchema, SecretSnapshotsSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; -import { removeTrailingSlash } from "@app/lib/fn"; +import { AUDIT_LOGS, PROJECTS } from "@app/lib/api-docs"; +import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -10,15 +12,24 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:workspaceId/secret-snapshots", + config: { + rateLimit: readLimit + }, schema: { + description: "Return project secret snapshots ids", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.workspaceId) }), querystring: z.object({ - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), - offset: z.coerce.number().default(0), - limit: z.coerce.number().default(20) + environment: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(PROJECTS.GET_SNAPSHOTS.path), + offset: z.coerce.number().default(0).describe(PROJECTS.GET_SNAPSHOTS.offset), + limit: z.coerce.number().default(20).describe(PROJECTS.GET_SNAPSHOTS.limit) }), response: { 200: z.object({ @@ -30,7 +41,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const secretSnapshots = await server.services.snapshot.listSnapshots({ actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, ...req.query }); @@ -41,6 +54,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:workspaceId/secret-snapshots/count", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -60,6 +76,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const count = await server.services.snapshot.projectSecretSnapshotCount({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, environment: req.query.environment, path: req.query.path @@ -71,18 +89,27 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:workspaceId/audit-logs", + config: { + rateLimit: readLimit + }, schema: { + description: "Return audit logs", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.workspaceId) }), querystring: z.object({ - eventType: z.nativeEnum(EventType).optional(), - userAgentType: z.nativeEnum(UserAgentType).optional(), - startDate: z.string().datetime().optional(), - endDate: z.string().datetime().optional(), - offset: z.coerce.number().default(0), - limit: z.coerce.number().default(20), - actor: z.string().optional() + eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType), + userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType), + startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate), + endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate), + offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset), + limit: z.coerce.number().default(20).describe(AUDIT_LOGS.EXPORT.limit), + actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor) }), response: { 200: z.object({ @@ -112,8 +139,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const auditLogs = await server.services.auditLog.listProjectAuditLogs({ actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, projectId: req.params.workspaceId, ...req.query, + startDate: req.query.endDate || getLastMidnightDateISO(), auditLogActor: req.query.actor, actor: req.permission.type }); @@ -124,6 +154,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:workspaceId/audit-logs/filters/actors", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index edea312fd..6001b8b6e 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -13,13 +13,13 @@ import { FastifyRequest } from "fastify"; import { z } from "zod"; import { SamlConfigsSchema } from "@app/db/schemas"; -import { SamlProviders } from "@app/ee/services/saml-config/saml-config-types"; +import { SamlProviders, TGetSamlCfgDTO } from "@app/ee/services/saml-config/saml-config-types"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { getServerCfg } from "@app/services/super-admin/super-admin-service"; type TSAMLConfig = { callbackUrl: string; @@ -28,6 +28,8 @@ type TSAMLConfig = { cert: string; audience: string; wantAuthnResponseSigned?: boolean; + wantAssertionsSigned?: boolean; + disableRequestedAuthnContext?: boolean; }; export const registerSamlRouter = async (server: FastifyZodProvider) => { @@ -44,17 +46,30 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line getSamlOptions: async (req, done) => { try { - const { ssoIdentifier } = req.params; - if (!ssoIdentifier) throw new BadRequestError({ message: "Missing sso identitier" }); + const { samlConfigId, orgSlug } = req.params; - const ssoConfig = await server.services.saml.getSaml({ - type: "ssoId", - id: ssoIdentifier - }); - if (!ssoConfig) throw new BadRequestError({ message: "SSO config not found" }); + let ssoLookupDetails: TGetSamlCfgDTO; + + if (orgSlug) { + ssoLookupDetails = { + type: "orgSlug", + orgSlug + }; + } else if (samlConfigId) { + ssoLookupDetails = { + type: "ssoId", + id: samlConfigId + }; + } else { + throw new BadRequestError({ message: "Missing sso identitier or org slug" }); + } + + const ssoConfig = await server.services.saml.getSaml(ssoLookupDetails); + if (!ssoConfig || !ssoConfig.isActive) + throw new BadRequestError({ message: "Failed to authenticate with SAML SSO" }); const samlConfig: TSAMLConfig = { - callbackUrl: `${appCfg.SITE_URL}/api/v1/sso/saml2/${ssoIdentifier}`, + callbackUrl: `${appCfg.SITE_URL}/api/v1/sso/saml2/${ssoConfig.id}`, entryPoint: ssoConfig.entryPoint, issuer: ssoConfig.issuer, cert: ssoConfig.cert, @@ -64,10 +79,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { samlConfig.wantAuthnResponseSigned = false; } if (ssoConfig.authProvider === SamlProviders.AZURE_SAML) { - if (req.body.RelayState && JSON.parse(req.body.RelayState).spIntiaited) { + samlConfig.disableRequestedAuthnContext = true; + if (req.body?.RelayState && JSON.parse(req.body.RelayState).spInitiated) { samlConfig.audience = `spn:${ssoConfig.issuer}`; } } + if (ssoConfig.authProvider === SamlProviders.GOOGLE_SAML) { + samlConfig.wantAssertionsSigned = false; + } + (req as unknown as FastifyRequest).ssoConfig = ssoConfig; done(null, samlConfig); } catch (error) { @@ -79,20 +99,18 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line async (req, profile, cb) => { try { - const serverCfg = await getServerCfg(); if (!profile) throw new BadRequestError({ message: "Missing profile" }); - const { firstName } = profile; const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved - if (!email || !firstName) { + if (!email || !profile.firstName) { throw new BadRequestError({ message: "Invalid request. Missing email or first name" }); } const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ + externalId: profile.nameID, email, firstName: profile.firstName as string, lastName: profile.lastName as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp), relayState: (req.body as { RelayState?: string }).RelayState, authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider as string, orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string @@ -108,11 +126,11 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { ); server.route({ - url: "/redirect/saml2/:ssoIdentifier", + url: "/redirect/saml2/organizations/:orgSlug", method: "GET", schema: { params: z.object({ - ssoIdentifier: z.string().trim() + orgSlug: z.string().trim() }), querystring: z.object({ callback_port: z.string().optional() @@ -134,11 +152,37 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/saml2/:ssoIdentifier", + url: "/redirect/saml2/:samlConfigId", + method: "GET", + schema: { + params: z.object({ + samlConfigId: z.string().trim() + }), + querystring: z.object({ + callback_port: z.string().optional() + }) + }, + preValidation: (req, res) => + ( + passport.authenticate("saml", { + failureRedirect: "/", + additionalParams: { + RelayState: JSON.stringify({ + spInitiated: true, + callbackPort: req.query.callback_port ?? "" + }) + } + } as any) as any + )(req, res), + handler: () => {} + }); + + server.route({ + url: "/saml2/:samlConfigId", method: "POST", schema: { params: z.object({ - ssoIdentifier: z.string().trim() + samlConfigId: z.string().trim() }) }, preValidation: passport.authenticate("saml", { @@ -160,8 +204,11 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/config", method: "GET", + url: "/config", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { querystring: z.object({ @@ -177,7 +224,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { isActive: z.boolean(), entryPoint: z.string(), issuer: z.string(), - cert: z.string() + cert: z.string(), + lastUsed: z.date().nullable().optional() }) .optional() } @@ -186,6 +234,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const saml = await server.services.saml.getSaml({ actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.query.organizationId, type: "org" }); @@ -194,8 +244,11 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/config", method: "POST", + url: "/config", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ @@ -214,6 +267,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const saml = await server.services.saml.createSamlCfg({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.body.organizationId, ...req.body }); @@ -222,8 +277,11 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/config", method: "PATCH", + url: "/config", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z @@ -244,6 +302,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { const saml = await server.services.saml.updateSamlCfg({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.body.organizationId, ...req.body }); diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts new file mode 100644 index 000000000..8965c28f3 --- /dev/null +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -0,0 +1,585 @@ +import { z } from "zod"; + +import { ScimTokensSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerScimRouter = async (server: FastifyZodProvider) => { + server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { + try { + const strBody = body instanceof Buffer ? body.toString() : body; + + const json: unknown = JSON.parse(strBody); + done(null, json); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + + server.route({ + url: "/scim-tokens", + method: "POST", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + organizationId: z.string().trim(), + description: z.string().trim().default(""), + ttlDays: z.number().min(0).default(0) + }), + response: { + 200: z.object({ + scimToken: z.string().trim() + }) + } + }, + handler: async (req) => { + const { scimToken } = await server.services.scim.createScimToken({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + orgId: req.body.organizationId, + actorAuthMethod: req.permission.authMethod, + description: req.body.description, + ttlDays: req.body.ttlDays + }); + + return { scimToken }; + } + }); + + server.route({ + url: "/scim-tokens", + method: "GET", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + querystring: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + scimTokens: z.array(ScimTokensSchema) + }) + } + }, + handler: async (req) => { + const scimTokens = await server.services.scim.listScimTokens({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + orgId: req.query.organizationId + }); + + return { scimTokens }; + } + }); + + server.route({ + url: "/scim-tokens/:scimTokenId", + method: "DELETE", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + scimTokenId: z.string().trim() + }), + response: { + 200: z.object({ + scimToken: ScimTokensSchema + }) + } + }, + handler: async (req) => { + const scimToken = await server.services.scim.deleteScimToken({ + scimTokenId: req.params.scimTokenId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { scimToken }; + } + }); + + // SCIM server endpoints + server.route({ + url: "/Users", + method: "GET", + schema: { + querystring: z.object({ + startIndex: z.coerce.number().default(1), + count: z.coerce.number().default(20), + filter: z.string().trim().optional() + }), + response: { + 200: z.object({ + Resources: z.array( + z.object({ + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array( + z.object({ + primary: z.boolean(), + value: z.string(), + type: z.string().trim() + }) + ), + displayName: z.string().trim(), + active: z.boolean() + }) + ), + itemsPerPage: z.number(), + schemas: z.array(z.string()), + startIndex: z.number(), + totalResults: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const users = await req.server.services.scim.listScimUsers({ + startIndex: req.query.startIndex, + limit: req.query.count, + filter: req.query.filter, + orgId: req.permission.orgId + }); + return users; + } + }); + + server.route({ + url: "/Users/:orgMembershipId", + method: "GET", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + response: { + 201: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array( + z.object({ + primary: z.boolean(), + value: z.string(), + type: z.string().trim() + }) + ), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.getScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId + }); + return user; + } + }); + + server.route({ + url: "/Users", + method: "POST", + schema: { + body: z.object({ + schemas: z.array(z.string()), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z + .array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ) + .optional(), + // displayName: z.string().trim(), + active: z.boolean() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; + + const user = await req.server.services.scim.createScimUser({ + externalId: req.body.userName, + email: primaryEmail, + firstName: req.body.name.givenName, + lastName: req.body.name.familyName, + orgId: req.permission.orgId + }); + + return user; + } + }); + + server.route({ + url: "/Users/:orgMembershipId", + method: "DELETE", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.deleteScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId + }); + + return user; + } + }); + + server.route({ + url: "/Groups", + method: "POST", + schema: { + body: z.object({ + schemas: z.array(z.string()), + displayName: z.string().trim(), + members: z + .array( + z.object({ + value: z.string(), + display: z.string() + }) + ) + .optional() // okta-specific + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z + .array( + z.object({ + value: z.string(), + display: z.string() + }) + ) + .optional(), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.createScimGroup({ + orgId: req.permission.orgId, + ...req.body + }); + + return group; + } + }); + + server.route({ + url: "/Groups", + method: "GET", + schema: { + querystring: z.object({ + startIndex: z.coerce.number().default(1), + count: z.coerce.number().default(20), + filter: z.string().trim().optional() + }), + response: { + 200: z.object({ + Resources: z.array( + z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array(z.any()).length(0), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + ), + itemsPerPage: z.number(), + schemas: z.array(z.string()), + startIndex: z.number(), + totalResults: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const groups = await req.server.services.scim.listScimGroups({ + orgId: req.permission.orgId, + startIndex: req.query.startIndex, + limit: req.query.count + }); + + return groups; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "GET", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.getScimGroup({ + groupId: req.params.groupId, + orgId: req.permission.orgId + }); + return group; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "PUT", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), // infisical orgMembershipId + display: z.string() + }) + ) + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.updateScimGroupNamePut({ + groupId: req.params.groupId, + orgId: req.permission.orgId, + ...req.body + }); + + return group; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "PATCH", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + Operations: z.array( + z.union([ + z.object({ + op: z.literal("replace"), + value: z.object({ + id: z.string().trim(), + displayName: z.string().trim() + }) + }), + z.object({ + op: z.literal("remove"), + path: z.string().trim() + }), + z.object({ + op: z.literal("add"), + value: z.object({ + value: z.string().trim(), + display: z.string().trim().optional() + }) + }) + ]) + ) + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.updateScimGroupNamePatch({ + groupId: req.params.groupId, + orgId: req.permission.orgId, + operations: req.body.Operations + }); + + return group; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "DELETE", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.deleteScimGroup({ + groupId: req.params.groupId, + orgId: req.permission.orgId + }); + + return group; + } + }); + + server.route({ + url: "/Users/:orgMembershipId", + method: "PUT", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + displayName: z.string().trim(), + active: z.boolean() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.replaceScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId, + active: req.body.active + }); + return user; + } + }); +}; diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index dda8dbe38..f6a955625 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -9,6 +10,9 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi server.route({ url: "/", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z .object({ @@ -34,6 +38,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi const approval = await server.services.secretApprovalPolicy.createSecretApprovalPolicy({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.body.workspaceId, ...req.body, name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` @@ -45,6 +51,9 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi server.route({ url: "/:sapId", method: "PATCH", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ sapId: z.string() @@ -71,6 +80,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi const approval = await server.services.secretApprovalPolicy.updateSecretApprovalPolicy({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, secretPolicyId: req.params.sapId }); @@ -81,6 +92,9 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi server.route({ url: "/:sapId", method: "DELETE", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ sapId: z.string() @@ -96,6 +110,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi const approval = await server.services.secretApprovalPolicy.deleteSecretApprovalPolicy({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPolicyId: req.params.sapId }); return { approval }; @@ -105,6 +121,9 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi server.route({ url: "/", method: "GET", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim() @@ -120,6 +139,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi const approvals = await server.services.secretApprovalPolicy.getSecretApprovalPolicyByProjectId({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.query.workspaceId }); return { approvals }; @@ -129,6 +150,9 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi server.route({ url: "/board", method: "GET", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim(), @@ -146,6 +170,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.query.workspaceId, ...req.query }); diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index f33e8b0d0..2a9cc405d 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -10,13 +10,17 @@ import { } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApprovalStatus, RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretApprovalRequestRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim(), @@ -52,6 +56,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv const approvals = await server.services.secretApprovalRequest.getSecretApprovals({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.query, projectId: req.query.workspaceId }); @@ -60,8 +66,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }); server.route({ - url: "/count", method: "GET", + url: "/count", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim() @@ -80,6 +89,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv const approvals = await server.services.secretApprovalRequest.requestCount({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.query.workspaceId }); return { approvals }; @@ -89,6 +100,9 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv server.route({ url: "/:id/merge", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ id: z.string() @@ -104,6 +118,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv const { approval } = await server.services.secretApprovalRequest.mergeSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, approvalId: req.params.id }); return { approval }; @@ -111,8 +127,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }); server.route({ - url: "/:id/review", method: "POST", + url: "/:id/review", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ id: z.string() @@ -131,6 +150,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv const review = await server.services.secretApprovalRequest.reviewApproval({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, approvalId: req.params.id, status: req.body.status }); @@ -139,8 +160,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }); server.route({ - url: "/:id/status", method: "POST", + url: "/:id/status", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ id: z.string() @@ -159,6 +183,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv const approval = await server.services.secretApprovalRequest.updateApprovalStatus({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, approvalId: req.params.id, status: req.body.status }); @@ -193,8 +219,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv .array() .optional(); server.route({ - url: "/:id", method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ id: z.string() @@ -266,6 +295,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv const approval = await server.services.secretApprovalRequest.getSecretApprovalDetails({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.id }); return { approval }; diff --git a/backend/src/ee/routes/v1/secret-rotation-provider-router.ts b/backend/src/ee/routes/v1/secret-rotation-provider-router.ts index bcaf1ab39..58419d3b7 100644 --- a/backend/src/ee/routes/v1/secret-rotation-provider-router.ts +++ b/backend/src/ee/routes/v1/secret-rotation-provider-router.ts @@ -1,12 +1,16 @@ import { z } from "zod"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretRotationProviderRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:workspaceId", method: "GET", + url: "/:workspaceId", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -30,6 +34,8 @@ export const registerSecretRotationProviderRouter = async (server: FastifyZodPro const providers = await server.services.secretRotation.getProviderTemplates({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); return providers; diff --git a/backend/src/ee/routes/v1/secret-rotation-router.ts b/backend/src/ee/routes/v1/secret-rotation-router.ts index 062c51980..d951eb744 100644 --- a/backend/src/ee/routes/v1/secret-rotation-router.ts +++ b/backend/src/ee/routes/v1/secret-rotation-router.ts @@ -2,13 +2,17 @@ import { z } from "zod"; import { SecretRotationOutputsSchema, SecretRotationsSchema, SecretsSchema } from "@app/db/schemas"; import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretRotationRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ workspaceId: z.string().trim(), @@ -39,7 +43,9 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = handler: async (req) => { const secretRotation = await server.services.secretRotation.createRotation({ actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, + actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId }); @@ -50,6 +56,9 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = server.route({ url: "/restart", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ id: z.string().trim() @@ -73,6 +82,8 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = const secretRotation = await server.services.secretRotation.restartById({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, rotationId: req.body.id }); return { secretRotation }; @@ -82,6 +93,9 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = server.route({ url: "/", method: "GET", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim() @@ -123,6 +137,8 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = const secretRotations = await server.services.secretRotation.getByProjectId({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.query.workspaceId }); return { secretRotations }; @@ -130,8 +146,11 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = }); server.route({ - url: "/:id", method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ id: z.string().trim() @@ -155,6 +174,8 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = const secretRotation = await server.services.secretRotation.deleteById({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, rotationId: req.params.id }); return { secretRotation }; diff --git a/backend/src/ee/routes/v1/secret-scanning-router.ts b/backend/src/ee/routes/v1/secret-scanning-router.ts index 2970a4308..2604d7232 100644 --- a/backend/src/ee/routes/v1/secret-scanning-router.ts +++ b/backend/src/ee/routes/v1/secret-scanning-router.ts @@ -2,13 +2,17 @@ import { z } from "zod"; import { GitAppOrgSchema, SecretScanningGitRisksSchema } from "@app/db/schemas"; import { SecretScanningRiskStatus } from "@app/ee/services/secret-scanning/secret-scanning-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretScanningRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/create-installation-session/organization", method: "POST", + url: "/create-installation-session/organization", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ organizationId: z.string().trim() }), response: { @@ -22,6 +26,8 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = const session = await server.services.secretScanning.createInstallationSession({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.body.organizationId }); return session; @@ -29,8 +35,11 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = }); server.route({ - url: "/link-installation", method: "POST", + url: "/link-installation", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ installationId: z.string(), @@ -45,6 +54,8 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = const { installatedApp } = await server.services.secretScanning.linkInstallationToOrg({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body }); return installatedApp; @@ -52,8 +63,11 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = }); server.route({ - url: "/installation-status/organization/:organizationId", method: "GET", + url: "/installation-status/organization/:organizationId", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -65,6 +79,8 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = const appInstallationCompleted = await server.services.secretScanning.getOrgInstallationStatus({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId }); return { appInstallationCompleted }; @@ -74,6 +90,9 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = server.route({ url: "/organization/:organizationId/risks", method: "GET", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -85,6 +104,8 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = const { risks } = await server.services.secretScanning.getRisksByOrg({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId }); return { risks }; @@ -92,8 +113,11 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = }); server.route({ - url: "/organization/:organizationId/risks/:riskId/status", method: "POST", + url: "/organization/:organizationId/risks/:riskId/status", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim(), riskId: z.string().trim() }), body: z.object({ status: z.nativeEnum(SecretScanningRiskStatus) }), @@ -106,6 +130,8 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = const { risk } = await server.services.secretScanning.updateRiskStatus({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.organizationId, riskId: req.params.riskId, ...req.body diff --git a/backend/src/ee/routes/v1/secret-version-router.ts b/backend/src/ee/routes/v1/secret-version-router.ts index 269ed8636..0604135ba 100644 --- a/backend/src/ee/routes/v1/secret-version-router.ts +++ b/backend/src/ee/routes/v1/secret-version-router.ts @@ -1,13 +1,17 @@ import { z } from "zod"; import { SecretVersionsSchema } from "@app/db/schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretVersionRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:secretId/secret-versions", method: "GET", + url: "/:secretId/secret-versions", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ secretId: z.string() @@ -27,6 +31,8 @@ export const registerSecretVersionRouter = async (server: FastifyZodProvider) => const secretVersions = await server.services.secret.getSecretVersions({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, limit: req.query.limit, offset: req.query.offset, secretId: req.params.secretId diff --git a/backend/src/ee/routes/v1/snapshot-router.ts b/backend/src/ee/routes/v1/snapshot-router.ts index c3b9d2d98..6767f8383 100644 --- a/backend/src/ee/routes/v1/snapshot-router.ts +++ b/backend/src/ee/routes/v1/snapshot-router.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { SecretSnapshotsSchema, SecretTagsSchema, SecretVersionsSchema } from "@app/db/schemas"; +import { PROJECTS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,6 +10,9 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:secretSnapshotId", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ secretSnapshotId: z.string().trim() @@ -46,6 +51,8 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { const secretSnapshot = await server.services.snapshot.getSnapshotData({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.secretSnapshotId }); return { secretSnapshot }; @@ -55,9 +62,18 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:secretSnapshotId/rollback", + config: { + rateLimit: writeLimit + }, schema: { + description: "Roll back project secrets to those captured in a secret snapshot version.", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretSnapshotId: z.string().trim() + secretSnapshotId: z.string().trim().describe(PROJECTS.ROLLBACK_TO_SNAPSHOT.secretSnapshotId) }), response: { 200: z.object({ @@ -70,6 +86,8 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { const secretSnapshot = await server.services.snapshot.rollbackSnapshot({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.secretSnapshotId }); return { secretSnapshot }; diff --git a/backend/src/ee/routes/v1/trusted-ip-router.ts b/backend/src/ee/routes/v1/trusted-ip-router.ts index fd56a2cda..b6fc3cc90 100644 --- a/backend/src/ee/routes/v1/trusted-ip-router.ts +++ b/backend/src/ee/routes/v1/trusted-ip-router.ts @@ -2,13 +2,17 @@ import { z } from "zod"; import { TrustedIpsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:workspaceId/trusted-ips", method: "GET", + url: "/:workspaceId/trusted-ips", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -22,17 +26,22 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const trustedIps = await server.services.trustedIp.listIpsByProjectId({ + actorAuthMethod: req.permission.authMethod, projectId: req.params.workspaceId, actor: req.permission.type, - actorId: req.permission.id + actorId: req.permission.id, + actorOrgId: req.permission.orgId }); return { trustedIps }; } }); server.route({ - url: "/:workspaceId/trusted-ips", method: "POST", + url: "/:workspaceId/trusted-ips", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -51,9 +60,11 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const { trustedIp, project } = await server.services.trustedIp.addProjectIp({ + actorAuthMethod: req.permission.authMethod, projectId: req.params.workspaceId, actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, ...req.body }); await server.services.auditLog.createAuditLog({ @@ -74,8 +85,11 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/trusted-ips/:trustedIpId", method: "PATCH", + url: "/:workspaceId/trusted-ips/:trustedIpId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim(), @@ -97,6 +111,8 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId, actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, trustedIpId: req.params.trustedIpId, ...req.body }); @@ -118,8 +134,11 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/trusted-ips/:trustedIpId", method: "DELETE", + url: "/:workspaceId/trusted-ips/:trustedIpId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim(), @@ -137,6 +156,8 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId, actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, trustedIpId: req.params.trustedIpId }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts new file mode 100644 index 000000000..7225caecf --- /dev/null +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -0,0 +1,256 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; +import { z } from "zod"; + +import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; +import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/permanent", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + isTemporary: false, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + method: "POST", + url: "/temporary", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + slug: req.body.slug ? slugify(req.body.slug) : `privilege-${slugify(alphaNumericNanoId(12))}`, + isTemporary: true, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + method: "PATCH", + url: "/:privilegeId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + privilegeId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.privilegeId) + }), + body: z + .object({ + slug: z + .string() + .max(60) + .trim() + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime) + }) + .partial(), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.updateById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + method: "DELETE", + url: "/:privilegeId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.DELETE.privilegeId) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.deleteById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + projectMembershipId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.LIST.projectMembershipId) + }), + response: { + 200: z.object({ + privileges: ProjectUserAdditionalPrivilegeSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privileges = await server.services.projectUserAdditionalPrivilege.listPrivileges({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + projectMembershipId: req.query.projectMembershipId + }); + return { privileges }; + } + }); + + server.route({ + method: "GET", + url: "/:privilegeId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.GET_BY_PRIVILEGEID.privilegeId) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.getPrivilegeDetailsById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts new file mode 100644 index 000000000..e14854d8f --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalPolicyApproverDALFactory = ReturnType; + +export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { + const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); + return { ...accessApprovalPolicyApproverOrm }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts new file mode 100644 index 000000000..88e288832 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -0,0 +1,76 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex"; + +export type TAccessApprovalPolicyDALFactory = ReturnType; + +export const accessApprovalPolicyDALFactory = (db: TDbClient) => { + const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy); + + const accessApprovalPolicyFindQuery = async (tx: Knex, filter: TFindFilter) => { + const result = await tx(TableName.AccessApprovalPolicy) + // eslint-disable-next-line + .where(buildFindFilter(filter)) + .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .join( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + .select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) + .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) + .select(tx.ref("projectId").withSchema(TableName.Environment)) + .select(selectAllTableCols(TableName.AccessApprovalPolicy)); + + return result; + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await accessApprovalPolicyFindQuery(tx || db, { + [`${TableName.AccessApprovalPolicy}.id` as "id"]: id + }); + const formatedDoc = mergeOneToManyRelation( + doc, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + const find = async (filter: TFindFilter, tx?: Knex) => { + try { + const docs = await accessApprovalPolicyFindQuery(tx || db, filter); + const formatedDoc = mergeOneToManyRelation( + docs, + "id", + ({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({ + ...el, + envId, + environment: { id: envId, name, slug } + }), + ({ approverId }) => approverId, + "approvers" + ); + return formatedDoc.map((policy) => ({ ...policy, secretPath: policy.secretPath || undefined })); + } catch (error) { + throw new DatabaseError({ error, name: "Find" }); + } + }; + + return { ...accessApprovalPolicyOrm, find, findById }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts new file mode 100644 index 000000000..7b0a2681f --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-fns.ts @@ -0,0 +1,36 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; + +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TVerifyApprovers } from "./access-approval-policy-types"; + +export const verifyApprovers = async ({ + userIds, + projectId, + orgId, + envSlug, + actorAuthMethod, + secretPath, + permissionService +}: TVerifyApprovers) => { + for await (const userId of userIds) { + try { + const { permission: approverPermission } = await permissionService.getProjectPermission( + ActorType.USER, + userId, + projectId, + actorAuthMethod, + orgId + ); + + ForbiddenError.from(approverPermission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: envSlug, secretPath }) + ); + } catch (err) { + throw new BadRequestError({ message: "One or more approvers doesn't have access to be specified secret path" }); + } + } +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts new file mode 100644 index 000000000..51a51abb5 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -0,0 +1,273 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; +import { verifyApprovers } from "./access-approval-policy-fns"; +import { + TCreateAccessApprovalPolicy, + TDeleteAccessApprovalPolicy, + TGetAccessPolicyCountByEnvironmentDTO, + TListAccessApprovalPoliciesDTO, + TUpdateAccessApprovalPolicy +} from "./access-approval-policy-types"; + +type TSecretApprovalPolicyServiceFactoryDep = { + projectDAL: TProjectDALFactory; + permissionService: Pick; + accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; + projectEnvDAL: Pick; + accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + projectMembershipDAL: Pick; +}; + +export type TAccessApprovalPolicyServiceFactory = ReturnType; + +export const accessApprovalPolicyServiceFactory = ({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectDAL, + projectMembershipDAL +}: TSecretApprovalPolicyServiceFactoryDep) => { + const createAccessApprovalPolicy = async ({ + name, + actor, + actorId, + actorOrgId, + secretPath, + actorAuthMethod, + approvals, + approvers, + projectSlug, + environment + }: TCreateAccessApprovalPolicy) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (approvals > approvers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretApproval + ); + const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); + if (!env) throw new BadRequestError({ message: "Environment not found" }); + + const secretApprovers = await projectMembershipDAL.find({ + projectId: project.id, + $in: { id: approvers } + }); + + if (secretApprovers.length !== approvers.length) { + throw new BadRequestError({ message: "Approver not found in project" }); + } + + await verifyApprovers({ + projectId: project.id, + orgId: actorOrgId, + envSlug: environment, + secretPath, + actorAuthMethod, + permissionService, + userIds: secretApprovers.map((approver) => approver.userId) + }); + + const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { + const doc = await accessApprovalPolicyDAL.create( + { + envId: env.id, + approvals, + secretPath, + name + }, + tx + ); + await accessApprovalPolicyApproverDAL.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + return doc; + }); + return { ...accessApproval, environment: env, projectId: project.id }; + }; + + const getAccessApprovalPolicyByProjectSlug = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectSlug + }: TListAccessApprovalPoliciesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + // Anyone in the project should be able to get the policies. + /* const { permission } = */ await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + return accessApprovalPolicies; + }; + + const updateAccessApprovalPolicy = async ({ + policyId, + approvers, + secretPath, + name, + actorId, + actor, + actorOrgId, + actorAuthMethod, + approvals + }: TUpdateAccessApprovalPolicy) => { + const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); + if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + accessApprovalPolicy.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + + const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { + const doc = await accessApprovalPolicyDAL.updateById( + accessApprovalPolicy.id, + { + approvals, + secretPath, + name + }, + tx + ); + if (approvers) { + // Find the workspace project memberships of the users passed in the approvers array + const secretApprovers = await projectMembershipDAL.find( + { + projectId: accessApprovalPolicy.projectId, + $in: { id: approvers } + }, + { tx } + ); + + await verifyApprovers({ + projectId: accessApprovalPolicy.projectId, + orgId: actorOrgId, + envSlug: accessApprovalPolicy.environment.slug, + secretPath: doc.secretPath!, + actorAuthMethod, + permissionService, + userIds: secretApprovers.map((approver) => approver.userId) + }); + + if (secretApprovers.length !== approvers.length) + throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); + await accessApprovalPolicyApproverDAL.insertMany( + secretApprovers.map(({ id }) => ({ + approverId: id, + policyId: doc.id + })), + tx + ); + } + return doc; + }); + return { + ...updatedPolicy, + environment: accessApprovalPolicy.environment, + projectId: accessApprovalPolicy.projectId + }; + }; + + const deleteAccessApprovalPolicy = async ({ + policyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteAccessApprovalPolicy) => { + const policy = await accessApprovalPolicyDAL.findById(policyId); + if (!policy) throw new BadRequestError({ message: "Secret approval policy not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + policy.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.SecretApproval + ); + + await accessApprovalPolicyDAL.deleteById(policyId); + return policy; + }; + + const getAccessPolicyCountByEnvSlug = async ({ + actor, + actorOrgId, + actorAuthMethod, + projectSlug, + actorId, + envSlug + }: TGetAccessPolicyCountByEnvironmentDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new BadRequestError({ message: "User not found in project" }); + + const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); + if (!environment) throw new BadRequestError({ message: "Environment not found" }); + + const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + if (!policies) throw new BadRequestError({ message: "No policies found" }); + + return { count: policies.length }; + }; + + return { + getAccessPolicyCountByEnvSlug, + createAccessApprovalPolicy, + deleteAccessApprovalPolicy, + updateAccessApprovalPolicy, + getAccessApprovalPolicyByProjectSlug + }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts new file mode 100644 index 000000000..601561b68 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -0,0 +1,44 @@ +import { TProjectPermission } from "@app/lib/types"; +import { ActorAuthMethod } from "@app/services/auth/auth-type"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; + +export type TVerifyApprovers = { + userIds: string[]; + permissionService: Pick; + envSlug: string; + actorAuthMethod: ActorAuthMethod; + secretPath: string; + projectId: string; + orgId: string; +}; + +export type TCreateAccessApprovalPolicy = { + approvals: number; + secretPath: string; + environment: string; + approvers: string[]; + projectSlug: string; + name: string; +} & Omit; + +export type TUpdateAccessApprovalPolicy = { + policyId: string; + approvals?: number; + approvers?: string[]; + secretPath?: string; + name?: string; +} & Omit; + +export type TDeleteAccessApprovalPolicy = { + policyId: string; +} & Omit; + +export type TGetAccessPolicyCountByEnvironmentDTO = { + envSlug: string; + projectSlug: string; +} & Omit; + +export type TListAccessApprovalPoliciesDTO = { + projectSlug: string; +} & Omit; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts new file mode 100644 index 000000000..c3f4c72a6 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -0,0 +1,266 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; + +import { ApprovalStatus } from "./access-approval-request-types"; + +export type TAccessApprovalRequestDALFactory = ReturnType; + +export const accessApprovalRequestDALFactory = (db: TDbClient) => { + const accessApprovalRequestOrm = ormify(db, TableName.AccessApprovalRequest); + + const findRequestsWithPrivilegeByPolicyIds = async (policyIds: string[]) => { + try { + const docs = await db(TableName.AccessApprovalRequest) + .whereIn(`${TableName.AccessApprovalRequest}.policyId`, policyIds) + + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.AccessApprovalRequest}.privilegeId`, + `${TableName.ProjectUserAdditionalPrivilege}.id` + ) + .leftJoin( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + .leftJoin( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select( + db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), + db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), + db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + ) + + .select(db.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)) + + .select( + db.ref("projectId").withSchema(TableName.Environment), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + + .select( + db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus") + ) + + .select( + db + .ref("projectMembershipId") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeMembershipId"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("privilegeTemporaryAccessEndTime"), + + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions") + ) + .orderBy(`${TableName.AccessApprovalRequest}.createdAt`, "desc"); + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (doc) => ({ + ...AccessApprovalRequestsSchema.parse(doc), + projectId: doc.projectId, + environment: doc.envSlug, + environmentName: doc.envName, + policy: { + id: doc.policyId, + name: doc.policyName, + approvals: doc.policyApprovals, + secretPath: doc.policySecretPath, + envId: doc.policyEnvId + }, + privilege: doc.privilegeId + ? { + membershipId: doc.privilegeMembershipId, + isTemporary: doc.privilegeIsTemporary, + temporaryMode: doc.privilegeTemporaryMode, + temporaryRange: doc.privilegeTemporaryRange, + temporaryAccessStartTime: doc.privilegeTemporaryAccessStartTime, + temporaryAccessEndTime: doc.privilegeTemporaryAccessEndTime, + permissions: doc.privilegePermissions + } + : null, + + isApproved: !!doc.privilegeId + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + }, + { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + ] + }); + + if (!formattedDocs) return []; + + return formattedDocs.map((doc) => ({ + ...doc, + policy: { ...doc.policy, approvers: doc.approvers } + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); + } + }; + + const findQuery = (filter: TFindFilter, tx: Knex) => + tx(TableName.AccessApprovalRequest) + .where(filter) + .join( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + + .join( + TableName.AccessApprovalPolicyApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyApprover}.policyId` + ) + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select( + tx.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"), + tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), + tx.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"), + tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), + tx.ref("projectId").withSchema(TableName.Environment), + tx.ref("slug").withSchema(TableName.Environment).as("environment"), + tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover) + ); + + const findById = async (id: string, tx?: Knex) => { + try { + const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db); + const docs = await sql; + const formatedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + ...AccessApprovalRequestsSchema.parse(el), + projectId: el.projectId, + environment: el.environment, + policy: { + id: el.policyId, + name: el.policyName, + approvals: el.policyApprovals, + secretPath: el.policySecretPath + } + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + }, + { key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId } + ] + }); + if (!formatedDoc?.[0]) return; + return { + ...formatedDoc[0], + policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" }); + } + }; + + const getCount = async ({ projectId }: { projectId: string }) => { + try { + const accessRequests = await db(TableName.AccessApprovalRequest) + .leftJoin( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalRequest}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.AccessApprovalRequest}.privilegeId`, + `${TableName.ProjectUserAdditionalPrivilege}.id` + ) + + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + + .where(`${TableName.Environment}.projectId`, projectId) + .select(selectAllTableCols(TableName.AccessApprovalRequest)) + .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) + .select(db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId")); + + const formattedRequests = sqlNestRelationships({ + data: accessRequests, + key: "id", + parentMapper: (doc) => ({ + ...AccessApprovalRequestsSchema.parse(doc) + }), + childrenMapper: [ + { + key: "reviewerMemberId", + label: "reviewers" as const, + mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined) + } + ] + }); + + // an approval is pending if there is no reviewer rejections and no privilege ID is set + const pendingApprovals = formattedRequests.filter( + (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + ); + + // an approval is finalized if there are any rejections or a privilege ID is set + const finalizedApprovals = formattedRequests.filter( + (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + ); + + return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; + } catch (error) { + throw new DatabaseError({ error, name: "GetCountAccessApprovalRequest" }); + } + }; + + return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount }; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts new file mode 100644 index 000000000..90b42aaf7 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-fns.ts @@ -0,0 +1,53 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TVerifyPermission } from "./access-approval-request-types"; + +function filterUnique(value: string, index: number, array: string[]) { + return array.indexOf(value) === index; +} + +export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => { + const permission = unpackRules( + permissions as PackRule<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + conditions?: Record; + action: string; + subject: [string]; + }>[] + ); + + if (!permission || !permission.length) { + throw new UnauthorizedError({ message: "No permission provided" }); + } + + const requestedPermissions: string[] = []; + + for (const p of permission) { + if (p.action[0] === "read") requestedPermissions.push("Read Access"); + if (p.action[0] === "create") requestedPermissions.push("Create Access"); + if (p.action[0] === "delete") requestedPermissions.push("Delete Access"); + if (p.action[0] === "edit") requestedPermissions.push("Edit Access"); + } + + const firstPermission = permission[0]; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const permissionSecretPath = firstPermission.conditions?.secretPath?.$glob; + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment + const permissionEnv = firstPermission.conditions?.environment; + + if (!permissionEnv || typeof permissionEnv !== "string") { + throw new UnauthorizedError({ message: "Permission environment is not a string" }); + } + if (!permissionSecretPath || typeof permissionSecretPath !== "string") { + throw new UnauthorizedError({ message: "Permission path is not a string" }); + } + + return { + envSlug: permissionEnv, + secretPath: permissionSecretPath, + accessTypes: requestedPermissions.filter(filterUnique) + }; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts new file mode 100644 index 000000000..251015b22 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-reviewer-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalRequestReviewerDALFactory = ReturnType; + +export const accessApprovalRequestReviewerDALFactory = (db: TDbClient) => { + const secretApprovalRequestReviewerOrm = ormify(db, TableName.AccessApprovalRequestReviewer); + return secretApprovalRequestReviewerOrm; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts new file mode 100644 index 000000000..becdb78da --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -0,0 +1,369 @@ +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; + +import { ProjectMembershipRole } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; +import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; +import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; +import { verifyRequestedPermissions } from "./access-approval-request-fns"; +import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal"; +import { + ApprovalStatus, + TCreateAccessApprovalRequestDTO, + TGetAccessRequestCountDTO, + TListApprovalRequestsDTO, + TReviewAccessRequestDTO +} from "./access-approval-request-types"; + +type TSecretApprovalRequestServiceFactoryDep = { + additionalPrivilegeDAL: Pick; + permissionService: Pick; + accessApprovalPolicyApproverDAL: Pick; + projectEnvDAL: Pick; + projectDAL: Pick; + accessApprovalRequestDAL: Pick< + TAccessApprovalRequestDALFactory, + | "create" + | "find" + | "findRequestsWithPrivilegeByPolicyIds" + | "findById" + | "transaction" + | "updateById" + | "findOne" + | "getCount" + >; + accessApprovalPolicyDAL: Pick; + accessApprovalRequestReviewerDAL: Pick< + TAccessApprovalRequestReviewerDALFactory, + "create" | "find" | "findOne" | "transaction" + >; + projectMembershipDAL: Pick; + smtpService: Pick; + userDAL: Pick; +}; + +export type TAccessApprovalRequestServiceFactory = ReturnType; + +export const accessApprovalRequestServiceFactory = ({ + projectDAL, + projectEnvDAL, + permissionService, + accessApprovalRequestDAL, + accessApprovalRequestReviewerDAL, + projectMembershipDAL, + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + additionalPrivilegeDAL, + smtpService, + userDAL +}: TSecretApprovalRequestServiceFactoryDep) => { + const createAccessApprovalRequest = async ({ + isTemporary, + temporaryRange, + actorId, + permissions: requestedPermissions, + actor, + actorOrgId, + actorAuthMethod, + projectSlug + }: TCreateAccessApprovalRequestDTO) => { + const cfg = getConfig(); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + // Anyone can create an access approval request. + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + const requestedByUser = await userDAL.findUserByProjectMembershipId(membership.id); + if (!requestedByUser) throw new UnauthorizedError({ message: "User not found" }); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ permissions: requestedPermissions }); + const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); + + if (!environment) throw new UnauthorizedError({ message: "Environment not found" }); + + const policy = await accessApprovalPolicyDAL.findOne({ + envId: environment.id, + secretPath + }); + if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." }); + + const approvers = await accessApprovalPolicyApproverDAL.find({ + policyId: policy.id + }); + + const approverUsers = await userDAL.findUsersByProjectMembershipIds( + approvers.map((approver) => approver.approverId) + ); + + const duplicateRequests = await accessApprovalRequestDAL.find({ + policyId: policy.id, + requestedBy: membership.id, + permissions: JSON.stringify(requestedPermissions), + isTemporary + }); + + if (duplicateRequests?.length > 0) { + for await (const duplicateRequest of duplicateRequests) { + if (duplicateRequest.privilegeId) { + const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId); + + const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string)); + + if (!isExpired || !privilege.isTemporary) { + throw new BadRequestError({ message: "You already have an active privilege with the same criteria" }); + } + } else { + const reviewers = await accessApprovalRequestReviewerDAL.find({ + requestId: duplicateRequest.id + }); + + const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); + + if (!isRejected) { + throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); + } + } + } + } + + const approval = await accessApprovalRequestDAL.transaction(async (tx) => { + const approvalRequest = await accessApprovalRequestDAL.create( + { + policyId: policy.id, + requestedBy: membership.id, + temporaryRange: temporaryRange || null, + permissions: JSON.stringify(requestedPermissions), + isTemporary + }, + tx + ); + + await smtpService.sendMail({ + recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), + subjectLine: "Access Approval Request", + + substitutions: { + projectName: project.name, + requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`, + requesterEmail: requestedByUser.email, + isTemporary, + ...(isTemporary && { + expiresIn: ms(ms(temporaryRange || ""), { long: true }) + }), + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval` + }, + template: SmtpTemplates.AccessApprovalRequest + }); + + return approvalRequest; + }); + + return { request: approval }; + }; + + const listApprovalRequests = async ({ + projectSlug, + authorProjectMembershipId, + envSlug, + actor, + actorOrgId, + actorId, + actorAuthMethod + }: TListApprovalRequestsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); + + if (authorProjectMembershipId) { + requests = requests.filter((request) => request.requestedBy === authorProjectMembershipId); + } + + if (envSlug) { + requests = requests.filter((request) => request.environment === envSlug); + } + + return { requests }; + }; + + const reviewAccessRequest = async ({ + requestId, + actor, + status, + actorId, + actorAuthMethod, + actorOrgId + }: TReviewAccessRequestDTO) => { + const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); + if (!accessApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); + + const { policy } = accessApprovalRequest; + const { membership, hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + accessApprovalRequest.projectId, + actorAuthMethod, + actorOrgId + ); + + if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" }); + + if ( + !hasRole(ProjectMembershipRole.Admin) && + accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user + !policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver + ) { + throw new UnauthorizedError({ message: "You are not authorized to approve this request" }); + } + + const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id); + + await verifyApprovers({ + projectId: accessApprovalRequest.projectId, + orgId: actorOrgId, + envSlug: accessApprovalRequest.environment, + secretPath: accessApprovalRequest.policy.secretPath!, + actorAuthMethod, + permissionService, + userIds: [reviewerProjectMembership.userId] + }); + + const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); + if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { + throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); + } + + const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { + const review = await accessApprovalRequestReviewerDAL.findOne( + { + requestId: accessApprovalRequest.id, + member: membership.id + }, + tx + ); + if (!review) { + const newReview = await accessApprovalRequestReviewerDAL.create( + { + status, + requestId: accessApprovalRequest.id, + member: membership.id + }, + tx + ); + + const allReviews = [...existingReviews, newReview]; + + const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + + // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved. + if (approvedReviews.length === policy.approvals) { + if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { + throw new BadRequestError({ message: "Temporary range is required for temporary access" }); + } + + let privilegeId: string | null = null; + + if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { + // Permanent access + const privilege = await additionalPrivilegeDAL.create( + { + projectMembershipId: accessApprovalRequest.requestedBy, + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + permissions: JSON.stringify(accessApprovalRequest.permissions) + }, + tx + ); + privilegeId = privilege.id; + } else { + // Temporary access + const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); + const startTime = new Date(); + + const privilege = await additionalPrivilegeDAL.create( + { + projectMembershipId: accessApprovalRequest.requestedBy, + slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + permissions: JSON.stringify(accessApprovalRequest.permissions), + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: accessApprovalRequest.temporaryRange!, + temporaryAccessStartTime: startTime, + temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + }, + tx + ); + privilegeId = privilege.id; + } + + await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx); + } + + return newReview; + } + throw new BadRequestError({ message: "You have already reviewed this request" }); + }); + + return reviewStatus; + }; + + const getCount = async ({ projectSlug, actor, actorAuthMethod, actorId, actorOrgId }: TGetAccessRequestCountDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new UnauthorizedError({ message: "Project not found" }); + + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + if (!membership) throw new BadRequestError({ message: "User not found in project" }); + + const count = await accessApprovalRequestDAL.getCount({ projectId: project.id }); + + return { count }; + }; + + return { + createAccessApprovalRequest, + listApprovalRequests, + reviewAccessRequest, + getCount + }; +}; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts new file mode 100644 index 000000000..e11ca58d5 --- /dev/null +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -0,0 +1,33 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum ApprovalStatus { + PENDING = "pending", + APPROVED = "approved", + REJECTED = "rejected" +} + +export type TVerifyPermission = { + permissions: unknown; +}; + +export type TGetAccessRequestCountDTO = { + projectSlug: string; +} & Omit; + +export type TReviewAccessRequestDTO = { + requestId: string; + status: ApprovalStatus; +} & Omit; + +export type TCreateAccessApprovalRequestDTO = { + projectSlug: string; + permissions: unknown; + isTemporary: boolean; + temporaryRange?: string; +} & Omit; + +export type TListApprovalRequestsDTO = { + projectSlug: string; + authorProjectMembershipId?: string; + envSlug?: string; +} & Omit; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts new file mode 100644 index 000000000..436821ae9 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAuditLogStreamDALFactory = ReturnType; + +export const auditLogStreamDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.AuditLogStream); + + return orm; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts new file mode 100644 index 000000000..0e313b59b --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -0,0 +1,233 @@ +import { ForbiddenError } from "@casl/ability"; +import { RawAxiosRequestHeaders } from "axios"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { request } from "@app/lib/config/request"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { validateLocalIps } from "@app/lib/validator"; + +import { AUDIT_LOG_STREAM_TIMEOUT } from "../audit-log/audit-log-queue"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; +import { + LogStreamHeaders, + TCreateAuditLogStreamDTO, + TDeleteAuditLogStreamDTO, + TGetDetailsAuditLogStreamDTO, + TListAuditLogStreamDTO, + TUpdateAuditLogStreamDTO +} from "./audit-log-stream-types"; + +type TAuditLogStreamServiceFactoryDep = { + auditLogStreamDAL: TAuditLogStreamDALFactory; + permissionService: Pick; + licenseService: Pick; +}; + +export type TAuditLogStreamServiceFactory = ReturnType; + +export const auditLogStreamServiceFactory = ({ + auditLogStreamDAL, + permissionService, + licenseService +}: TAuditLogStreamServiceFactoryDep) => { + const create = async ({ + url, + actor, + headers = [], + actorId, + actorOrgId, + actorAuthMethod + }: TCreateAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.auditLogStreams) + throw new BadRequestError({ + message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." + }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + validateLocalIps(url); + + const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + if (totalStreams.length >= plan.auditLogStreamLimit) { + throw new BadRequestError({ + message: + "Failed to create audit log streams due to plan limit reached. Kindly contact Infisical to add more streams." + }); + } + + // testing connection first + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + await request + .post( + url, + { ping: "ok" }, + { + headers: streamHeaders, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); + }); + const encryptedHeaders = headers ? infisicalSymmetricEncypt(JSON.stringify(headers)) : undefined; + const logStream = await auditLogStreamDAL.create({ + orgId: actorOrgId, + url, + ...(encryptedHeaders + ? { + encryptedHeadersCiphertext: encryptedHeaders.ciphertext, + encryptedHeadersIV: encryptedHeaders.iv, + encryptedHeadersTag: encryptedHeaders.tag, + encryptedHeadersAlgorithm: encryptedHeaders.algorithm, + encryptedHeadersKeyEncoding: encryptedHeaders.encoding + } + : {}) + }); + return logStream; + }; + + const updateById = async ({ + id, + url, + actor, + headers = [], + actorId, + actorOrgId, + actorAuthMethod + }: TUpdateAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.auditLogStreams) + throw new BadRequestError({ + message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." + }); + + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + if (url) validateLocalIps(url); + + // testing connection first + const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + if (headers.length) + headers.forEach(({ key, value }) => { + streamHeaders[key] = value; + }); + + await request + .post( + url || logStream.url, + { ping: "ok" }, + { + headers: streamHeaders, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + } + ) + .catch((err) => { + throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); + }); + + const encryptedHeaders = headers ? infisicalSymmetricEncypt(JSON.stringify(headers)) : undefined; + const updatedLogStream = await auditLogStreamDAL.updateById(id, { + url, + ...(encryptedHeaders + ? { + encryptedHeadersCiphertext: encryptedHeaders.ciphertext, + encryptedHeadersIV: encryptedHeaders.iv, + encryptedHeadersTag: encryptedHeaders.tag, + encryptedHeadersAlgorithm: encryptedHeaders.algorithm, + encryptedHeadersKeyEncoding: encryptedHeaders.encoding + } + : {}) + }); + return updatedLogStream; + }; + + const deleteById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TDeleteAuditLogStreamDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Missing org id from token" }); + + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); + + const deletedLogStream = await auditLogStreamDAL.deleteById(id); + return deletedLogStream; + }; + + const getById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TGetDetailsAuditLogStreamDTO) => { + const logStream = await auditLogStreamDAL.findById(id); + if (!logStream) throw new BadRequestError({ message: "Audit log stream not found" }); + + const { orgId } = logStream; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const headers = + logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + tag: logStream.encryptedHeadersTag, + iv: logStream.encryptedHeadersIV, + ciphertext: logStream.encryptedHeadersCiphertext, + keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding + }) + ) as LogStreamHeaders[]) + : undefined; + + return { ...logStream, headers }; + }; + + const list = async ({ actor, actorId, actorOrgId, actorAuthMethod }: TListAuditLogStreamDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); + return logStreams; + }; + + return { + create, + updateById, + deleteById, + getById, + list + }; +}; diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts new file mode 100644 index 000000000..3c22251d7 --- /dev/null +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-types.ts @@ -0,0 +1,27 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type LogStreamHeaders = { + key: string; + value: string; +}; + +export type TCreateAuditLogStreamDTO = Omit & { + url: string; + headers?: LogStreamHeaders[]; +}; + +export type TUpdateAuditLogStreamDTO = Omit & { + id: string; + url?: string; + headers?: LogStreamHeaders[]; +}; + +export type TDeleteAuditLogStreamDTO = Omit & { + id: string; +}; + +export type TListAuditLogStreamDTO = Omit; + +export type TGetDetailsAuditLogStreamDTO = Omit & { + id: string; +}; diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 6f2c93221..6c563b573 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,13 +1,21 @@ +import { RawAxiosRequestHeaders } from "axios"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { request } from "@app/lib/config/request"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TAuditLogStreamDALFactory } from "../audit-log-stream/audit-log-stream-dal"; +import { LogStreamHeaders } from "../audit-log-stream/audit-log-stream-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TCreateAuditLogDTO } from "./audit-log-types"; type TAuditLogQueueServiceFactoryDep = { auditLogDAL: TAuditLogDALFactory; + auditLogStreamDAL: Pick; queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; @@ -15,16 +23,20 @@ type TAuditLogQueueServiceFactoryDep = { export type TAuditLogQueueServiceFactory = ReturnType; +// keep this timeout 5s it must be fast because else the queue will take time to finish +// audit log is a crowded queue thus needs to be fast +export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000; export const auditLogQueueServiceFactory = ({ auditLogDAL, queueService, projectDAL, - licenseService + licenseService, + auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep) => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { removeOnFail: { - count: 5 + count: 3 }, removeOnComplete: true }); @@ -46,7 +58,8 @@ export const auditLogQueueServiceFactory = ({ const ttl = plan.auditLogsRetentionDays * MS_IN_DAY; // skip inserting if audit log retention is 0 meaning its not supported if (ttl === 0) return; - await auditLogDAL.create({ + + const auditLog = await auditLogDAL.create({ actor: actor.type, actorMetadata: actor.metadata, userAgent, @@ -58,6 +71,46 @@ export const auditLogQueueServiceFactory = ({ eventMetadata: event.metadata, userAgentType }); + + const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; + await Promise.allSettled( + logStreams.map( + async ({ + url, + encryptedHeadersTag, + encryptedHeadersIV, + encryptedHeadersKeyEncoding, + encryptedHeadersCiphertext + }) => { + const streamHeaders = + encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag + ? (JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding, + iv: encryptedHeadersIV, + tag: encryptedHeadersTag, + ciphertext: encryptedHeadersCiphertext + }) + ) as LogStreamHeaders[]) + : []; + + const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; + + if (streamHeaders.length) + streamHeaders.forEach(({ key, value }) => { + headers[key] = value; + }); + + return request.post(url, auditLog, { + headers, + // request timeout + timeout: AUDIT_LOG_STREAM_TIMEOUT, + // connection timeout + signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) + }); + } + ) + ); }); queueService.start(QueueName.AuditLogPrune, async () => { diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index c1d5c6925..1564c6dcb 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -30,10 +30,18 @@ export const auditLogServiceFactory = ({ startDate, actor, actorId, + actorOrgId, + actorAuthMethod, projectId, auditLogActor }: TListProjectAuditLogDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); const auditLogs = await auditLogDAL.find({ startDate, @@ -57,6 +65,7 @@ export const auditLogServiceFactory = ({ if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) { if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" }); } + return auditLogQueue.pushToLog(data); }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 011dffebe..e512389d7 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = { export type TCreateAuditLogDTO = { event: Event; - actor: UserActor | IdentityActor | ServiceActor; + actor: UserActor | IdentityActor | ServiceActor | ScimClientActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -51,6 +51,7 @@ export enum EventType { UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", + MANUAL_SYNC_INTEGRATION = "manual-sync-integration", ADD_TRUSTED_IP = "add-trusted-ip", UPDATE_TRUSTED_IP = "update-trusted-ip", DELETE_TRUSTED_IP = "delete-trusted-ip", @@ -63,9 +64,21 @@ export enum EventType { ADD_IDENTITY_UNIVERSAL_AUTH = "add-identity-universal-auth", UPDATE_IDENTITY_UNIVERSAL_AUTH = "update-identity-universal-auth", GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", + LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", + ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", + UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", + GET_IDENTITY_KUBERNETES_AUTH = "get-identity-kubernetes-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + LOGIN_IDENTITY_GCP_AUTH = "login-identity-gcp-auth", + ADD_IDENTITY_GCP_AUTH = "add-identity-gcp-auth", + UPDATE_IDENTITY_GCP_AUTH = "update-identity-gcp-auth", + GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", + ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", + UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", + GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -92,7 +105,8 @@ export enum EventType { interface UserActorMetadata { userId: string; - email: string; + email?: string | null; + username: string; } interface ServiceActorMetadata { @@ -105,6 +119,8 @@ interface IdentityActorMetadata { name: string; } +interface ScimClientActorMetadata {} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -120,7 +136,12 @@ export interface IdentityActor { metadata: IdentityActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor; +export interface ScimClientActor { + type: ActorType.SCIM_CLIENT; + metadata: ScimClientActorMetadata; +} + +export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -261,6 +282,25 @@ interface DeleteIntegrationEvent { }; } +interface ManualSyncIntegrationEvent { + type: EventType.MANUAL_SYNC_INTEGRATION; + metadata: { + integrationId: string; + integration: string; + 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: { @@ -375,6 +415,50 @@ interface GetIdentityUniversalAuthEvent { }; } +interface LoginIdentityKubernetesAuthEvent { + type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + identityKubernetesAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityKubernetesAuthEvent { + type: EventType.ADD_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + kubernetesHost: string; + allowedNamespaces: string; + allowedNames: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityKubernetesAuthEvent { + type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH; + metadata: { + identityId: string; + kubernetesHost?: string; + allowedNamespaces?: string; + allowedNames?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityKubernetesAuthEvent { + type: EventType.GET_IDENTITY_KUBERNETES_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateIdentityUniversalAuthClientSecretEvent { type: EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -398,6 +482,96 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface LoginIdentityGcpAuthEvent { + type: EventType.LOGIN_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + identityGcpAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityGcpAuthEvent { + type: EventType.ADD_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + type: string; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityGcpAuthEvent { + type: EventType.UPDATE_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + type?: string; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityGcpAuthEvent { + type: EventType.GET_IDENTITY_GCP_AUTH; + metadata: { + identityId: string; + }; +} + +interface LoginIdentityAwsAuthEvent { + type: EventType.LOGIN_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + identityAwsAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAwsAuthEvent { + type: EventType.ADD_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAwsAuthEvent { + type: EventType.UPDATE_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAwsAuthEvent { + type: EventType.GET_IDENTITY_AWS_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -637,6 +811,7 @@ export type Event = | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent + | ManualSyncIntegrationEvent | AddTrustedIPEvent | UpdateTrustedIPEvent | DeleteTrustedIPEvent @@ -649,9 +824,21 @@ export type Event = | AddIdentityUniversalAuthEvent | UpdateIdentityUniversalAuthEvent | GetIdentityUniversalAuthEvent + | LoginIdentityKubernetesAuthEvent + | AddIdentityKubernetesAuthEvent + | UpdateIdentityKubernetesAuthEvent + | GetIdentityKubernetesAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | LoginIdentityGcpAuthEvent + | AddIdentityGcpAuthEvent + | UpdateIdentityGcpAuthEvent + | GetIdentityGcpAuthEvent + | LoginIdentityAwsAuthEvent + | AddIdentityAwsAuthEvent + | UpdateIdentityAwsAuthEvent + | GetIdentityAwsAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts new file mode 100644 index 000000000..810628030 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts @@ -0,0 +1,80 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { DynamicSecretLeasesSchema, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TDynamicSecretLeaseDALFactory = ReturnType; + +export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecretLease); + + const countLeasesForDynamicSecret = async (dynamicSecretId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.DynamicSecretLease).count("*").where({ dynamicSecretId }).first(); + return parseInt(doc || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "DynamicSecretCountLeases" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.DynamicSecretLease) + .where({ [`${TableName.DynamicSecretLease}.id` as "id"]: id }) + .first() + .join( + TableName.DynamicSecret, + `${TableName.DynamicSecretLease}.dynamicSecretId`, + `${TableName.DynamicSecret}.id` + ) + .select(selectAllTableCols(TableName.DynamicSecretLease)) + .select( + db.ref("id").withSchema(TableName.DynamicSecret).as("dynId"), + db.ref("name").withSchema(TableName.DynamicSecret).as("dynName"), + db.ref("version").withSchema(TableName.DynamicSecret).as("dynVersion"), + db.ref("type").withSchema(TableName.DynamicSecret).as("dynType"), + db.ref("defaultTTL").withSchema(TableName.DynamicSecret).as("dynDefaultTTL"), + db.ref("maxTTL").withSchema(TableName.DynamicSecret).as("dynMaxTTL"), + db.ref("inputIV").withSchema(TableName.DynamicSecret).as("dynInputIV"), + db.ref("inputTag").withSchema(TableName.DynamicSecret).as("dynInputTag"), + db.ref("inputCiphertext").withSchema(TableName.DynamicSecret).as("dynInputCiphertext"), + db.ref("algorithm").withSchema(TableName.DynamicSecret).as("dynAlgorithm"), + db.ref("keyEncoding").withSchema(TableName.DynamicSecret).as("dynKeyEncoding"), + db.ref("folderId").withSchema(TableName.DynamicSecret).as("dynFolderId"), + db.ref("status").withSchema(TableName.DynamicSecret).as("dynStatus"), + db.ref("statusDetails").withSchema(TableName.DynamicSecret).as("dynStatusDetails"), + db.ref("createdAt").withSchema(TableName.DynamicSecret).as("dynCreatedAt"), + db.ref("updatedAt").withSchema(TableName.DynamicSecret).as("dynUpdatedAt") + ); + if (!doc) return; + + return { + ...DynamicSecretLeasesSchema.parse(doc), + dynamicSecret: { + id: doc.dynId, + name: doc.dynName, + version: doc.dynVersion, + type: doc.dynType, + defaultTTL: doc.dynDefaultTTL, + maxTTL: doc.dynMaxTTL, + inputIV: doc.dynInputIV, + inputTag: doc.dynInputTag, + inputCiphertext: doc.dynInputCiphertext, + algorithm: doc.dynAlgorithm, + keyEncoding: doc.dynKeyEncoding, + folderId: doc.dynFolderId, + status: doc.dynStatus, + statusDetails: doc.dynStatusDetails, + createdAt: doc.dynCreatedAt, + updatedAt: doc.dynUpdatedAt + } + }; + } catch (error) { + throw new DatabaseError({ error, name: "DynamicSecretLeaseFindById" }); + } + }; + + return { ...orm, findById, countLeasesForDynamicSecret }; +}; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts new file mode 100644 index 000000000..9bdb1c24e --- /dev/null +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -0,0 +1,159 @@ +import { SecretKeyEncoding } from "@app/db/schemas"; +import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; +import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; + +type TDynamicSecretLeaseQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + dynamicSecretLeaseDAL: Pick; + dynamicSecretDAL: Pick; + dynamicSecretProviders: Record; +}; + +export type TDynamicSecretLeaseQueueServiceFactory = ReturnType; + +export const dynamicSecretLeaseQueueServiceFactory = ({ + queueService, + dynamicSecretDAL, + dynamicSecretProviders, + dynamicSecretLeaseDAL +}: TDynamicSecretLeaseQueueServiceFactoryDep) => { + const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretPruning, + { dynamicSecretCfgId }, + { + jobId: dynamicSecretCfgId, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const setLeaseRevocation = async (leaseId: string, expiry: number) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretRevocation, + { leaseId }, + { + jobId: leaseId, + backoff: { + type: "exponential", + delay: 3000 + }, + delay: expiry, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const unsetLeaseRevocation = async (leaseId: string) => { + await queueService.stopJobById(QueueName.DynamicSecretRevocation, leaseId); + }; + + queueService.start(QueueName.DynamicSecretRevocation, async (job) => { + try { + if (job.name === QueueJobs.DynamicSecretRevocation) { + const { leaseId } = job.data as { leaseId: string }; + logger.info("Dynamic secret lease revocation started: ", leaseId, job.id); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return; + } + + if (job.name === QueueJobs.DynamicSecretPruning) { + const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; + logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, job.id); + const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId); + if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" }); + if ((dynamicSecretCfg.status as DynamicSecretStatus) !== DynamicSecretStatus.Deleting) + throw new DisableRotationErrors({ message: "Document not deleted" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfgId }); + if (dynamicSecretLeases.length) { + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); + await Promise.all( + dynamicSecretLeases.map(({ externalEntityId }) => + selectedProvider.revoke(decryptedStoredInput, externalEntityId) + ) + ); + } + + await dynamicSecretDAL.deleteById(dynamicSecretCfgId); + } + logger.info("Finished dynamic secret job", job.id); + } catch (error) { + logger.error(error); + + if (job?.name === QueueJobs.DynamicSecretPruning) { + const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; + await dynamicSecretDAL.updateById(dynamicSecretCfgId, { + status: DynamicSecretStatus.FailedDeletion, + statusDetails: (error as Error)?.message?.slice(0, 255) + }); + } + + if (job?.name === QueueJobs.DynamicSecretRevocation) { + const { leaseId } = job.data as { leaseId: string }; + await dynamicSecretLeaseDAL.updateById(leaseId, { + status: DynamicSecretStatus.FailedDeletion, + statusDetails: (error as Error)?.message?.slice(0, 255) + }); + } + if (error instanceof DisableRotationErrors) { + if (job.id) { + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id); + } + } + // propogate to next part + throw error; + } + }); + + return { + pruneDynamicSecret, + setLeaseRevocation, + unsetLeaseRevocation + }; +}; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts new file mode 100644 index 000000000..1e5487d22 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -0,0 +1,343 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import ms from "ms"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; + +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue"; +import { + DynamicSecretLeaseStatus, + TCreateDynamicSecretLeaseDTO, + TDeleteDynamicSecretLeaseDTO, + TDetailsDynamicSecretLeaseDTO, + TListDynamicSecretLeasesDTO, + TRenewDynamicSecretLeaseDTO +} from "./dynamic-secret-lease-types"; + +type TDynamicSecretLeaseServiceFactoryDep = { + dynamicSecretLeaseDAL: TDynamicSecretLeaseDALFactory; + dynamicSecretDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: TDynamicSecretLeaseQueueServiceFactory; + licenseService: Pick; + folderDAL: Pick; + permissionService: Pick; + projectDAL: Pick; +}; + +export type TDynamicSecretLeaseServiceFactory = ReturnType; + +export const dynamicSecretLeaseServiceFactory = ({ + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL, + folderDAL, + permissionService, + dynamicSecretQueueService, + projectDAL, + licenseService +}: TDynamicSecretLeaseServiceFactoryDep) => { + const create = async ({ + environmentSlug, + path, + name, + projectSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + ttl + }: TCreateDynamicSecretLeaseDTO) => { + const appCfg = getConfig(); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to create lease due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const totalLeasesTaken = await dynamicSecretLeaseDAL.countLeasesForDynamicSecret(dynamicSecretCfg.id); + if (totalLeasesTaken >= appCfg.MAX_LEASE_LIMIT) + throw new BadRequestError({ message: `Max lease limit reached. Limit: ${appCfg.MAX_LEASE_LIMIT}` }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL; + const { maxTTL } = dynamicSecretCfg; + const expireAt = new Date(new Date().getTime() + ms(selectedTTL)); + if (maxTTL) { + const maxExpiryDate = new Date(new Date().getTime() + ms(maxTTL)); + if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max TTL" }); + } + + const { entityId, data } = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + const dynamicSecretLease = await dynamicSecretLeaseDAL.create({ + expireAt, + version: 1, + dynamicSecretId: dynamicSecretCfg.id, + externalEntityId: entityId + }); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); + return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; + }; + + const renewLease = async ({ + ttl, + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + path, + environmentSlug, + leaseId + }: TRenewDynamicSecretLeaseDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to renew lease due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL; + const { maxTTL } = dynamicSecretCfg; + const expireAt = new Date(dynamicSecretLease.expireAt.getTime() + ms(selectedTTL)); + if (maxTTL) { + const maxExpiryDate = new Date(dynamicSecretLease.createdAt.getTime() + ms(maxTTL)); + if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max ttl" }); + } + + const { entityId } = await selectedProvider.renew( + decryptedStoredInput, + dynamicSecretLease.externalEntityId, + expireAt.getTime() + ); + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); + const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + expireAt, + externalEntityId: entityId + }); + return updatedDynamicSecretLease; + }; + + const revokeLease = async ({ + leaseId, + environmentSlug, + path, + projectSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + isForced + }: TDeleteDynamicSecretLeaseDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = dynamicSecretLease.dynamicSecret; + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const revokeResponse = await selectedProvider + .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId) + .catch(async (err) => { + // only propogate this error if forced is false + if (!isForced) return { error: err as Error }; + }); + + if ((revokeResponse as { error?: Error })?.error) { + const { error } = revokeResponse as { error?: Error }; + logger.error(error?.message, "Failed to revoke lease"); + const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + status: DynamicSecretLeaseStatus.FailedDeletion, + statusDetails: error?.message?.slice(0, 255) + }); + return deletedDynamicSecretLease; + } + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return deletedDynamicSecretLease; + }; + + const listLeases = async ({ + path, + name, + actor, + actorId, + projectSlug, + actorOrgId, + environmentSlug, + actorAuthMethod + }: TListDynamicSecretLeasesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + return dynamicSecretLeases; + }; + + const getLeaseDetails = async ({ + projectSlug, + actorOrgId, + path, + environmentSlug, + actor, + actorId, + leaseId, + actorAuthMethod + }: TDetailsDynamicSecretLeaseDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + return dynamicSecretLease; + }; + + return { + create, + listLeases, + revokeLease, + renewLease, + getLeaseDetails + }; +}; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts new file mode 100644 index 000000000..bf182b349 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts @@ -0,0 +1,43 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum DynamicSecretLeaseStatus { + FailedDeletion = "Failed to delete" +} + +export type TCreateDynamicSecretLeaseDTO = { + name: string; + path: string; + environmentSlug: string; + ttl?: string; + projectSlug: string; +} & Omit; + +export type TDetailsDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; + +export type TListDynamicSecretLeasesDTO = { + name: string; + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; + +export type TDeleteDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environmentSlug: string; + projectSlug: string; + isForced?: boolean; +} & Omit; + +export type TRenewDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environmentSlug: string; + ttl?: string; + projectSlug: string; +} & Omit; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts new file mode 100644 index 000000000..0cc4aca2f --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TDynamicSecretDALFactory = ReturnType; + +export const dynamicSecretDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecret); + return orm; +}; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts new file mode 100644 index 000000000..1aef3cc86 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -0,0 +1,341 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; + +import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue"; +import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; +import { + DynamicSecretStatus, + TCreateDynamicSecretDTO, + TDeleteDynamicSecretDTO, + TDetailsDynamicSecretDTO, + TListDynamicSecretsDTO, + TUpdateDynamicSecretDTO +} from "./dynamic-secret-types"; +import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models"; + +type TDynamicSecretServiceFactoryDep = { + dynamicSecretDAL: TDynamicSecretDALFactory; + dynamicSecretLeaseDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: Pick< + TDynamicSecretLeaseQueueServiceFactory, + "pruneDynamicSecret" | "unsetLeaseRevocation" + >; + licenseService: Pick; + folderDAL: Pick; + projectDAL: Pick; + permissionService: Pick; +}; + +export type TDynamicSecretServiceFactory = ReturnType; + +export const dynamicSecretServiceFactory = ({ + dynamicSecretDAL, + dynamicSecretLeaseDAL, + licenseService, + folderDAL, + dynamicSecretProviders, + permissionService, + dynamicSecretQueueService, + projectDAL +}: TDynamicSecretServiceFactoryDep) => { + const create = async ({ + path, + actor, + name, + actorId, + maxTTL, + provider, + environmentSlug, + projectSlug, + actorOrgId, + defaultTTL, + actorAuthMethod + }: TCreateDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to create dynamic secret due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (existingDynamicSecret) + throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + + const selectedProvider = dynamicSecretProviders[provider.type]; + const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + + const isConnected = await selectedProvider.validateConnection(provider.inputs); + if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); + + const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs)); + const dynamicSecretCfg = await dynamicSecretDAL.create({ + type: provider.type, + version: 1, + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + folderId: folder.id, + name + }); + return dynamicSecretCfg; + }; + + const updateByName = async ({ + name, + maxTTL, + defaultTTL, + inputs, + environmentSlug, + projectSlug, + path, + actor, + actorId, + newName, + actorOrgId, + actorAuthMethod + }: TUpdateDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan?.dynamicSecret) { + throw new BadRequestError({ + message: "Failed to update dynamic secret due to plan restriction. Upgrade plan to create dynamic secret." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + if (newName) { + const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id }); + if (existingDynamicSecret) + throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + } + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; + const updatedInput = await selectedProvider.validateProviderInputs(newInput); + + const isConnected = await selectedProvider.validateConnection(newInput); + if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); + + const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(updatedInput)); + const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + name: newName ?? name, + status: null, + statusDetails: null + }); + + return updatedDynamicCfg; + }; + + const deleteByName = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + name, + path, + environmentSlug, + isForced + }: TDeleteDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + // when not forced we check with the external system to first remove the things + // we introduce a forced concept because consider the external lease got deleted by some other external like a human or another system + // this allows user to clean up it from infisical + if (isForced) { + // clear all queues for lease revocations + await Promise.all(leases.map(({ id: leaseId }) => dynamicSecretQueueService.unsetLeaseRevocation(leaseId))); + + const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id); + return deletedDynamicSecretCfg; + } + // if leases exist we should flag it as deleting and then remove leases in background + // then delete the main one + if (leases.length) { + const updatedDynamicSecretCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { + status: DynamicSecretStatus.Deleting + }); + await dynamicSecretQueueService.pruneDynamicSecret(updatedDynamicSecretCfg.id); + return updatedDynamicSecretCfg; + } + // if no leases just delete the config + const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id); + return deletedDynamicSecretCfg; + }; + + const getDetails = async ({ + name, + projectSlug, + path, + environmentSlug, + actorAuthMethod, + actorOrgId, + actorId, + actor + }: TDetailsDynamicSecretDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; + return { ...dynamicSecretCfg, inputs: providerInputs }; + }; + + const list = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectSlug, + path, + environmentSlug + }: TListDynamicSecretsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id }); + return dynamicSecretCfg; + }; + + return { + create, + updateByName, + deleteByName, + getDetails, + list + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts new file mode 100644 index 000000000..02f2cbb86 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { TProjectPermission } from "@app/lib/types"; + +import { DynamicSecretProviderSchema } from "./providers/models"; + +// various status for dynamic secret that happens in background +export enum DynamicSecretStatus { + Deleting = "Revocation in process", + FailedDeletion = "Failed to delete" +} + +type TProvider = z.infer; +export type TCreateDynamicSecretDTO = { + provider: TProvider; + defaultTTL: string; + maxTTL?: string | null; + path: string; + environmentSlug: string; + name: string; + projectSlug: string; +} & Omit; + +export type TUpdateDynamicSecretDTO = { + name: string; + newName?: string; + defaultTTL?: string; + maxTTL?: string | null; + path: string; + environmentSlug: string; + inputs?: TProvider["inputs"]; + projectSlug: string; +} & Omit; + +export type TDeleteDynamicSecretDTO = { + name: string; + path: string; + environmentSlug: string; + projectSlug: string; + isForced?: boolean; +} & Omit; + +export type TDetailsDynamicSecretDTO = { + name: string; + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; + +export type TListDynamicSecretsDTO = { + path: string; + environmentSlug: string; + projectSlug: string; +} & Omit; diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts new file mode 100644 index 000000000..3feafa534 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -0,0 +1,194 @@ +import { + AddUserToGroupCommand, + AttachUserPolicyCommand, + CreateAccessKeyCommand, + CreateUserCommand, + DeleteAccessKeyCommand, + DeleteUserCommand, + DeleteUserPolicyCommand, + DetachUserPolicyCommand, + GetUserCommand, + IAMClient, + ListAccessKeysCommand, + ListAttachedUserPoliciesCommand, + ListGroupsForUserCommand, + ListUserPoliciesCommand, + PutUserPolicyCommand, + RemoveUserFromGroupCommand +} from "@aws-sdk/client-iam"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const AwsIamProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretAwsIamSchema.parseAsync(inputs); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const client = new IAMClient({ + region: providerInputs.region, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client.send(new GetUserCommand({})).then(() => true); + return isConnected; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; + const createUserRes = await client.send( + new CreateUserCommand({ + Path: awsPath, + PermissionsBoundary: permissionBoundaryPolicyArn || undefined, + Tags: [{ Key: "createdBy", Value: "infisical-dynamic-secret" }], + UserName: username + }) + ); + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); + if (userGroups) { + await Promise.all( + userGroups + .split(",") + .filter(Boolean) + .map((group) => + client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) + ) + ); + } + if (policyArns) { + await Promise.all( + policyArns + .split(",") + .filter(Boolean) + .map((policyArn) => + client.send(new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn })) + ) + ); + } + if (policyDocument) { + await client.send( + new PutUserPolicyCommand({ + UserName: createUserRes.User.UserName, + PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, + PolicyDocument: policyDocument + }) + ); + } + + const createAccessKeyRes = await client.send( + new CreateAccessKeyCommand({ + UserName: createUserRes.User.UserName + }) + ); + if (!createAccessKeyRes.AccessKey) + throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); + + return { + entityId: username, + data: { + ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, + SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, + USERNAME: username + } + }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + + // remove user from groups + const userGroups = await client.send(new ListGroupsForUserCommand({ UserName: username })); + await Promise.all( + (userGroups.Groups || []).map(({ GroupName }) => + client.send( + new RemoveUserFromGroupCommand({ + GroupName, + UserName: username + }) + ) + ) + ); + + // remove user access keys + const userAccessKeys = await client.send(new ListAccessKeysCommand({ UserName: username })); + await Promise.all( + (userAccessKeys.AccessKeyMetadata || []).map(({ AccessKeyId }) => + client.send( + new DeleteAccessKeyCommand({ + AccessKeyId, + UserName: username + }) + ) + ) + ); + + // remove user inline policies + const userInlinePolicies = await client.send(new ListUserPoliciesCommand({ UserName: username })); + await Promise.all( + (userInlinePolicies.PolicyNames || []).map((policyName) => + client.send( + new DeleteUserPolicyCommand({ + PolicyName: policyName, + UserName: username + }) + ) + ) + ); + + // remove user attached policies + const userAttachedPolicies = await client.send(new ListAttachedUserPoliciesCommand({ UserName: username })); + await Promise.all( + (userAttachedPolicies.AttachedPolicies || []).map((policy) => + client.send( + new DetachUserPolicyCommand({ + PolicyArn: policy.PolicyArn, + UserName: username + }) + ) + ) + ); + + await client.send(new DeleteUserCommand({ UserName: username })); + return { entityId: username }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // do nothing + const username = entityId; + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts new file mode 100644 index 000000000..aea0b9c99 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -0,0 +1,125 @@ +import cassandra from "cassandra-driver"; +import handlebars from "handlebars"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const CassandraProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") { + throw new BadRequestError({ message: "Invalid db host" }); + } + + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const client = new cassandra.Client({ + sslOptions, + protocolOptions: { + port: providerInputs.port + }, + credentials: { + username: providerInputs.username, + password: providerInputs.password + }, + keyspace: providerInputs.keyspace, + localDataCenter: providerInputs?.localDataCenter, + contactPoints: providerInputs.host.split(",").filter(Boolean) + }); + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client.execute("SELECT * FROM system_schema.keyspaces").then(() => true); + await client.shutdown(); + return isConnected; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const password = generatePassword(); + const { keyspace } = providerInputs; + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + keyspace + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + const { keyspace } = providerInputs; + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + const { keyspace } = providerInputs; + + const renewStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace, expiration }); + const queries = renewStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts new file mode 100644 index 000000000..beb6c428e --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -0,0 +1,10 @@ +import { AwsIamProvider } from "./aws-iam"; +import { CassandraProvider } from "./cassandra"; +import { DynamicSecretProviders } from "./models"; +import { SqlDatabaseProvider } from "./sql-database"; + +export const buildDynamicSecretProviders = () => ({ + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), + [DynamicSecretProviders.Cassandra]: CassandraProvider(), + [DynamicSecretProviders.AwsIam]: AwsIamProvider() +}); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts new file mode 100644 index 000000000..c11f6ddfb --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +export enum SqlProviders { + Postgres = "postgres", + MySQL = "mysql2", + Oracle = "oracledb" +} + +export const DynamicSecretSqlDBSchema = z.object({ + client: z.nativeEnum(SqlProviders), + host: z.string().trim().toLowerCase(), + port: z.number(), + database: z.string().trim(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional() +}); + +export const DynamicSecretCassandraSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + localDataCenter: z.string().trim().min(1), + keyspace: z.string().trim().optional(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional() +}); + +export const DynamicSecretAwsIamSchema = z.object({ + accessKey: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() +}); + +export enum DynamicSecretProviders { + SqlDatabase = "sql-database", + Cassandra = "cassandra", + AwsIam = "aws-iam" +} + +export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }) +]); + +export type TDynamicProviderFns = { + create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; + validateConnection: (inputs: unknown) => Promise; + validateProviderInputs: (inputs: object) => Promise; + revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; + renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts new file mode 100644 index 000000000..6745f573b --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -0,0 +1,162 @@ +import handlebars from "handlebars"; +import knex from "knex"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { getDbConnectionHost } from "@app/lib/knex"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const generatePassword = (provider: SqlProviders) => { + // oracle has limit of 48 password length + const size = provider === SqlProviders.Oracle ? 30 : 48; + + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = (provider: SqlProviders) => { + // For oracle, the client assumes everything is upper case when not using quotes around the password + if (provider === SqlProviders.Oracle) return alphaNumericNanoId(32).toUpperCase(); + + return alphaNumericNanoId(32); +}; + +export const SqlDatabaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const appCfg = getConfig(); + const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not + const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); + if ( + isCloud && + // localhost + // internal ips + (providerInputs.host === "host.docker.internal" || + providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || + providerInputs.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Invalid db host" }); + if ( + providerInputs.host === "localhost" || + providerInputs.host === "127.0.0.1" || + // database infisical uses + dbHost === providerInputs.host + ) + throw new BadRequestError({ message: "Invalid db host" }); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const db = knex({ + client: providerInputs.client, + connection: { + database: providerInputs.database, + port: providerInputs.port, + host: providerInputs.host, + user: providerInputs.username, + password: providerInputs.password, + ssl, + pool: { min: 0, max: 1 } + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT + }); + return db; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + // oracle needs from keyword + const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; + + const isConnected = await db.raw(testStatement).then(() => true); + await db.destroy(); + return isConnected; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = generateUsername(providerInputs.client); + const password = generatePassword(providerInputs.client); + const { database } = providerInputs; + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + await db.destroy(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = entityId; + const { database } = providerInputs; + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + + await db.destroy(); + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + const { database } = providerInputs; + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration, database }); + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } + + await db.destroy(); + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts new file mode 100644 index 000000000..3da1f242c --- /dev/null +++ b/backend/src/ee/services/group/group-dal.ts @@ -0,0 +1,130 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TGroups } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; + +export type TGroupDALFactory = ReturnType; + +export const groupDALFactory = (db: TDbClient) => { + const groupOrm = ormify(db, TableName.Groups); + + const findGroups = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + try { + const query = (tx || db)(TableName.Groups) + // eslint-disable-next-line + .where(buildFindFilter(filter)) + .select(selectAllTableCols(TableName.Groups)); + + if (limit) void query.limit(limit); + if (offset) void query.limit(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const res = await query; + return res; + } catch (err) { + throw new DatabaseError({ error: err, name: "Find groups" }); + } + }; + + const findByOrgId = async (orgId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.Groups) + .where(`${TableName.Groups}.orgId`, orgId) + .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) + .select(selectAllTableCols(TableName.Groups)) + // cr stands for custom role + .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) + .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) + .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)); + return docs.map(({ crId, crDescription, crSlug, crPermission, crName, ...el }) => ({ + ...el, + customRole: el.roleId + ? { + id: crId, + name: crName, + slug: crSlug, + permissions: crPermission, + description: crDescription + } + : undefined + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindByOrgId" }); + } + }; + + // special query + const findAllGroupMembers = async ({ + orgId, + groupId, + offset = 0, + limit, + username + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + username?: string; + }) => { + try { + let query = db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserGroupMembership, function () { + this.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId") + ) + .where({ isGhost: false }) + .offset(offset); + + if (limit) { + query = query.limit(limit); + } + + if (username) { + query = query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); + } + + const members = await query; + + return members.map( + ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + id: userId, + email, + username: memberUsername, + firstName, + lastName, + isPartOfGroup: !!memberGroupId + }) + ); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + + return { + findGroups, + findByOrgId, + findAllGroupMembers, + ...groupOrm + }; +}; diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts new file mode 100644 index 000000000..4f96ddbf0 --- /dev/null +++ b/backend/src/ee/services/group/group-fns.ts @@ -0,0 +1,450 @@ +import { Knex } from "knex"; + +import { SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; +import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError, ScimRequestError } from "@app/lib/errors"; + +import { + TAddUsersToGroup, + TAddUsersToGroupByUserIds, + TConvertPendingGroupAdditionsToGroupMemberships, + TRemoveUsersFromGroupByUserIds +} from "./group-types"; + +const addAcceptedUsersToGroup = async ({ + userIds, + group, + userGroupMembershipDAL, + userDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx +}: TAddUsersToGroup) => { + const users = await userDAL.findUserEncKeyByUserIdsBatch( + { + userIds + }, + tx + ); + + await userGroupMembershipDAL.insertMany( + users.map((user) => ({ + userId: user.userId, + groupId: group.id, + isPending: false + })), + tx + ); + + // check which projects the group is part of + const projectIds = Array.from( + new Set( + ( + await groupProjectDAL.find( + { + groupId: group.id + }, + { tx } + ) + ).map((gp) => gp.projectId) + ) + ); + + const keys = await projectKeyDAL.find( + { + $in: { + projectId: projectIds, + receiverId: users.map((u) => u.id) + } + }, + { tx } + ); + + const userKeysSet = new Set(keys.map((k) => `${k.projectId}-${k.receiverId}`)); + + for await (const projectId of projectIds) { + const usersToAddProjectKeyFor = users.filter((u) => !userKeysSet.has(`${projectId}-${u.userId}`)); + + if (usersToAddProjectKeyFor.length) { + // there are users who need to be shared keys + // process adding bulk users to projects for each project individually + const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId, tx); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }, tx); + + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const plaintextProjectKey = decryptAsymmetric({ + ciphertext: ghostUserLatestKey.encryptedKey, + nonce: ghostUserLatestKey.nonce, + publicKey: ghostUserLatestKey.sender.publicKey, + privateKey: botPrivateKey + }); + + const projectKeysToAdd = usersToAddProjectKeyFor.map((user) => { + const { ciphertext: encryptedKey, nonce } = encryptAsymmetric( + plaintextProjectKey, + user.publicKey, + botPrivateKey + ); + return { + encryptedKey, + nonce, + senderId: ghostUser.id, + receiverId: user.userId, + projectId + }; + }); + + await projectKeyDAL.insertMany(projectKeysToAdd, tx); + } + } +}; + +/** + * Add users with user ids [userIds] to group [group]. + * - Users may or may not have finished completing their accounts; this function will + * handle both adding users to groups directly and via pending group additions. + * @param {group} group - group to add user(s) to + * @param {string[]} userIds - id(s) of user(s) to add to group + */ +export const addUsersToGroupByUserIds = async ({ + group, + userIds, + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx: outerTx +}: TAddUsersToGroupByUserIds) => { + const processAddition = async (tx: Knex) => { + const foundMembers = await userDAL.find( + { + $in: { + id: userIds + } + }, + { tx } + ); + + const foundMembersIdsSet = new Set(foundMembers.map((member) => member.id)); + + const isCompleteMatch = userIds.every((userId) => foundMembersIdsSet.has(userId)); + + if (!isCompleteMatch) { + throw new ScimRequestError({ + detail: "Members not found", + status: 404 + }); + } + + // check if user(s) group membership(s) already exists + const existingUserGroupMemberships = await userGroupMembershipDAL.find( + { + groupId: group.id, + $in: { + userId: userIds + } + }, + { tx } + ); + + if (existingUserGroupMemberships.length) { + throw new BadRequestError({ + message: `User(s) are already part of the group ${group.slug}` + }); + } + + // check if all user(s) are part of the organization + const existingUserOrgMemberships = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: group.orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: userIds + } + }, + { tx } + ); + + const existingUserOrgMembershipsUserIdsSet = new Set(existingUserOrgMemberships.map((u) => u.userId)); + + userIds.forEach((userId) => { + if (!existingUserOrgMembershipsUserIdsSet.has(userId)) + throw new BadRequestError({ + message: `User with id ${userId} is not part of the organization` + }); + }); + + const membersToAddToGroupNonPending: TUsers[] = []; + const membersToAddToGroupPending: TUsers[] = []; + + foundMembers.forEach((member) => { + if (member.isAccepted) { + // add accepted member to group + membersToAddToGroupNonPending.push(member); + } else { + // add incomplete member to pending group addition + membersToAddToGroupPending.push(member); + } + }); + + if (membersToAddToGroupNonPending.length) { + await addAcceptedUsersToGroup({ + userIds: membersToAddToGroupNonPending.map((member) => member.id), + group, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + } + + if (membersToAddToGroupPending.length) { + await userGroupMembershipDAL.insertMany( + membersToAddToGroupPending.map((member) => ({ + userId: member.id, + groupId: group.id, + isPending: true + })), + tx + ); + } + + return membersToAddToGroupNonPending.concat(membersToAddToGroupPending); + }; + + if (outerTx) { + return processAddition(outerTx); + } + return userDAL.transaction(async (tx) => { + return processAddition(tx); + }); +}; + +/** + * Remove users with user ids [userIds] from group [group]. + * - Users may be part of the group (non-pending + pending); + * this function will handle both cases. + * @param {group} group - group to remove user(s) from + * @param {string[]} userIds - id(s) of user(s) to remove from group + */ +export const removeUsersFromGroupByUserIds = async ({ + group, + userIds, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx: outerTx +}: TRemoveUsersFromGroupByUserIds) => { + const processRemoval = async (tx: Knex) => { + const foundMembers = await userDAL.find({ + $in: { + id: userIds + } + }); + + const foundMembersIdsSet = new Set(foundMembers.map((member) => member.id)); + + const isCompleteMatch = userIds.every((userId) => foundMembersIdsSet.has(userId)); + + if (!isCompleteMatch) { + throw new ScimRequestError({ + detail: "Members not found", + status: 404 + }); + } + + // check if user group membership already exists + const existingUserGroupMemberships = await userGroupMembershipDAL.find( + { + groupId: group.id, + $in: { + userId: userIds + } + }, + { tx } + ); + + const existingUserGroupMembershipsUserIdsSet = new Set(existingUserGroupMemberships.map((u) => u.userId)); + + userIds.forEach((userId) => { + if (!existingUserGroupMembershipsUserIdsSet.has(userId)) + throw new BadRequestError({ + message: `User(s) are not part of the group ${group.slug}` + }); + }); + + const membersToRemoveFromGroupNonPending: TUsers[] = []; + const membersToRemoveFromGroupPending: TUsers[] = []; + + foundMembers.forEach((member) => { + if (member.isAccepted) { + // remove accepted member from group + membersToRemoveFromGroupNonPending.push(member); + } else { + // remove incomplete member from pending group addition + membersToRemoveFromGroupPending.push(member); + } + }); + + if (membersToRemoveFromGroupNonPending.length) { + // check which projects the group is part of + const projectIds = Array.from( + new Set( + ( + await groupProjectDAL.find( + { + groupId: group.id + }, + { tx } + ) + ).map((gp) => gp.projectId) + ) + ); + + // TODO: this part can be optimized + for await (const userId of userIds) { + const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx); + const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p)); + + if (projectsToDeleteKeyFor.length) { + await projectKeyDAL.delete( + { + receiverId: userId, + $in: { + projectId: projectsToDeleteKeyFor + } + }, + tx + ); + } + + await userGroupMembershipDAL.delete( + { + groupId: group.id, + userId + }, + tx + ); + } + } + + if (membersToRemoveFromGroupPending.length) { + await userGroupMembershipDAL.delete({ + groupId: group.id, + $in: { + userId: membersToRemoveFromGroupPending.map((member) => member.id) + } + }); + } + + return membersToRemoveFromGroupNonPending.concat(membersToRemoveFromGroupPending); + }; + + if (outerTx) { + return processRemoval(outerTx); + } + return userDAL.transaction(async (tx) => { + return processRemoval(tx); + }); +}; + +/** + * Convert pending group additions for users with ids [userIds] to group memberships. + * @param {string[]} userIds - id(s) of user(s) to try to convert pending group additions to group memberships + */ +export const convertPendingGroupAdditionsToGroupMemberships = async ({ + userIds, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx: outerTx +}: TConvertPendingGroupAdditionsToGroupMemberships) => { + const processConversion = async (tx: Knex) => { + const users = await userDAL.find( + { + $in: { + id: userIds + } + }, + { tx } + ); + + const usersUserIdsSet = new Set(users.map((u) => u.id)); + userIds.forEach((userId) => { + if (!usersUserIdsSet.has(userId)) { + throw new BadRequestError({ + message: `Failed to find user with id ${userId}` + }); + } + }); + + users.forEach((user) => { + if (!user.isAccepted) { + throw new BadRequestError({ + message: `Failed to convert pending group additions to group memberships for user ${user.username} because they have not confirmed their account` + }); + } + }); + + const pendingGroupAdditions = await userGroupMembershipDAL.deletePendingUserGroupMembershipsByUserIds(userIds, tx); + + for await (const pendingGroupAddition of pendingGroupAdditions) { + await addAcceptedUsersToGroup({ + userIds: [pendingGroupAddition.user.id], + group: pendingGroupAddition.group, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + } + }; + + if (outerTx) { + return processConversion(outerTx); + } + return userDAL.transaction(async (tx) => { + await processConversion(tx); + }); +}; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts new file mode 100644 index 000000000..e6a151bf7 --- /dev/null +++ b/backend/src/ee/services/group/group-service.ts @@ -0,0 +1,347 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; + +import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TGroupDALFactory } from "./group-dal"; +import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns"; +import { + TAddUserToGroupDTO, + TCreateGroupDTO, + TDeleteGroupDTO, + TListGroupUsersDTO, + TRemoveUserFromGroupDTO, + TUpdateGroupDTO +} from "./group-types"; +import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; + +type TGroupServiceFactoryDep = { + userDAL: Pick; + groupDAL: Pick; + groupProjectDAL: Pick; + orgDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "findOne" | "delete" | "filterProjectsByUserMembership" | "transaction" | "insertMany" | "find" + >; + projectDAL: Pick; + projectBotDAL: Pick; + projectKeyDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TGroupServiceFactory = ReturnType; + +export const groupServiceFactory = ({ + userDAL, + groupDAL, + groupProjectDAL, + orgDAL, + userGroupMembershipDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, + permissionService, + licenseService +}: TGroupServiceFactoryDep) => { + const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to create group due to plan restriction. Upgrade plan to create group." + }); + + const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( + role, + actorOrgId + ); + const isCustomRole = Boolean(customRole); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredPriviledges) throw new BadRequestError({ message: "Failed to create a more privileged group" }); + + const group = await groupDAL.create({ + name, + slug: slug || slugify(`${name}-${alphaNumericNanoId(4)}`), + orgId: actorOrgId, + role: isCustomRole ? OrgMembershipRole.Custom : role, + roleId: customRole?.id + }); + + return group; + }; + + const updateGroup = async ({ + currentSlug, + name, + slug, + role, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to update group due to plan restrictio Upgrade plan to update group." + }); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: currentSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${currentSlug}` }); + + let customRole: TOrgRoles | undefined; + if (role) { + const { permission: rolePermission, role: customOrgRole } = await permissionService.getOrgPermissionByRole( + role, + group.orgId + ); + + const isCustomRole = Boolean(customOrgRole); + const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredNewRolePermission) + throw new BadRequestError({ message: "Failed to create a more privileged group" }); + if (isCustomRole) customRole = customOrgRole; + } + + const [updatedGroup] = await groupDAL.update( + { + orgId: actorOrgId, + slug: currentSlug + }, + { + name, + slug: slug ? slugify(slug) : undefined, + ...(role + ? { + role: customRole ? OrgMembershipRole.Custom : role, + roleId: customRole?.id ?? null + } + : {}) + } + ); + + return updatedGroup; + }; + + const deleteGroup = async ({ groupSlug, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); + + const plan = await licenseService.getPlan(actorOrgId); + + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to delete group due to plan restriction. Upgrade plan to delete group." + }); + + const [group] = await groupDAL.delete({ + orgId: actorOrgId, + slug: groupSlug + }); + + return group; + }; + + const listGroupUsers = async ({ + groupSlug, + offset, + limit, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListGroupUsersDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const users = await groupDAL.findAllGroupMembers({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + username + }); + + const count = await orgDAL.countAllOrgMembers(group.orgId); + + return { users, totalCount: count }; + }; + + const addUserToGroup = async ({ + groupSlug, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TAddUserToGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId: actorOrgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); + + // check if user has broader or equal to privileges than group + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); + + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ message: `Failed to find user with username ${username}` }); + + const users = await addUsersToGroupByUserIds({ + group, + userIds: [user.id], + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL + }); + + return users[0]; + }; + + const removeUserFromGroup = async ({ + groupSlug, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRemoveUserFromGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId: actorOrgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); + + // check if user has broader or equal to privileges than group + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); + + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ message: `Failed to find user with username ${username}` }); + + const users = await removeUsersFromGroupByUserIds({ + group, + userIds: [user.id], + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL + }); + + return users[0]; + }; + + return { + createGroup, + updateGroup, + deleteGroup, + listGroupUsers, + addUserToGroup, + removeUserFromGroup + }; +}; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts new file mode 100644 index 000000000..ca9831ffb --- /dev/null +++ b/backend/src/ee/services/group/group-types.ts @@ -0,0 +1,98 @@ +import { Knex } from "knex"; + +import { TGroups } from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { TGenericPermission } from "@app/lib/types"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +export type TCreateGroupDTO = { + name: string; + slug?: string; + role: string; +} & TGenericPermission; + +export type TUpdateGroupDTO = { + currentSlug: string; +} & Partial<{ + name: string; + slug: string; + role: string; +}> & + TGenericPermission; + +export type TDeleteGroupDTO = { + groupSlug: string; +} & TGenericPermission; + +export type TListGroupUsersDTO = { + groupSlug: string; + offset: number; + limit: number; + username?: string; +} & TGenericPermission; + +export type TAddUserToGroupDTO = { + groupSlug: string; + username: string; +} & TGenericPermission; + +export type TRemoveUserFromGroupDTO = { + groupSlug: string; + username: string; +} & TGenericPermission; + +// group fns types + +export type TAddUsersToGroup = { + userIds: string[]; + group: TGroups; + userDAL: Pick; + userGroupMembershipDAL: Pick; + groupProjectDAL: Pick; + projectKeyDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + tx: Knex; +}; + +export type TAddUsersToGroupByUserIds = { + group: TGroups; + userIds: string[]; + userDAL: Pick; + userGroupMembershipDAL: Pick; + orgDAL: Pick; + groupProjectDAL: Pick; + projectKeyDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + tx?: Knex; +}; + +export type TRemoveUsersFromGroupByUserIds = { + group: TGroups; + userIds: string[]; + userDAL: Pick; + userGroupMembershipDAL: Pick; + groupProjectDAL: Pick; + projectKeyDAL: Pick; + tx?: Knex; +}; + +export type TConvertPendingGroupAdditionsToGroupMemberships = { + userIds: string[]; + userDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds" + >; + groupProjectDAL: Pick; + projectKeyDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + tx?: Knex; +}; diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts new file mode 100644 index 000000000..1ab1839c5 --- /dev/null +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -0,0 +1,171 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TUserGroupMembershipDALFactory = ReturnType; + +export const userGroupMembershipDALFactory = (db: TDbClient) => { + const userGroupMembershipOrm = ormify(db, TableName.UserGroupMembership); + + /** + * Returns a sub-set of projectIds fed into this function corresponding to projects where either: + * - The user is a direct member of the project. + * - The user is a member of a group that is a member of the project, excluding projects that they are part of + * through the group with id [groupId]. + */ + const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[], tx?: Knex) => { + try { + const userProjectMemberships: string[] = await (tx || db)(TableName.ProjectMembership) + .where(`${TableName.ProjectMembership}.userId`, userId) + .whereIn(`${TableName.ProjectMembership}.projectId`, projectIds) + .pluck(`${TableName.ProjectMembership}.projectId`); + + const userGroupMemberships: string[] = await (tx || db)(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .whereNot(`${TableName.UserGroupMembership}.groupId`, groupId) + .join( + TableName.GroupProjectMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.GroupProjectMembership}.groupId` + ) + .whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds) + .pluck(`${TableName.GroupProjectMembership}.projectId`); + + return new Set(userProjectMemberships.concat(userGroupMemberships)); + } catch (error) { + throw new DatabaseError({ error, name: "Filter projects by user membership" }); + } + }; + + // special query + const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string) => { + try { + const usernameDocs: string[] = await db(TableName.UserGroupMembership) + .join( + TableName.GroupProjectMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.GroupProjectMembership}.groupId` + ) + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .whereIn(`${TableName.Users}.username`, usernames) + .pluck(`${TableName.Users}.id`); + + return usernameDocs; + } catch (error) { + throw new DatabaseError({ error, name: "Find user group members in project" }); + } + }; + + /** + * Return list of completed/accepted users that are part of the group with id [groupId] + * that have not yet been added individually to project with id [projectId]. + * + * Note: Filters out users that are part of other groups in the project. + * @param groupId + * @param projectId + * @returns + */ + const findGroupMembersNotInProject = async (groupId: string, projectId: string, tx?: Knex) => { + try { + // get list of groups in the project with id [projectId] + // that that are not the group with id [groupId] + const groups: string[] = await (tx || db)(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .whereNot(`${TableName.GroupProjectMembership}.groupId`, groupId) + .pluck(`${TableName.GroupProjectMembership}.groupId`); + + // main query + const members = await (tx || db)(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.groupId`, groupId) + .where(`${TableName.UserGroupMembership}.isPending`, false) + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.ProjectMembership, function () { + this.on(`${TableName.Users}.id`, "=", `${TableName.ProjectMembership}.userId`).andOn( + `${TableName.ProjectMembership}.projectId`, + "=", + db.raw("?", [projectId]) + ); + }) + .whereNull(`${TableName.ProjectMembership}.userId`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.UserGroupMembership), + db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER + .whereNotIn(`${TableName.UserGroupMembership}.userId`, function () { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.select(`${TableName.UserGroupMembership}.userId`) + .from(TableName.UserGroupMembership) + .whereIn(`${TableName.UserGroupMembership}.groupId`, groups); + }); + + return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, username, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find group members not in project" }); + } + }; + + const deletePendingUserGroupMembershipsByUserIds = async (userIds: string[], tx?: Knex) => { + try { + const members = await (tx || db)(TableName.UserGroupMembership) + .whereIn(`${TableName.UserGroupMembership}.userId`, userIds) + .where(`${TableName.UserGroupMembership}.isPending`, true) + .join(TableName.Groups, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`); + + await userGroupMembershipOrm.delete( + { + $in: { + userId: userIds + } + }, + tx + ); + + return members.map(({ userId, username, groupId, orgId, name, slug, role, roleId }) => ({ + user: { + id: userId, + username + }, + group: { + id: groupId, + orgId, + name, + slug, + role, + roleId, + createdAt: new Date(), + updatedAt: new Date() + } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Delete pending user group memberships by user ids" }); + } + }; + + return { + ...userGroupMembershipOrm, + filterProjectsByUserMembership, + findUserGroupMembershipsInProject, + findGroupMembersNotInProject, + deletePendingUserGroupMembershipsByUserIds + }; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts new file mode 100644 index 000000000..26252f2d1 --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts @@ -0,0 +1,12 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityProjectAdditionalPrivilegeDALFactory = ReturnType< + typeof identityProjectAdditionalPrivilegeDALFactory +>; + +export const identityProjectAdditionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege); + return orm; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts new file mode 100644 index 000000000..70753ee09 --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -0,0 +1,345 @@ +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, unpackRules } from "@casl/ability/extra"; +import ms from "ms"; +import { z } from "zod"; + +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; +import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; +import { + IdentityProjectAdditionalPrivilegeTemporaryMode, + TCreateIdentityPrivilegeDTO, + TDeleteIdentityPrivilegeDTO, + TGetIdentityPrivilegeDetailsDTO, + TListIdentityPrivilegesDTO, + TUpdateIdentityPrivilegeDTO +} from "./identity-project-additional-privilege-types"; + +type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = { + identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory; + identityProjectDAL: Pick; + projectDAL: Pick; + permissionService: Pick; +}; + +export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< + typeof identityProjectAdditionalPrivilegeServiceFactory +>; + +// TODO(akhilmhdh): move this to more centralized +export const UnpackedPermissionSchema = z.object({ + subject: z.union([z.string().min(1), z.string().array()]).optional(), + action: z.union([z.string().min(1), z.string().array()]), + conditions: z + .object({ + environment: z.string().optional(), + secretPath: z + .object({ + $glob: z.string().min(1) + }) + .optional() + }) + .optional() +}); + +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + +export const identityProjectAdditionalPrivilegeServiceFactory = ({ + identityProjectAdditionalPrivilegeDAL, + identityProjectDAL, + permissionService, + projectDAL +}: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => { + const create = async ({ + slug, + actor, + actorId, + identityId, + projectSlug, + permissions: customPermission, + actorOrgId, + actorAuthMethod, + ...dto + }: TCreateIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + + const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + + if (!dto.isTemporary) { + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ + projectMembershipId: identityProjectMembership.id, + slug, + permissions: customPermission + }); + return { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + }; + } + + const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ + projectMembershipId: identityProjectMembership.id, + slug, + permissions: customPermission, + isTemporary: true, + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: dto.temporaryRange, + temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) + }); + return { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + }; + }; + + const updateBySlug = async ({ + projectSlug, + slug, + identityId, + data, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TUpdateIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + if (data?.slug) { + const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug: data.slug, + projectMembershipId: identityProjectMembership.id + }); + if (existingSlug && existingSlug.id !== identityPrivilege.id) + throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + } + + const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; + if (isTemporary) { + const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; + const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange; + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { + ...data, + temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), + temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) + }); + return { + ...additionalPrivilege, + + permissions: unpackPermissions(additionalPrivilege.permissions) + }; + } + + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { + ...data, + isTemporary: false, + temporaryAccessStartTime: null, + temporaryAccessEndTime: null, + temporaryRange: null, + temporaryMode: null + }); + return { + ...additionalPrivilege, + + permissions: unpackPermissions(additionalPrivilege.permissions) + }; + }; + + const deleteBySlug = async ({ + actorId, + slug, + identityId, + projectSlug, + actor, + actorOrgId, + actorAuthMethod + }: TDeleteIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" }); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); + return { + ...deletedPrivilege, + + permissions: unpackPermissions(deletedPrivilege.permissions) + }; + }; + + const getPrivilegeDetailsBySlug = async ({ + projectSlug, + identityId, + slug, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TGetIdentityPrivilegeDetailsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + return { + ...identityPrivilege, + permissions: unpackPermissions(identityPrivilege.permissions) + }; + }; + + const listIdentityProjectPrivileges = async ({ + identityId, + actorOrgId, + actor, + actorId, + actorAuthMethod, + projectSlug + }: TListIdentityPrivilegesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + + const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ + projectMembershipId: identityProjectMembership.id + }); + return identityPrivileges.map((el) => ({ + ...el, + + permissions: unpackPermissions(el.permissions) + })); + }; + + return { + create, + updateBySlug, + deleteBySlug, + getPrivilegeDetailsBySlug, + listIdentityProjectPrivileges + }; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts new file mode 100644 index 000000000..88ff01d7d --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts @@ -0,0 +1,54 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum IdentityProjectAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TCreateIdentityPrivilegeDTO = { + permissions: unknown; + identityId: string; + projectSlug: string; + slug: string; +} & ( + | { + isTemporary: false; + } + | { + isTemporary: true; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } +) & + Omit; + +export type TUpdateIdentityPrivilegeDTO = { slug: string; identityId: string; projectSlug: string } & Omit< + TProjectPermission, + "projectId" +> & { + data: Partial<{ + permissions: unknown; + slug: string; + isTemporary: boolean; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + }; + +export type TDeleteIdentityPrivilegeDTO = Omit & { + slug: string; + identityId: string; + projectSlug: string; +}; + +export type TGetIdentityPrivilegeDetailsDTO = Omit & { + slug: string; + identityId: string; + projectSlug: string; +}; + +export type TListIdentityPrivilegesDTO = Omit & { + identityId: string; + projectSlug: string; +}; diff --git a/backend/src/ee/services/ldap-config/ldap-config-dal.ts b/backend/src/ee/services/ldap-config/ldap-config-dal.ts new file mode 100644 index 000000000..d05747c36 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TLdapConfigDALFactory = ReturnType; + +export const ldapConfigDALFactory = (db: TDbClient) => { + const ldapCfgOrm = ormify(db, TableName.LdapConfig); + + return { ...ldapCfgOrm }; +}; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts new file mode 100644 index 000000000..6773c9486 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -0,0 +1,747 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { + OrgMembershipRole, + OrgMembershipStatus, + SecretKeyEncoding, + TableName, + TLdapConfigsUpdate, + TUsers +} from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { getConfig } from "@app/lib/config/env"; +import { + decryptSymmetric, + encryptSymmetric, + generateAsymmetricKeyPair, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TLdapConfigDALFactory } from "./ldap-config-dal"; +import { + TCreateLdapCfgDTO, + TCreateLdapGroupMapDTO, + TDeleteLdapGroupMapDTO, + TGetLdapCfgDTO, + TGetLdapGroupMapsDTO, + TLdapLoginDTO, + TTestLdapConnectionDTO, + TUpdateLdapCfgDTO +} from "./ldap-config-types"; +import { testLDAPConfig } from "./ldap-fns"; +import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; + +type TLdapConfigServiceFactoryDep = { + ldapConfigDAL: Pick; + ldapGroupMapDAL: Pick; + orgMembershipDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgBotDAL: Pick; + groupDAL: Pick; + groupProjectDAL: Pick; + projectKeyDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete" + >; + userDAL: Pick< + TUserDALFactory, + "create" | "findOne" | "transaction" | "updateById" | "findUserEncKeyByUserIdsBatch" | "find" + >; + userAliasDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TLdapConfigServiceFactory = ReturnType; + +export const ldapConfigServiceFactory = ({ + ldapConfigDAL, + ldapGroupMapDAL, + orgDAL, + orgMembershipDAL, + orgBotDAL, + groupDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + userGroupMembershipDAL, + userDAL, + userAliasDAL, + permissionService, + licenseService +}: TLdapConfigServiceFactoryDep) => { + const createLdapCfg = async ({ + actor, + actorId, + orgId, + actorOrgId, + actorAuthMethod, + isActive, + url, + bindDN, + bindPass, + searchBase, + searchFilter, + groupSearchBase, + groupSearchFilter, + caCert + }: TCreateLdapCfgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: + "Failed to create LDAP configuration due to plan restriction. Upgrade plan to create LDAP configuration." + }); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedBindDN, iv: bindDNIV, tag: bindDNTag } = encryptSymmetric(bindDN, key); + const { ciphertext: encryptedBindPass, iv: bindPassIV, tag: bindPassTag } = encryptSymmetric(bindPass, key); + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + + const ldapConfig = await ldapConfigDAL.create({ + orgId, + isActive, + url, + encryptedBindDN, + bindDNIV, + bindDNTag, + encryptedBindPass, + bindPassIV, + bindPassTag, + searchBase, + searchFilter, + groupSearchBase, + groupSearchFilter, + encryptedCACert, + caCertIV, + caCertTag + }); + + return ldapConfig; + }; + + const updateLdapCfg = async ({ + actor, + actorId, + orgId, + actorOrgId, + isActive, + actorAuthMethod, + url, + bindDN, + bindPass, + searchBase, + searchFilter, + groupSearchBase, + groupSearchFilter, + caCert + }: TUpdateLdapCfgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: + "Failed to update LDAP configuration due to plan restriction. Upgrade plan to update LDAP configuration." + }); + + const updateQuery: TLdapConfigsUpdate = { + isActive, + url, + searchBase, + searchFilter, + groupSearchBase, + groupSearchFilter + }; + + const orgBot = await orgBotDAL.findOne({ orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + if (bindDN !== undefined) { + const { ciphertext: encryptedBindDN, iv: bindDNIV, tag: bindDNTag } = encryptSymmetric(bindDN, key); + updateQuery.encryptedBindDN = encryptedBindDN; + updateQuery.bindDNIV = bindDNIV; + updateQuery.bindDNTag = bindDNTag; + } + + if (bindPass !== undefined) { + const { ciphertext: encryptedBindPass, iv: bindPassIV, tag: bindPassTag } = encryptSymmetric(bindPass, key); + updateQuery.encryptedBindPass = encryptedBindPass; + updateQuery.bindPassIV = bindPassIV; + updateQuery.bindPassTag = bindPassTag; + } + + if (caCert !== undefined) { + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + updateQuery.encryptedCACert = encryptedCACert; + updateQuery.caCertIV = caCertIV; + updateQuery.caCertTag = caCertTag; + } + + const [ldapConfig] = await ldapConfigDAL.update({ orgId }, updateQuery); + + return ldapConfig; + }; + + const getLdapCfg = async (filter: { orgId: string; isActive?: boolean }) => { + const ldapConfig = await ldapConfigDAL.findOne(filter); + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const orgBot = await orgBotDAL.findOne({ orgId: ldapConfig.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { + encryptedBindDN, + bindDNIV, + bindDNTag, + encryptedBindPass, + bindPassIV, + bindPassTag, + encryptedCACert, + caCertIV, + caCertTag + } = ldapConfig; + + let bindDN = ""; + if (encryptedBindDN && bindDNIV && bindDNTag) { + bindDN = decryptSymmetric({ + ciphertext: encryptedBindDN, + key, + tag: bindDNTag, + iv: bindDNIV + }); + } + + let bindPass = ""; + if (encryptedBindPass && bindPassIV && bindPassTag) { + bindPass = decryptSymmetric({ + ciphertext: encryptedBindPass, + key, + tag: bindPassTag, + iv: bindPassIV + }); + } + + let caCert = ""; + if (encryptedCACert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCACert, + key, + tag: caCertTag, + iv: caCertIV + }); + } + + return { + id: ldapConfig.id, + organization: ldapConfig.orgId, + isActive: ldapConfig.isActive, + url: ldapConfig.url, + bindDN, + bindPass, + searchBase: ldapConfig.searchBase, + searchFilter: ldapConfig.searchFilter, + groupSearchBase: ldapConfig.groupSearchBase, + groupSearchFilter: ldapConfig.groupSearchFilter, + caCert + }; + }; + + const getLdapCfgWithPermissionCheck = async ({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TGetLdapCfgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); + return getLdapCfg({ + orgId + }); + }; + + const bootLdap = async (organizationSlug: string) => { + const organization = await orgDAL.findOne({ slug: organizationSlug }); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + const ldapConfig = await getLdapCfg({ + orgId: organization.id, + isActive: true + }); + + const opts = { + server: { + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + searchBase: ldapConfig.searchBase, + searchFilter: ldapConfig.searchFilter || "(uid={{username}})", + // searchAttributes: ["uid", "uidNumber", "givenName", "sn", "mail"], + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }, + passReqToCallback: true + }; + + return { opts, ldapConfig }; + }; + + const ldapLogin = async ({ + ldapConfigId, + externalId, + username, + firstName, + lastName, + email, + groups, + orgId, + relayState + }: TLdapLoginDTO) => { + const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + let userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.LDAP + }); + + const organization = await orgDAL.findOrgById(orgId); + if (!organization) throw new BadRequestError({ message: "Org not found" }); + + if (userAlias) { + await userDAL.transaction(async (tx) => { + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: userAlias.userId, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + if (!orgMembership) { + await orgDAL.createMembership( + { + userId: userAlias.userId, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Accepted + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + }); + } else { + userAlias = await userDAL.transaction(async (tx) => { + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(username, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustLdapEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustLdapEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + const newUserAlias = await userAliasDAL.create( + { + userId: newUser.id, + username, + aliasType: UserAliasType.LDAP, + externalId, + emails: [email], + orgId + }, + tx + ); + + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + + return newUserAlias; + }); + } + + const user = await userDAL.transaction(async (tx) => { + const newUser = await userDAL.findOne({ id: userAlias.userId }, tx); + if (groups) { + const ldapGroupIdsToBePartOf = ( + await ldapGroupMapDAL.find({ + ldapConfigId, + $in: { + ldapGroupCN: groups.map((group) => group.cn) + } + }) + ).map((groupMap) => groupMap.groupId); + + const groupsToBePartOf = await groupDAL.find({ + orgId, + $in: { + id: ldapGroupIdsToBePartOf + } + }); + const toBePartOfGroupIdsSet = new Set(groupsToBePartOf.map((groupToBePartOf) => groupToBePartOf.id)); + + const allLdapGroupMaps = await ldapGroupMapDAL.find({ + ldapConfigId + }); + + const ldapGroupIdsCurrentlyPartOf = ( + await userGroupMembershipDAL.find({ + userId: newUser.id, + $in: { + groupId: allLdapGroupMaps.map((groupMap) => groupMap.groupId) + } + }) + ).map((userGroupMembership) => userGroupMembership.groupId); + + const userGroupMembershipGroupIdsSet = new Set(ldapGroupIdsCurrentlyPartOf); + + for await (const group of groupsToBePartOf) { + if (!userGroupMembershipGroupIdsSet.has(group.id)) { + // add user to group that they should be part of + await addUsersToGroupByUserIds({ + group, + userIds: [newUser.id], + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + } + } + + const groupsCurrentlyPartOf = await groupDAL.find({ + orgId, + $in: { + id: ldapGroupIdsCurrentlyPartOf + } + }); + + for await (const group of groupsCurrentlyPartOf) { + if (!toBePartOfGroupIdsSet.has(group.id)) { + // remove user from group that they should no longer be part of + await removeUsersFromGroupByUserIds({ + group, + userIds: [newUser.id], + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx + }); + } + } + } + + return newUser; + }); + + const isUserCompleted = Boolean(user.isAccepted); + + const providerAuthToken = jwt.sign( + { + authTokenType: AuthTokenType.PROVIDER_TOKEN, + userId: user.id, + username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + firstName, + lastName, + organizationName: organization.name, + organizationId: organization.id, + organizationSlug: organization.slug, + authMethod: AuthMethod.LDAP, + authType: UserAliasType.LDAP, + isUserCompleted, + ...(relayState + ? { + callbackPort: (JSON.parse(relayState) as { callbackPort: string }).callbackPort + } + : {}) + }, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME + } + ); + + return { isUserCompleted, providerAuthToken }; + }; + + const getLdapGroupMaps = async ({ + ldapConfigId, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TGetLdapGroupMapsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); + + const ldapConfig = await ldapConfigDAL.findOne({ + id: ldapConfigId, + orgId + }); + + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const groupMaps = await ldapGroupMapDAL.findLdapGroupMapsByLdapConfigId(ldapConfigId); + + return groupMaps; + }; + + const createLdapGroupMap = async ({ + ldapConfigId, + ldapGroupCN, + groupSlug, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TCreateLdapGroupMapDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: "Failed to create LDAP group map due to plan restriction. Upgrade plan to create LDAP group map." + }); + + const ldapConfig = await ldapConfigDAL.findOne({ + id: ldapConfigId, + orgId + }); + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const group = await groupDAL.findOne({ slug: groupSlug, orgId }); + if (!group) throw new BadRequestError({ message: "Failed to find group" }); + + const groupMap = await ldapGroupMapDAL.create({ + ldapConfigId, + ldapGroupCN, + groupId: group.id + }); + + return groupMap; + }; + + const deleteLdapGroupMap = async ({ + ldapConfigId, + ldapGroupMapId, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TDeleteLdapGroupMapDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: "Failed to delete LDAP group map due to plan restriction. Upgrade plan to delete LDAP group map." + }); + + const ldapConfig = await ldapConfigDAL.findOne({ + id: ldapConfigId, + orgId + }); + + if (!ldapConfig) throw new BadRequestError({ message: "Failed to find organization LDAP data" }); + + const [deletedGroupMap] = await ldapGroupMapDAL.delete({ + ldapConfigId: ldapConfig.id, + id: ldapGroupMapId + }); + + return deletedGroupMap; + }; + + const testLDAPConnection = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TTestLdapConnectionDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + + const plan = await licenseService.getPlan(orgId); + if (!plan.ldap) + throw new BadRequestError({ + message: "Failed to test LDAP connection due to plan restriction. Upgrade plan to test the LDAP connection." + }); + + const ldapConfig = await getLdapCfg({ + orgId + }); + + return testLDAPConfig(ldapConfig); + }; + + return { + createLdapCfg, + updateLdapCfg, + getLdapCfgWithPermissionCheck, + getLdapCfg, + // getLdapPassportOpts, + ldapLogin, + bootLdap, + getLdapGroupMaps, + createLdapGroupMap, + deleteLdapGroupMap, + testLDAPConnection + }; +}; diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts new file mode 100644 index 000000000..aa4aa8da7 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -0,0 +1,80 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TLDAPConfig = { + id: string; + organization: string; + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + groupSearchBase: string; + groupSearchFilter: string; + caCert: string; +}; + +export type TCreateLdapCfgDTO = { + orgId: string; + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + searchFilter: string; + groupSearchBase: string; + groupSearchFilter: string; + caCert: string; +} & TOrgPermission; + +export type TUpdateLdapCfgDTO = { + orgId: string; +} & Partial<{ + isActive: boolean; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + searchFilter: string; + groupSearchBase: string; + groupSearchFilter: string; + caCert: string; +}> & + TOrgPermission; + +export type TGetLdapCfgDTO = { + orgId: string; +} & TOrgPermission; + +export type TLdapLoginDTO = { + ldapConfigId: string; + externalId: string; + username: string; + firstName: string; + lastName: string; + email: string; + orgId: string; + groups?: { + dn: string; + cn: string; + }[]; + relayState?: string; +}; + +export type TGetLdapGroupMapsDTO = { + ldapConfigId: string; +} & TOrgPermission; + +export type TCreateLdapGroupMapDTO = { + ldapConfigId: string; + ldapGroupCN: string; + groupSlug: string; +} & TOrgPermission; + +export type TDeleteLdapGroupMapDTO = { + ldapConfigId: string; + ldapGroupMapId: string; +} & TOrgPermission; + +export type TTestLdapConnectionDTO = { + ldapConfigId: string; +} & TOrgPermission; diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts new file mode 100644 index 000000000..66d799583 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -0,0 +1,119 @@ +import ldapjs from "ldapjs"; + +import { logger } from "@app/lib/logger"; + +import { TLDAPConfig } from "./ldap-config-types"; + +export const isValidLdapFilter = (filter: string) => { + try { + ldapjs.parseFilter(filter); + return true; + } catch (error) { + logger.error("Invalid LDAP filter"); + logger.error(error); + return false; + } +}; + +/** + * Test the LDAP configuration by attempting to bind to the LDAP server + * @param ldapConfig - The LDAP configuration to test + * @returns {Boolean} isConnected - Whether or not the connection was successful + */ +export const testLDAPConfig = async (ldapConfig: TLDAPConfig): Promise => { + return new Promise((resolve) => { + const ldapClient = ldapjs.createClient({ + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }); + + ldapClient.on("error", (err) => { + logger.error("LDAP client error:", err); + logger.error(err); + resolve(false); + }); + + ldapClient.bind(ldapConfig.bindDN, ldapConfig.bindPass, (err) => { + if (err) { + logger.error("Error binding to LDAP"); + logger.error(err); + ldapClient.unbind(); + resolve(false); + } else { + logger.info("Successfully connected and bound to LDAP."); + ldapClient.unbind(); + resolve(true); + } + }); + }); +}; + +/** + * Search for groups in the LDAP server + * @param ldapConfig - The LDAP configuration to use + * @param filter - The filter to use when searching for groups + * @param base - The base to search from + * @returns + */ +export const searchGroups = async ( + ldapConfig: TLDAPConfig, + filter: string, + base: string +): Promise<{ dn: string; cn: string }[]> => { + return new Promise((resolve, reject) => { + const ldapClient = ldapjs.createClient({ + url: ldapConfig.url, + bindDN: ldapConfig.bindDN, + bindCredentials: ldapConfig.bindPass, + ...(ldapConfig.caCert !== "" + ? { + tlsOptions: { + ca: [ldapConfig.caCert] + } + } + : {}) + }); + + ldapClient.search( + base, + { + filter, + scope: "sub" + }, + (err, res) => { + if (err) { + ldapClient.unbind(); + return reject(err); + } + + const groups: { dn: string; cn: string }[] = []; + + res.on("searchEntry", (entry) => { + const dn = entry.dn.toString(); + const regex = /cn=([^,]+)/; + const match = dn.match(regex); + // parse the cn from the dn + const cn = (match && match[1]) as string; + + groups.push({ dn, cn }); + }); + res.on("error", (error) => { + ldapClient.unbind(); + reject(error); + }); + res.on("end", () => { + ldapClient.unbind(); + resolve(groups); + }); + } + ); + }); +}; diff --git a/backend/src/ee/services/ldap-config/ldap-group-map-dal.ts b/backend/src/ee/services/ldap-config/ldap-group-map-dal.ts new file mode 100644 index 000000000..2264efa75 --- /dev/null +++ b/backend/src/ee/services/ldap-config/ldap-group-map-dal.ts @@ -0,0 +1,41 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TLdapGroupMapDALFactory = ReturnType; + +export const ldapGroupMapDALFactory = (db: TDbClient) => { + const ldapGroupMapOrm = ormify(db, TableName.LdapGroupMap); + + const findLdapGroupMapsByLdapConfigId = async (ldapConfigId: string) => { + try { + const docs = await db(TableName.LdapGroupMap) + .where(`${TableName.LdapGroupMap}.ldapConfigId`, ldapConfigId) + .join(TableName.Groups, `${TableName.LdapGroupMap}.groupId`, `${TableName.Groups}.id`) + .select(selectAllTableCols(TableName.LdapGroupMap)) + .select( + db.ref("id").withSchema(TableName.Groups).as("groupId"), + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") + ); + + return docs.map((doc) => { + return { + id: doc.id, + ldapConfigId: doc.ldapConfigId, + ldapGroupCN: doc.ldapGroupCN, + group: { + id: doc.groupId, + name: doc.groupName, + slug: doc.groupSlug + } + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "findGroupMaps" }); + } + }; + + return { ...ldapGroupMapOrm, findLdapGroupMapsByLdapConfigId }; +}; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts new file mode 100644 index 000000000..b5cbf103e --- /dev/null +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -0,0 +1,30 @@ +export const getDefaultOnPremFeatures = () => { + return { + _id: null, + slug: null, + tier: -1, + workspaceLimit: null, + workspacesUsed: 0, + memberLimit: null, + membersUsed: 0, + environmentLimit: null, + environmentsUsed: 0, + secretVersioning: true, + pitRecovery: false, + ipAllowlisting: true, + rbac: false, + customRateLimits: false, + customAlerts: false, + auditLogs: false, + auditLogsRetentionDays: 0, + samlSSO: false, + scim: false, + ldap: false, + groups: false, + status: null, + trial_end: null, + has_used_trial: true, + secretApproval: false, + secretRotation: true + }; +}; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 7014eac5f..189a3c4e0 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -15,6 +15,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ membersUsed: 0, environmentLimit: null, environmentsUsed: 0, + dynamicSecret: false, secretVersioning: true, pitRecovery: false, ipAllowlisting: false, @@ -23,7 +24,12 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, + auditLogStreams: false, + auditLogStreamLimit: 3, samlSSO: false, + scim: false, + ldap: false, + groups: false, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 028af2339..47b46d010 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -5,9 +5,10 @@ // TODO(akhilmhdh): With tony find out the api structure and fill it here import { ForbiddenError } from "@casl/ability"; -import NodeCache from "node-cache"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; +import { verifyOfflineLicense } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -26,6 +27,7 @@ import { TFeatureSet, TGetOrgBillInfoDTO, TGetOrgTaxIdDTO, + TOfflineLicenseContents, TOrgInvoiceDTO, TOrgLicensesDTO, TOrgPlanDTO, @@ -39,6 +41,7 @@ type TLicenseServiceFactoryDep = { orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; + keyStore: Pick; }; export type TLicenseServiceFactory = ReturnType; @@ -46,12 +49,18 @@ export type TLicenseServiceFactory = ReturnType; const LICENSE_SERVER_CLOUD_LOGIN = "/api/auth/v1/license-server-login"; const LICENSE_SERVER_ON_PREM_LOGIN = "/api/auth/v1/license-login"; -const FEATURE_CACHE_KEY = (orgId: string, projectId?: string) => `${orgId}-${projectId || ""}`; -export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: TLicenseServiceFactoryDep) => { +const LICENSE_SERVER_CLOUD_PLAN_TTL = 30; // 30 second +const FEATURE_CACHE_KEY = (orgId: string) => `infisical-cloud-plan-${orgId}`; + +export const licenseServiceFactory = ({ + orgDAL, + permissionService, + licenseDAL, + keyStore +}: TLicenseServiceFactoryDep) => { let isValidLicense = false; let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); - const featureStore = new NodeCache({ stdTTL: 60 }); const appCfg = getConfig(); const licenseServerCloudApi = setupLicenceRequestWithStore( @@ -75,6 +84,7 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: isValidLicense = true; return; } + if (appCfg.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicence(); if (token) { @@ -88,6 +98,36 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: } return; } + + if (appCfg.LICENSE_KEY_OFFLINE) { + let isValidOfflineLicense = true; + const contents: TOfflineLicenseContents = JSON.parse( + Buffer.from(appCfg.LICENSE_KEY_OFFLINE, "base64").toString("utf8") + ); + const isVerified = await verifyOfflineLicense(JSON.stringify(contents.license), contents.signature); + + if (!isVerified) { + isValidOfflineLicense = false; + logger.warn(`Infisical EE offline license verification failed`); + } + + if (contents.license.terminatesAt) { + const terminationDate = new Date(contents.license.terminatesAt); + if (terminationDate < new Date()) { + isValidOfflineLicense = false; + logger.warn(`Infisical EE offline license has expired`); + } + } + + if (isValidOfflineLicense) { + onPremFeatures = contents.license.features; + instanceType = InstanceType.EnterpriseOnPremOffline; + logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`); + isValidLicense = true; + return; + } + } + // this means this is self hosted oss version // else it would reach catch statement isValidLicense = true; @@ -100,22 +140,21 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: logger.info(`getPlan: attempting to fetch plan for [orgId=${orgId}] [projectId=${projectId}]`); try { if (instanceType === InstanceType.Cloud) { - const cachedPlan = featureStore.get(FEATURE_CACHE_KEY(orgId, projectId)); - if (cachedPlan) return cachedPlan; + const cachedPlan = await keyStore.getItem(FEATURE_CACHE_KEY(orgId)); + if (cachedPlan) return JSON.parse(cachedPlan) as TFeatureSet; const org = await orgDAL.findOrgById(orgId); if (!org) throw new BadRequestError({ message: "Org not found" }); const { data: { currentPlan } } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( - `/api/license-server/v1/customers/${org.customerId}/cloud-plan`, - { - params: { - workspaceId: projectId - } - } + `/api/license-server/v1/customers/${org.customerId}/cloud-plan` + ); + await keyStore.setItemWithExpiry( + FEATURE_CACHE_KEY(org.id), + LICENSE_SERVER_CLOUD_PLAN_TTL, + JSON.stringify(currentPlan) ); - featureStore.set(FEATURE_CACHE_KEY(org.id, projectId), currentPlan); return currentPlan; } } catch (error) { @@ -123,26 +162,31 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: `getPlan: encountered an error when fetching pan [orgId=${orgId}] [projectId=${projectId}] [error]`, error ); + await keyStore.setItemWithExpiry( + FEATURE_CACHE_KEY(orgId), + LICENSE_SERVER_CLOUD_PLAN_TTL, + JSON.stringify(onPremFeatures) + ); return onPremFeatures; } return onPremFeatures; }; - const refreshPlan = async (orgId: string, projectId?: string) => { + const refreshPlan = async (orgId: string) => { if (instanceType === InstanceType.Cloud) { - featureStore.del(FEATURE_CACHE_KEY(orgId, projectId)); - await getPlan(orgId, projectId); + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + await getPlan(orgId); } }; - const generateOrgCustomerId = async (orgName: string, email: string) => { + const generateOrgCustomerId = async (orgName: string, email?: string | null) => { if (instanceType === InstanceType.Cloud) { const { data: { customerId } } = await licenseServerCloudApi.request.post<{ customerId: string }>( "/api/license-server/v1/customers", { - email, + email: email ?? "", name: orgName }, { timeout: 5000, signal: AbortSignal.timeout(5000) } @@ -166,7 +210,7 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: quantity: count }); } - featureStore.del(orgId); + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null); await licenseServerOnPremApi.request.patch(`/api/license/v1/license`, { usedSeats }); @@ -175,8 +219,15 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: }; // below all are api calls - const getOrgPlansTableByBillCycle = async ({ orgId, actor, actorId, billingCycle }: TOrgPlansTableDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgPlansTableByBillCycle = async ({ + orgId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + billingCycle + }: TOrgPlansTableDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` @@ -184,15 +235,22 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const getOrgPlan = async ({ orgId, actor, actorId, projectId }: TOrgPlanDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgPlan = async ({ orgId, actor, actorId, actorOrgId, actorAuthMethod, projectId }: TOrgPlanDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const plan = await getPlan(orgId, projectId); return plan; }; - const startOrgTrial = async ({ orgId, actorId, actor, success_url }: TStartOrgTrialDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const startOrgTrial = async ({ + orgId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + success_url + }: TStartOrgTrialDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); @@ -209,12 +267,18 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: `/api/license-server/v1/customers/${organization.customerId}/session/trial`, { success_url } ); - featureStore.del(FEATURE_CACHE_KEY(orgId)); + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); return { url }; }; - const createOrganizationPortalSession = async ({ orgId, actorId, actor }: TCreateOrgPortalSession) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const createOrganizationPortalSession = async ({ + orgId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TCreateOrgPortalSession) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); @@ -260,8 +324,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return { url }; }; - const getOrgBillingInfo = async ({ orgId, actor, actorId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -277,8 +341,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: }; // returns org current plan feature table - const getOrgPlanTable = async ({ orgId, actor, actorId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -293,8 +357,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const getOrgBillingDetails = async ({ orgId, actor, actorId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -310,8 +374,16 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const updateOrgBillingDetails = async ({ actorId, actor, orgId, name, email }: TUpdateOrgBillingDetailsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const updateOrgBillingDetails = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + orgId, + name, + email + }: TUpdateOrgBillingDetailsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -330,8 +402,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const getOrgPmtMethods = async ({ orgId, actor, actorId }: TOrgPmtMethodsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgPmtMethods = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPmtMethodsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -349,8 +421,16 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return pmtMethods; }; - const addOrgPmtMethods = async ({ orgId, actor, actorId, success_url, cancel_url }: TAddOrgPmtMethodDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const addOrgPmtMethods = async ({ + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + success_url, + cancel_url + }: TAddOrgPmtMethodDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -371,8 +451,15 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return { url }; }; - const delOrgPmtMethods = async ({ actorId, actor, orgId, pmtMethodId }: TDelOrgPmtMethodDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const delOrgPmtMethods = async ({ + actorId, + actor, + actorAuthMethod, + actorOrgId, + orgId, + pmtMethodId + }: TDelOrgPmtMethodDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -388,8 +475,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const getOrgTaxIds = async ({ orgId, actor, actorId }: TGetOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -406,8 +493,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return taxIds; }; - const addOrgTaxId = async ({ actorId, actor, orgId, type, value }: TAddOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const addOrgTaxId = async ({ actorId, actor, actorAuthMethod, actorOrgId, orgId, type, value }: TAddOrgTaxIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -427,8 +514,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const delOrgTaxId = async ({ orgId, actor, actorId, taxId }: TDelOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const delOrgTaxId = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId, taxId }: TDelOrgTaxIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -444,8 +531,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return data; }; - const getOrgTaxInvoices = async ({ actorId, actor, orgId }: TOrgInvoiceDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgTaxInvoices = async ({ actorId, actor, actorOrgId, actorAuthMethod, orgId }: TOrgInvoiceDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -461,8 +548,8 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: return invoices; }; - const getOrgLicenses = async ({ orgId, actor, actorId }: TOrgLicensesDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgLicenses = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgLicensesDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); @@ -485,6 +572,9 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: get isValidLicense() { return isValidLicense; }, + getInstanceType() { + return instanceType; + }, getPlan, updateSubscriptionOrgMemberCount, refreshPlan, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 762aff5b2..0c8fdc197 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -3,15 +3,32 @@ import { TOrgPermission } from "@app/lib/types"; export enum InstanceType { OnPrem = "self-hosted", EnterpriseOnPrem = "enterprise-self-hosted", + EnterpriseOnPremOffline = "enterprise-self-hosted-offline", Cloud = "cloud" } +export type TOfflineLicenseContents = { + license: TOfflineLicense; + signature: string; +}; + +export type TOfflineLicense = { + issuedTo: string; + licenseId: string; + customerId: string | null; + issuedAt: string; + expiresAt: string | null; + terminatesAt: string | null; + features: TFeatureSet; +}; + export type TFeatureSet = { _id: null; slug: null; tier: -1; workspaceLimit: null; workspacesUsed: 0; + dynamicSecret: false; memberLimit: null; membersUsed: 0; environmentLimit: null; @@ -24,7 +41,12 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; + auditLogStreams: false; + auditLogStreamLimit: 3; samlSSO: false; + scim: false; + ldap: false; + groups: false; status: null; trial_end: null; has_used_trial: true; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index cc18af8ae..9fece040b 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -16,6 +16,9 @@ export enum OrgPermissionSubjects { Settings = "settings", IncidentAccount = "incident-contact", Sso = "sso", + Scim = "scim", + Ldap = "ldap", + Groups = "groups", Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity" @@ -29,6 +32,9 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.Scim] + | [OrgPermissionActions, OrgPermissionSubjects.Ldap] + | [OrgPermissionActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity]; @@ -69,6 +75,21 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Scim); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); @@ -91,6 +112,7 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index cc35fb04e..d8114388e 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -1,7 +1,9 @@ +import { z } from "zod"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { selectAllTableCols } from "@app/lib/knex"; +import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TPermissionDALFactory = ReturnType; @@ -10,8 +12,10 @@ export const permissionDALFactory = (db: TDbClient) => { try { const membership = await db(TableName.OrgMembership) .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`) .where("userId", userId) .where(`${TableName.OrgMembership}.orgId`, orgId) + .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced")) .select("permissions") .select(selectAllTableCols(TableName.OrgMembership)) .first(); @@ -26,9 +30,11 @@ export const permissionDALFactory = (db: TDbClient) => { try { const membership = await db(TableName.IdentityOrgMembership) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`) .where("identityId", identityId) .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) .select(selectAllTableCols(TableName.IdentityOrgMembership)) + .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced")) .select("permissions") .first(); return membership; @@ -39,15 +45,185 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectPermission = async (userId: string, projectId: string) => { try { - const membership = await db(TableName.ProjectMembership) - .leftJoin(TableName.ProjectRoles, `${TableName.ProjectMembership}.roleId`, `${TableName.ProjectRoles}.id`) + const groups: string[] = await db(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .pluck(`${TableName.GroupProjectMembership}.groupId`); + + const groupDocs = await db(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .whereIn(`${TableName.UserGroupMembership}.groupId`, groups) + .join( + TableName.GroupProjectMembership, + `${TableName.GroupProjectMembership}.groupId`, + `${TableName.UserGroupMembership}.groupId` + ) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + .select(selectAllTableCols(TableName.GroupProjectMembershipRole)) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("membershipUpdatedAt"), + db.ref("projectId").withSchema(TableName.GroupProjectMembership), + db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("orgId").withSchema(TableName.Project), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") + ) + .select("permissions"); + + const docs = await db(TableName.ProjectMembership) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .where("userId", userId) .where(`${TableName.ProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.ProjectMembership)) - .select("permissions") - .first(); + .select(selectAllTableCols(TableName.ProjectUserMembershipRole)) + .select( + db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), + db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), + db.ref("projectId").withSchema(TableName.ProjectMembership), + db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("orgId").withSchema(TableName.Project), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessEndTime") + ); - return membership; + const permission = sqlNestRelationships({ + data: docs, + key: "projectId", + parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({ + orgId, + orgAuthEnforced, + userId, + id: membershipId, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + ProjectUserMembershipRolesSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + }, + { + key: "userApId", + label: "additionalPrivileges" as const, + mapper: ({ + userApId, + userApPermissions, + userApIsTemporary, + userApTemporaryMode, + userApTemporaryRange, + userApTemporaryAccessEndTime, + userApTemporaryAccessStartTime + }) => ({ + id: userApId, + permissions: userApPermissions, + temporaryRange: userApTemporaryRange, + temporaryMode: userApTemporaryMode, + temporaryAccessEndTime: userApTemporaryAccessEndTime, + temporaryAccessStartTime: userApTemporaryAccessStartTime, + isTemporary: userApIsTemporary + }) + } + ] + }); + + const groupPermission = groupDocs.length + ? sqlNestRelationships({ + data: groupDocs, + key: "projectId", + parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({ + orgId, + orgAuthEnforced, + userId, + id: membershipId, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + ProjectUserMembershipRolesSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + } + ] + }) + : []; + + if (!permission?.[0] && !groupPermission[0]) return undefined; + + // when introducting cron mode change it here + const activeRoles = + permission?.[0]?.roles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + const activeGroupRoles = + groupPermission?.[0]?.roles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return { + ...(permission[0] || groupPermission[0]), + roles: [...activeRoles, ...activeGroupRoles], + additionalPrivileges: activeAdditionalPrivileges + }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); } @@ -55,18 +231,119 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectIdentityPermission = async (identityId: string, projectId: string) => { try { - const membership = await db(TableName.IdentityProjectMembership) + const docs = await db(TableName.IdentityProjectMembership) + .join( + TableName.IdentityProjectMembershipRole, + `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .leftJoin( TableName.ProjectRoles, - `${TableName.IdentityProjectMembership}.roleId`, + `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) + .join( + // Join the Project table to later select orgId + TableName.Project, + `${TableName.IdentityProjectMembership}.projectId`, + `${TableName.Project}.id` + ) .where("identityId", identityId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.IdentityProjectMembership)) - .select("permissions") - .first(); - return membership; + .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) + .select( + db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), + db.ref("orgId").withSchema(TableName.Project).as("orgId"), // Now you can select orgId from Project + db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), + db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), + db + .ref("temporaryMode") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessEndTime") + ); + + const permission = sqlNestRelationships({ + data: docs, + key: "membershipId", + parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, orgId }) => ({ + id: membershipId, + identityId, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt, + orgId, + // just a prefilled value + orgAuthEnforced: false + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + IdentityProjectMembershipRoleSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + }, + { + key: "identityApId", + label: "additionalPrivileges" as const, + mapper: ({ + identityApId, + identityApPermissions, + identityApIsTemporary, + identityApTemporaryMode, + identityApTemporaryRange, + identityApTemporaryAccessEndTime, + identityApTemporaryAccessStartTime + }) => ({ + id: identityApId, + permissions: identityApPermissions, + temporaryRange: identityApTemporaryRange, + temporaryMode: identityApTemporaryMode, + temporaryAccessEndTime: identityApTemporaryAccessEndTime, + temporaryAccessStartTime: identityApTemporaryAccessStartTime, + isTemporary: identityApIsTemporary + }) + } + ] + }); + + if (!permission?.[0]) return undefined; + + // when introducting cron mode change it here + const activeRoles = permission?.[0]?.roles.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); } diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts new file mode 100644 index 000000000..eda19c215 --- /dev/null +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -0,0 +1,27 @@ +import { TOrganizations } from "@app/db/schemas"; +import { UnauthorizedError } from "@app/lib/errors"; +import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; + +function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { + if (!actorAuthMethod) return false; + + return [ + AuthMethod.AZURE_SAML, + AuthMethod.OKTA_SAML, + AuthMethod.JUMPCLOUD_SAML, + AuthMethod.GOOGLE_SAML, + AuthMethod.KEYCLOAK_SAML + ].includes(actorAuthMethod); +} + +function validateOrgSAML(actorAuthMethod: ActorAuthMethod, isSamlEnforced: TOrganizations["authEnforced"]) { + if (actorAuthMethod === undefined) { + throw new UnauthorizedError({ name: "No auth method defined" }); + } + + if (isSamlEnforced && actorAuthMethod !== null && !isAuthMethodSaml(actorAuthMethod)) { + throw new UnauthorizedError({ name: "Cannot access org-scoped resource" }); + } +} + +export { isAuthMethodSaml, validateOrgSAML }; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 3daf1c20a..f4e423797 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -11,13 +11,16 @@ import { } from "@app/db/schemas"; import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; -import { ActorType } from "@app/services/auth/auth-type"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { TServiceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; +import { validateOrgSAML } from "./permission-fns"; +import { TBuildProjectPermissionDTO } from "./permission-types"; import { buildServiceTokenProjectPermission, projectAdminPermissions, @@ -31,6 +34,7 @@ type TPermissionServiceFactoryDep = { orgRoleDAL: Pick; projectRoleDAL: Pick; serviceTokenDAL: Pick; + projectDAL: Pick; permissionDAL: TPermissionDALFactory; }; @@ -40,7 +44,8 @@ export const permissionServiceFactory = ({ permissionDAL, orgRoleDAL, projectRoleDAL, - serviceTokenDAL + serviceTokenDAL, + projectDAL }: TPermissionServiceFactoryDep) => { const buildOrgPermission = (role: string, permission?: unknown) => { switch (role) { @@ -64,42 +69,63 @@ export const permissionServiceFactory = ({ } }; - const buildProjectPermission = (role: string, permission?: unknown) => { - switch (role) { - case ProjectMembershipRole.Admin: - return projectAdminPermissions; - case ProjectMembershipRole.Member: - return projectMemberPermissions; - case ProjectMembershipRole.Viewer: - return projectViewerPermission; - case ProjectMembershipRole.NoAccess: - return projectNoAccessPermissions; - case ProjectMembershipRole.Custom: - return createMongoAbility( - unpackRules>>( - permission as PackRule>>[] - ), - { - conditionsMatcher + const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { + const rules = projectUserRoles + .map(({ role, permissions }) => { + switch (role) { + case ProjectMembershipRole.Admin: + return projectAdminPermissions; + case ProjectMembershipRole.Member: + return projectMemberPermissions; + case ProjectMembershipRole.Viewer: + return projectViewerPermission; + case ProjectMembershipRole.NoAccess: + return projectNoAccessPermissions; + case ProjectMembershipRole.Custom: { + return unpackRules>>( + permissions as PackRule>>[] + ); } - ); - default: - throw new BadRequestError({ - name: "ProjectRoleInvalid", - message: "Project role not found" - }); - } + default: + throw new BadRequestError({ + name: "ProjectRoleInvalid", + message: "Project role not found" + }); + } + }) + .reduce((curr, prev) => prev.concat(curr), []); + + return createMongoAbility(rules, { + conditionsMatcher + }); }; /* * Get user permission in an organization - * */ - const getUserOrgPermission = async (userId: string, orgId: string) => { + */ + const getUserOrgPermission = async ( + userId: string, + orgId: string, + authMethod: ActorAuthMethod, + userOrgId?: string + ) => { const membership = await permissionDAL.getOrgPermission(userId, orgId); if (!membership) throw new UnauthorizedError({ name: "User not in org" }); if (membership.role === OrgMembershipRole.Custom && !membership.permissions) { throw new BadRequestError({ name: "Custom permission not found" }); } + + // If the org ID is API_KEY, the request is being made with an API Key. + // Since we can't scope API keys to an organization, we'll need to do an arbitrary check to see if the user is a member of the organization. + + // Extra: This means that when users are using API keys to make requests, they can't use slug-based routes. + // Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization. + if (userOrgId !== "API_KEY" && membership.orgId !== userOrgId) { + throw new UnauthorizedError({ name: "You are not logged into this organization" }); + } + + validateOrgSAML(authMethod, membership.orgAuthEnforced); + return { permission: buildOrgPermission(membership.role, membership.permissions), membership }; }; @@ -112,10 +138,16 @@ export const permissionServiceFactory = ({ return { permission: buildOrgPermission(membership.role, membership.permissions), membership }; }; - const getOrgPermission = async (type: ActorType, id: string, orgId: string) => { + const getOrgPermission = async ( + type: ActorType, + id: string, + orgId: string, + authMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { switch (type) { case ActorType.USER: - return getUserOrgPermission(id, orgId); + return getUserOrgPermission(id, orgId, authMethod, actorOrgId); case ActorType.IDENTITY: return getIdentityOrgPermission(id, orgId); default: @@ -142,36 +174,114 @@ export const permissionServiceFactory = ({ }; // user permission for a project in an organization - const getUserProjectPermission = async (userId: string, projectId: string) => { - const membership = await permissionDAL.getProjectPermission(userId, projectId); - if (!membership) throw new UnauthorizedError({ name: "User not in project" }); - if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { + const getUserProjectPermission = async ( + userId: string, + projectId: string, + authMethod: ActorAuthMethod, + userOrgId?: string + ): Promise> => { + const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); + if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" }); + + if ( + userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } + + // If the org ID is API_KEY, the request is being made with an API Key. + // Since we can't scope API keys to an organization, we'll need to do an arbitrary check to see if the user is a member of the organization. + + // Extra: This means that when users are using API keys to make requests, they can't use slug-based routes. + // Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization. + if (userOrgId !== "API_KEY" && userProjectPermission.orgId !== userOrgId) { + throw new UnauthorizedError({ name: "You are not logged into this organization" }); + } + + validateOrgSAML(authMethod, userProjectPermission.orgAuthEnforced); + + // join two permissions and pass to build the final permission set + const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + return { - permission: buildProjectPermission(membership.role, membership.permissions), - membership + permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), + membership: userProjectPermission, + hasRole: (role: string) => + userProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; - const getIdentityProjectPermission = async (identityId: string, projectId: string) => { - const membership = await permissionDAL.getProjectIdentityPermission(identityId, projectId); - if (!membership) throw new UnauthorizedError({ name: "Identity not in project" }); - if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { + const getIdentityProjectPermission = async ( + identityId: string, + projectId: string, + identityOrgId: string | undefined + ): Promise> => { + const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId); + if (!identityProjectPermission) throw new UnauthorizedError({ name: "Identity not in project" }); + + if ( + identityProjectPermission.roles.some( + ({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions + ) + ) { throw new BadRequestError({ name: "Custom permission not found" }); } + + if (identityProjectPermission.orgId !== identityOrgId) { + throw new UnauthorizedError({ name: "You are not a member of this organization" }); + } + + const rolePermissions = + identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + return { - permission: buildProjectPermission(membership.role, membership.permissions), - membership + permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), + membership: identityProjectPermission, + hasRole: (role: string) => + identityProjectPermission.roles.findIndex( + ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug + ) !== -1 }; }; - const getServiceTokenProjectPermission = async (serviceTokenId: string, projectId: string) => { + const getServiceTokenProjectPermission = async ( + serviceTokenId: string, + projectId: string, + actorOrgId: string | undefined + ) => { const serviceToken = await serviceTokenDAL.findById(serviceTokenId); + if (!serviceToken) throw new BadRequestError({ message: "Service token not found" }); + + const serviceTokenProject = await projectDAL.findById(serviceToken.projectId); + + if (!serviceTokenProject) throw new BadRequestError({ message: "Service token not linked to a project" }); + + if (serviceTokenProject.orgId !== actorOrgId) { + throw new UnauthorizedError({ message: "Service token not a part of this organization" }); + } + if (serviceToken.projectId !== projectId) throw new UnauthorizedError({ message: "Failed to find service authorization for given project" }); + + if (serviceTokenProject.orgId !== actorOrgId) + throw new UnauthorizedError({ + message: "Failed to find service authorization for given project" + }); + const scopes = ServiceTokenScopes.parse(serviceToken.scopes || []); return { permission: buildServiceTokenProjectPermission(scopes, serviceToken.permissions), @@ -180,26 +290,35 @@ export const permissionServiceFactory = ({ }; type TProjectPermissionRT = T extends ActorType.SERVICE - ? { permission: MongoAbility; membership: undefined } + ? { + permission: MongoAbility; + membership: undefined; + hasRole: (arg: string) => boolean; + } // service token doesn't have both membership and roles : { permission: MongoAbility; membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { - permissions?: unknown; + orgAuthEnforced: boolean | null | undefined; + orgId: string; + roles: Array<{ role: string }>; }; + hasRole: (role: string) => boolean; }; const getProjectPermission = async ( type: T, id: string, - projectId: string + projectId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined ): Promise> => { switch (type) { case ActorType.USER: - return getUserProjectPermission(id, projectId) as Promise>; + return getUserProjectPermission(id, projectId, actorAuthMethod, actorOrgId) as Promise>; case ActorType.SERVICE: - return getServiceTokenProjectPermission(id, projectId) as Promise>; + return getServiceTokenProjectPermission(id, projectId, actorOrgId) as Promise>; case ActorType.IDENTITY: - return getIdentityProjectPermission(id, projectId) as Promise>; + return getIdentityProjectPermission(id, projectId, actorOrgId) as Promise>; default: throw new UnauthorizedError({ message: "Permission not defined", @@ -214,11 +333,13 @@ export const permissionServiceFactory = ({ const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); if (!projectRole) throw new BadRequestError({ message: "Role not found" }); return { - permission: buildProjectPermission(ProjectMembershipRole.Custom, projectRole.permissions), + permission: buildProjectPermission([ + { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } + ]), role: projectRole }; } - return { permission: buildProjectPermission(role, []) }; + return { permission: buildProjectPermission([{ role, permissions: [] }]) }; }; return { diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index e69de29bb..a35958ffd 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -0,0 +1,4 @@ +export type TBuildProjectPermissionDTO = { + permissions?: unknown; + role: string; +}[]; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 5245c26e4..b24024bd4 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -12,6 +12,7 @@ export enum ProjectPermissionActions { export enum ProjectPermissionSub { Role = "role", Member = "member", + Groups = "groups", Settings = "settings", Integrations = "integrations", Webhooks = "webhooks", @@ -41,6 +42,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] @@ -56,8 +58,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; -const buildAdminPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildAdminPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); @@ -82,6 +84,11 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Create, ProjectPermissionSub.Role); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); @@ -135,13 +142,13 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - return build({ conditionsMatcher }); + return rules; }; -export const projectAdminPermissions = buildAdminPermission(); +export const projectAdminPermissions = buildAdminPermissionRules(); -const buildMemberPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildMemberPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); @@ -157,6 +164,8 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); @@ -196,19 +205,20 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build({ conditionsMatcher }); + return rules; }; -export const projectMemberPermissions = buildMemberPermission(); +export const projectMemberPermissions = buildMemberPermissionRules(); -const buildViewerPermission = () => { - const { can, build } = new AbilityBuilder>(createMongoAbility); +const buildViewerPermissionRules = () => { + const { can, rules } = new AbilityBuilder>(createMongoAbility); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); @@ -220,14 +230,14 @@ const buildViewerPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build({ conditionsMatcher }); + return rules; }; -export const projectViewerPermission = buildViewerPermission(); +export const projectViewerPermission = buildViewerPermissionRules(); const buildNoAccessProjectPermission = () => { - const { build } = new AbilityBuilder>(createMongoAbility); - return build({ conditionsMatcher }); + const { rules } = new AbilityBuilder>(createMongoAbility); + return rules; }; export const buildServiceTokenProjectPermission = ( diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts new file mode 100644 index 000000000..6c15d2d5d --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectUserAdditionalPrivilegeDALFactory = ReturnType; + +export const projectUserAdditionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProjectUserAdditionalPrivilege); + return orm; +}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts new file mode 100644 index 000000000..c9ff2c7e0 --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -0,0 +1,212 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { BadRequestError } from "@app/lib/errors"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; +import { + ProjectUserAdditionalPrivilegeTemporaryMode, + TCreateUserPrivilegeDTO, + TDeleteUserPrivilegeDTO, + TGetUserPrivilegeDetailsDTO, + TListUserPrivilegesDTO, + TUpdateUserPrivilegeDTO +} from "./project-user-additional-privilege-types"; + +type TProjectUserAdditionalPrivilegeServiceFactoryDep = { + projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; + projectMembershipDAL: Pick; + permissionService: Pick; +}; + +export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType< + typeof projectUserAdditionalPrivilegeServiceFactory +>; + +export const projectUserAdditionalPrivilegeServiceFactory = ({ + projectUserAdditionalPrivilegeDAL, + projectMembershipDAL, + permissionService +}: TProjectUserAdditionalPrivilegeServiceFactoryDep) => { + const create = async ({ + slug, + actor, + actorId, + permissions: customPermission, + actorOrgId, + actorAuthMethod, + projectMembershipId, + ...dto + }: TCreateUserPrivilegeDTO) => { + const projectMembership = await projectMembershipDAL.findById(projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId }); + if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + + if (!dto.isTemporary) { + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ + projectMembershipId, + slug, + permissions: customPermission + }); + return additionalPrivilege; + } + + const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ + projectMembershipId, + slug, + permissions: customPermission, + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: dto.temporaryRange, + temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) + }); + return additionalPrivilege; + }; + + const updateById = async ({ + privilegeId, + actorOrgId, + actor, + actorId, + actorAuthMethod, + ...dto + }: TUpdateUserPrivilegeDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + if (dto?.slug) { + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ + slug: dto.slug, + projectMembershipId: projectMembership.id + }); + if (existingSlug && existingSlug.id !== userPrivilege.id) + throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + } + + const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary; + if (isTemporary) { + const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime; + const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange; + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { + ...dto, + temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), + temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) + }); + return additionalPrivilege; + } + + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { + ...dto, + isTemporary: false, + temporaryAccessStartTime: null, + temporaryAccessEndTime: null, + temporaryRange: null, + temporaryMode: null + }); + return additionalPrivilege; + }; + + const deleteById = async ({ actorId, actor, actorOrgId, actorAuthMethod, privilegeId }: TDeleteUserPrivilegeDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); + return deletedPrivilege; + }; + + const getPrivilegeDetailsById = async ({ + privilegeId, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TGetUserPrivilegeDetailsDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + + return userPrivilege; + }; + + const listPrivileges = async ({ + projectMembershipId, + actorOrgId, + actor, + actorId, + actorAuthMethod + }: TListUserPrivilegesDTO) => { + const projectMembership = await projectMembershipDAL.findById(projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + + const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ projectMembershipId }); + return userPrivileges; + }; + + return { + create, + updateById, + deleteById, + getPrivilegeDetailsById, + listPrivileges + }; +}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts new file mode 100644 index 000000000..572474270 --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts @@ -0,0 +1,40 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum ProjectUserAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TCreateUserPrivilegeDTO = ( + | { + permissions: unknown; + projectMembershipId: string; + slug: string; + isTemporary: false; + } + | { + permissions: unknown; + projectMembershipId: string; + slug: string; + isTemporary: true; + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } +) & + Omit; + +export type TUpdateUserPrivilegeDTO = { privilegeId: string } & Omit & + Partial<{ + permissions: unknown; + slug: string; + isTemporary: boolean; + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + +export type TDeleteUserPrivilegeDTO = Omit & { privilegeId: string }; + +export type TGetUserPrivilegeDetailsDTO = Omit & { privilegeId: string }; + +export type TListUserPrivilegesDTO = Omit & { projectMembershipId: string }; diff --git a/backend/src/ee/services/saml-config/saml-config-dal.ts b/backend/src/ee/services/saml-config/saml-config-dal.ts index 95f6828bc..1e7b9e47e 100644 --- a/backend/src/ee/services/saml-config/saml-config-dal.ts +++ b/backend/src/ee/services/saml-config/saml-config-dal.ts @@ -1,10 +1,31 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TSamlConfigDALFactory = ReturnType; export const samlConfigDALFactory = (db: TDbClient) => { const samlCfgOrm = ormify(db, TableName.SamlConfig); - return samlCfgOrm; + + const findEnforceableSamlCfg = async (orgId: string) => { + try { + const samlCfg = await db(TableName.SamlConfig) + .where({ + orgId, + isActive: true + }) + .whereNotNull("lastUsed") + .first(); + + return samlCfg; + } catch (error) { + throw new DatabaseError({ error, name: "Find org by id" }); + } + }; + + return { + ...samlCfgOrm, + findEnforceableSamlCfg + }; }; diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 9da2dd122..7dfd211e1 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -5,8 +5,10 @@ import { OrgMembershipRole, OrgMembershipStatus, SecretKeyEncoding, + TableName, TSamlConfigs, - TSamlConfigsUpdate + TSamlConfigsUpdate, + TUsers } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { @@ -19,29 +21,38 @@ import { } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { AuthTokenType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TSamlConfigDALFactory } from "./saml-config-dal"; -import { - SamlProviders, - TCreateSamlCfgDTO, - TGetSamlCfgDTO, - TSamlLoginDTO, - TUpdateSamlCfgDTO -} from "./saml-config-types"; +import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types"; type TSamlConfigServiceFactoryDep = { - samlConfigDAL: TSamlConfigDALFactory; - userDAL: Pick; - orgDAL: Pick; + samlConfigDAL: Pick; + userDAL: Pick; + userAliasDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" + >; + orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; licenseService: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TSamlConfigServiceFactory = ReturnType; @@ -50,13 +61,19 @@ export const samlConfigServiceFactory = ({ samlConfigDAL, orgBotDAL, orgDAL, + orgMembershipDAL, userDAL, + userAliasDAL, permissionService, - licenseService + licenseService, + tokenService, + smtpService }: TSamlConfigServiceFactoryDep) => { const createSamlCfg = async ({ cert, actor, + actorAuthMethod, + actorOrgId, orgId, issuer, actorId, @@ -64,14 +81,14 @@ export const samlConfigServiceFactory = ({ entryPoint, authProvider }: TCreateSamlCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); const plan = await licenseService.getPlan(orgId); if (!plan.samlSSO) throw new BadRequestError({ message: - "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to create SSO configuration." }); const orgBot = await orgBotDAL.transaction(async (tx) => { @@ -124,7 +141,6 @@ export const samlConfigServiceFactory = ({ const { ciphertext: encryptedEntryPoint, iv: entryPointIV, tag: entryPointTag } = encryptSymmetric(entryPoint, key); const { ciphertext: encryptedIssuer, iv: issuerIV, tag: issuerTag } = encryptSymmetric(issuer, key); - const { ciphertext: encryptedCert, iv: certIV, tag: certTag } = encryptSymmetric(cert, key); const samlConfig = await samlConfigDAL.create({ orgId, @@ -140,12 +156,15 @@ export const samlConfigServiceFactory = ({ certIV, certTag }); + return samlConfig; }; const updateSamlCfg = async ({ orgId, actor, + actorOrgId, + actorAuthMethod, cert, actorId, issuer, @@ -153,7 +172,7 @@ export const samlConfigServiceFactory = ({ entryPoint, authProvider }: TUpdateSamlCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); const plan = await licenseService.getPlan(orgId); if (!plan.samlSSO) @@ -162,7 +181,7 @@ export const samlConfigServiceFactory = ({ "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." }); - const updateQuery: TSamlConfigsUpdate = { authProvider, isActive }; + const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null }; const orgBot = await orgBotDAL.findOne({ orgId }); if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); const key = infisicalSymmetricDecrypt({ @@ -172,7 +191,7 @@ export const samlConfigServiceFactory = ({ keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding }); - if (entryPoint) { + if (entryPoint !== undefined) { const { ciphertext: encryptedEntryPoint, iv: entryPointIV, @@ -182,19 +201,22 @@ export const samlConfigServiceFactory = ({ updateQuery.entryPointIV = entryPointIV; updateQuery.entryPointTag = entryPointTag; } - if (issuer) { + if (issuer !== undefined) { const { ciphertext: encryptedIssuer, iv: issuerIV, tag: issuerTag } = encryptSymmetric(issuer, key); updateQuery.encryptedIssuer = encryptedIssuer; updateQuery.issuerIV = issuerIV; updateQuery.issuerTag = issuerTag; } - if (cert) { + if (cert !== undefined) { const { ciphertext: encryptedCert, iv: certIV, tag: certTag } = encryptSymmetric(cert, key); updateQuery.encryptedCert = encryptedCert; updateQuery.certIV = certIV; updateQuery.certTag = certTag; } + const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery); + await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false }); + return ssoConfig; }; @@ -203,6 +225,10 @@ export const samlConfigServiceFactory = ({ if (dto.type === "org") { ssoConfig = await samlConfigDAL.findOne({ orgId: dto.orgId }); if (!ssoConfig) return; + } else if (dto.type === "orgSlug") { + const org = await orgDAL.findOne({ slug: dto.orgSlug }); + if (!org) return; + ssoConfig = await samlConfigDAL.findOne({ orgId: org.id }); } else if (dto.type === "ssoId") { // TODO: // We made this change because saml config ids were not moved over during the migration @@ -227,7 +253,13 @@ export const samlConfigServiceFactory = ({ // when dto is type id means it's internally used if (dto.type === "org") { - const { permission } = await permissionService.getOrgPermission(dto.actor, dto.actorId, ssoConfig.orgId); + const { permission } = await permissionService.getOrgPermission( + dto.actor, + dto.actorId, + ssoConfig.orgId, + dto.actorAuthMethod, + dto.actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); } const { @@ -284,48 +316,55 @@ export const samlConfigServiceFactory = ({ isActive: ssoConfig.isActive, entryPoint, issuer, - cert + cert, + lastUsed: ssoConfig.lastUsed }; }; const samlLogin = async ({ - firstName, + externalId, email, + firstName, lastName, authProvider, orgId, - relayState, - isSignupAllowed + relayState }: TSamlLoginDTO) => { const appCfg = getConfig(); - let user = await userDAL.findUserByEmail(email); - const isSamlSignUpDisabled = !isSignupAllowed && !user; - if (isSamlSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Saml SSO login" }); + const serverCfg = await getServerCfg(); + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.SAML + }); const organization = await orgDAL.findOrgById(orgId); if (!organization) throw new BadRequestError({ message: "Org not found" }); - if (user) { - const hasSamlEnabled = (user.authMethods || []).some((method) => - Object.values(SamlProviders).includes(method as SamlProviders) - ); - await userDAL.transaction(async (tx) => { - if (!hasSamlEnabled) { - await userDAL.updateById(user.id, { authMethods: [authProvider] }, tx); - } - const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx }); + let user: TUsers; + if (userAlias) { + user = await userDAL.transaction(async (tx) => { + const foundUser = await userDAL.findById(userAlias.userId, tx); + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); if (!orgMembership) { - await orgDAL.createMembership( + await orgMembershipDAL.create( { - userId: user.id, - orgId, + userId: userAlias.userId, inviteEmail: email, + orgId, role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Accepted + status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later }, tx ); - } else if (orgMembership.status === OrgMembershipStatus.Invited) { + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) { await orgDAL.updateMembershipById( orgMembership.id, { @@ -334,38 +373,97 @@ export const samlConfigServiceFactory = ({ tx ); } + + return foundUser; }); } else { user = await userDAL.transaction(async (tx) => { - const newUser = await userDAL.create( + let newUser: TUsers | undefined; + if (serverCfg.trustSamlEmails) { + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!newUser) { + const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); + newUser = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( { - email, - firstName, - lastName, - authMethods: [authProvider] + userId: newUser.id, + aliasType: UserAliasType.SAML, + externalId, + emails: email ? [email] : [], + orgId }, tx ); - await orgDAL.createMembership({ - inviteEmail: email, - orgId, - role: OrgMembershipRole.Member, - status: OrgMembershipStatus.Invited - }); + + const [orgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + if (!orgMembership) { + await orgMembershipDAL.create( + { + userId: newUser.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && newUser.isAccepted) { + await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + return newUser; }); } + const isUserCompleted = Boolean(user.isAccepted); const providerAuthToken = jwt.sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, - email: user.email, + username: user.username, + ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, + organizationSlug: organization.slug, authMethod: authProvider, + authType: UserAliasType.SAML, isUserCompleted, ...(relayState ? { @@ -378,6 +476,25 @@ export const samlConfigServiceFactory = ({ expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME } ); + + await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); + + if (user.email && !user.isEmailVerified) { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + } + return { isUserCompleted, providerAuthToken }; }; diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 18a511af5..92ee32b5c 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -1,10 +1,12 @@ import { TOrgPermission } from "@app/lib/types"; -import { ActorType } from "@app/services/auth/auth-type"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; export enum SamlProviders { OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + JUMPCLOUD_SAML = "jumpcloud-saml", + GOOGLE_SAML = "google-saml", + KEYCLOAK_SAML = "keycloak-saml" } export type TCreateSamlCfgDTO = { @@ -25,19 +27,30 @@ export type TUpdateSamlCfgDTO = Partial<{ TOrgPermission; export type TGetSamlCfgDTO = - | { type: "org"; orgId: string; actor: ActorType; actorId: string } + | { + type: "org"; + orgId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + } + | { + type: "orgSlug"; + orgSlug: string; + } | { type: "ssoId"; id: string; }; export type TSamlLoginDTO = { + externalId: string; email: string; firstName: string; lastName?: string; authProvider: string; orgId: string; - isSignupAllowed: boolean; // saml thingy relayState?: string; }; diff --git a/backend/src/ee/services/scim/scim-dal.ts b/backend/src/ee/services/scim/scim-dal.ts new file mode 100644 index 000000000..05c21b80c --- /dev/null +++ b/backend/src/ee/services/scim/scim-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TScimDALFactory = ReturnType; + +export const scimDALFactory = (db: TDbClient) => { + const scimTokenOrm = ormify(db, TableName.ScimToken); + return scimTokenOrm; +}; diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts new file mode 100644 index 000000000..ec54a4d1f --- /dev/null +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -0,0 +1,108 @@ +import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-types"; + +export const buildScimUserList = ({ + scimUsers, + startIndex, + limit +}: { + scimUsers: TScimUser[]; + startIndex: number; + limit: number; +}): TListScimUsers => { + return { + Resources: scimUsers, + itemsPerPage: limit, + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + startIndex, + totalResults: scimUsers.length + }; +}; + +export const buildScimUser = ({ + orgMembershipId, + username, + email, + firstName, + lastName, + active +}: { + orgMembershipId: string; + username: string; + email?: string | null; + firstName: string; + lastName: string; + active: boolean; +}): TScimUser => { + const scimUser = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + id: orgMembershipId, + userName: username, + displayName: `${firstName} ${lastName}`, + name: { + givenName: firstName, + middleName: null, + familyName: lastName + }, + emails: email + ? [ + { + primary: true, + value: email, + type: "work" + } + ] + : [], + active, + groups: [], + meta: { + resourceType: "User", + location: null + } + }; + + return scimUser; +}; + +export const buildScimGroupList = ({ + scimGroups, + startIndex, + limit +}: { + scimGroups: TScimGroup[]; + startIndex: number; + limit: number; +}): TListScimGroups => { + return { + Resources: scimGroups, + itemsPerPage: limit, + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + startIndex, + totalResults: scimGroups.length + }; +}; + +export const buildScimGroup = ({ + groupId, + name, + members +}: { + groupId: string; + name: string; + members: { + value: string; + display: string; + }[]; +}): TScimGroup => { + const scimGroup = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + id: groupId, + displayName: name, + members, + meta: { + resourceType: "Group", + location: null + } + }; + + return scimGroup; +}; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts new file mode 100644 index 000000000..9a084c6d7 --- /dev/null +++ b/backend/src/ee/services/scim/scim-service.ts @@ -0,0 +1,977 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups, TOrgMemberships, TUsers } from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TOrgPermission } from "@app/lib/types"; +import { AuthTokenType } from "@app/services/auth/auth-type"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; +import { normalizeUsername } from "@app/services/user/user-fns"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { UserAliasType } from "@app/services/user-alias/user-alias-types"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList } from "./scim-fns"; +import { + TCreateScimGroupDTO, + TCreateScimTokenDTO, + TCreateScimUserDTO, + TDeleteScimGroupDTO, + TDeleteScimTokenDTO, + TDeleteScimUserDTO, + TGetScimGroupDTO, + TGetScimUserDTO, + TListScimGroupsDTO, + TListScimUsers, + TListScimUsersDTO, + TReplaceScimUserDTO, + TScimTokenJwtPayload, + TUpdateScimGroupNamePatchDTO, + TUpdateScimGroupNamePutDTO, + TUpdateScimUserDTO +} from "./scim-types"; + +type TScimServiceFactoryDep = { + scimDAL: Pick; + userDAL: Pick< + TUserDALFactory, + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + >; + userAliasDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" + >; + orgMembershipDAL: Pick; + projectDAL: Pick; + projectMembershipDAL: Pick; + groupDAL: Pick< + TGroupDALFactory, + "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction" + >; + groupProjectDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "filterProjectsByUserMembership" | "delete" + >; + projectKeyDAL: Pick; + projectBotDAL: Pick; + licenseService: Pick; + permissionService: Pick; + smtpService: Pick; +}; + +export type TScimServiceFactory = ReturnType; + +export const scimServiceFactory = ({ + licenseService, + scimDAL, + userDAL, + userAliasDAL, + orgDAL, + orgMembershipDAL, + projectDAL, + projectMembershipDAL, + groupDAL, + groupProjectDAL, + userGroupMembershipDAL, + projectKeyDAL, + projectBotDAL, + permissionService, + smtpService +}: TScimServiceFactoryDep) => { + const createScimToken = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + orgId, + description, + ttlDays + }: TCreateScimTokenDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim); + + const plan = await licenseService.getPlan(orgId); + if (!plan.scim) + throw new BadRequestError({ + message: "Failed to create a SCIM token due to plan restriction. Upgrade plan to create a SCIM token." + }); + + const appCfg = getConfig(); + + const scimTokenData = await scimDAL.create({ + orgId, + description, + ttlDays + }); + + const scimToken = jwt.sign( + { + scimTokenId: scimTokenData.id, + authTokenType: AuthTokenType.SCIM_TOKEN + }, + appCfg.AUTH_SECRET + ); + + return { scimToken }; + }; + + const listScimTokens = async ({ actor, actorId, actorOrgId, actorAuthMethod, orgId }: TOrgPermission) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); + + const plan = await licenseService.getPlan(orgId); + if (!plan.scim) + throw new BadRequestError({ + message: "Failed to get SCIM tokens due to plan restriction. Upgrade plan to get SCIM tokens." + }); + + const scimTokens = await scimDAL.find({ orgId }); + return scimTokens; + }; + + const deleteScimToken = async ({ scimTokenId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteScimTokenDTO) => { + let scimToken = await scimDAL.findById(scimTokenId); + if (!scimToken) throw new BadRequestError({ message: "Failed to find SCIM token to delete" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + scimToken.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); + + const plan = await licenseService.getPlan(scimToken.orgId); + if (!plan.scim) + throw new BadRequestError({ + message: "Failed to delete the SCIM token due to plan restriction. Upgrade plan to delete the SCIM token." + }); + + scimToken = await scimDAL.deleteById(scimTokenId); + + return scimToken; + }; + + // SCIM server endpoints + const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const parseFilter = (filterToParse: string | undefined) => { + if (!filterToParse) return {}; + const [parsedName, parsedValue] = filterToParse.split("eq").map((s) => s.trim()); + + let attributeName = parsedName; + if (parsedName === "userName") { + attributeName = "email"; + } + + return { [attributeName]: parsedValue.replace(/"/g, "") }; + }; + + const findOpts = { + ...(startIndex && { offset: startIndex - 1 }), + ...(limit && { limit }) + }; + + const users = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "id"]: orgId, + ...parseFilter(filter) + }, + findOpts + ); + + const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email }) => + buildScimUser({ + orgMembershipId: id ?? "", + username: externalId ?? username, + firstName: firstName ?? "", + lastName: lastName ?? "", + email, + active: true + }) + ); + + return buildScimUserList({ + scimUsers, + startIndex, + limit + }); + }; + + const getScimUser = async ({ orgMembershipId, orgId }: TGetScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }) + .catch(() => { + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + }); + + if (!membership) + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + if (!membership.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + return buildScimUser({ + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, + email: membership.email ?? "", + firstName: membership.firstName as string, + lastName: membership.lastName as string, + active: true + }); + }; + + const createScimUser = async ({ externalId, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + if (!email) throw new ScimRequestError({ detail: "Invalid request. Missing email.", status: 400 }); + + const org = await orgDAL.findById(orgId); + + if (!org) + throw new ScimRequestError({ + detail: "Organization not found", + status: 404 + }); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + + const userAlias = await userAliasDAL.findOne({ + externalId, + orgId, + aliasType: UserAliasType.SAML + }); + + const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { + let user: TUsers | undefined; + let orgMembership: TOrgMemberships; + if (userAlias) { + user = await userDAL.findById(userAlias.userId, tx); + orgMembership = await orgMembershipDAL.findOne( + { + userId: user.id, + orgId + }, + tx + ); + + if (!orgMembership) { + orgMembership = await orgMembershipDAL.create( + { + userId: userAlias.userId, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgMembershipDAL.updateById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } else { + if (serverCfg.trustSamlEmails) { + user = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); + } + + if (!user) { + const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); + user = await userDAL.create( + { + username: serverCfg.trustSamlEmails ? email : uniqueUsername, + email, + isEmailVerified: serverCfg.trustSamlEmails, + firstName, + lastName, + authMethods: [], + isGhost: false + }, + tx + ); + } + + await userAliasDAL.create( + { + userId: user.id, + aliasType: UserAliasType.SAML, + externalId, + emails: email ? [email] : [], + orgId + }, + tx + ); + + const [foundOrgMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }, + { tx } + ); + + orgMembership = foundOrgMembership; + + if (!orgMembership) { + orgMembership = await orgMembershipDAL.create( + { + userId: user.id, + inviteEmail: email, + orgId, + role: OrgMembershipRole.Member, + status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later + }, + tx + ); + // Only update the membership to Accepted if the user account is already completed. + } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { + orgMembership = await orgDAL.updateMembershipById( + orgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } + + return { user, orgMembership }; + }); + + if (email) { + await smtpService.sendMail({ + template: SmtpTemplates.ScimUserProvisioned, + subjectLine: "Infisical organization invitation", + recipients: [email], + substitutions: { + organizationName: org.name, + callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}` + } + }); + } + + return buildScimUser({ + orgMembershipId: createdOrgMembership.id, + username: externalId, + firstName: createdUser.firstName as string, + lastName: createdUser.lastName as string, + email: createdUser.email ?? "", + active: true + }); + }; + + const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }) + .catch(() => { + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + }); + + if (!membership) + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + if (!membership.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + let active = true; + + operations.forEach((operation) => { + if (operation.op.toLowerCase() === "replace") { + if (operation.path === "active" && operation.value === "False") { + // azure scim op format + active = false; + } else if (typeof operation.value === "object" && operation.value.active === false) { + // okta scim op format + active = false; + } + } + }); + + if (!active) { + await deleteOrgMembershipFn({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService + }); + } + + return buildScimUser({ + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, + email: membership.email, + firstName: membership.firstName as string, + lastName: membership.lastName as string, + active + }); + }; + + const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }) + .catch(() => { + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + }); + + if (!membership) + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + if (!membership.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + if (!active) { + await deleteOrgMembershipFn({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService + }); + } + + return buildScimUser({ + orgMembershipId: membership.id, + username: membership.externalId ?? membership.username, + email: membership.email, + firstName: membership.firstName as string, + lastName: membership.lastName as string, + active + }); + }; + + const deleteScimUser = async ({ orgMembershipId, orgId }: TDeleteScimUserDTO) => { + const [membership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + }); + + if (!membership) + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + if (!membership.scimEnabled) { + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + } + + await deleteOrgMembershipFn({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService + }); + + return {}; // intentionally return empty object upon success + }; + + const listScimGroups = async ({ orgId, startIndex, limit }: TListScimGroupsDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to list SCIM groups due to plan restriction. Upgrade plan to list SCIM groups." + }); + + const org = await orgDAL.findById(orgId); + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const groups = await groupDAL.findGroups( + { + orgId + }, + { + offset: startIndex - 1, + limit + } + ); + + const scimGroups = groups.map((group) => + buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] // does this need to be populated? + }) + ); + + return buildScimGroupList({ + scimGroups, + startIndex, + limit + }); + }; + + const createScimGroup = async ({ displayName, orgId, members }: TCreateScimGroupDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to create a SCIM group due to plan restriction. Upgrade plan to create a SCIM group." + }); + + const org = await orgDAL.findById(orgId); + + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const newGroup = await groupDAL.transaction(async (tx) => { + const group = await groupDAL.create( + { + name: displayName, + slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`), + orgId, + role: OrgMembershipRole.NoAccess + }, + tx + ); + + if (members && members.length) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + + const newMembers = await addUsersToGroupByUserIds({ + group, + userIds: orgMemberships.map((membership) => membership.userId as string), + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + + return { group, newMembers }; + } + + return { group, newMembers: [] }; + }); + + const orgMemberships = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: newGroup.newMembers.map((member) => member.id) + } + }); + + return buildScimGroup({ + groupId: newGroup.group.id, + name: newGroup.group.name, + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` + })) + }); + }; + + const getScimGroup = async ({ groupId, orgId }: TGetScimGroupDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to get SCIM group due to plan restriction. Upgrade plan to get SCIM group." + }); + + const group = await groupDAL.findOne({ + id: groupId, + orgId + }); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + const users = await groupDAL.findAllGroupMembers({ + orgId: group.orgId, + groupId: group.id + }); + + const orgMemberships = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + $in: { + [`${TableName.OrgMembership}.userId` as "userId"]: users + .filter((user) => user.isPartOfGroup) + .map((user) => user.id) + } + }); + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: orgMemberships.map(({ id, firstName, lastName }) => ({ + value: id, + display: `${firstName} ${lastName}` + })) + }); + }; + + const updateScimGroupNamePut = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group." + }); + + const org = await orgDAL.findById(orgId); + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const updatedGroup = await groupDAL.transaction(async (tx) => { + const [group] = await groupDAL.update( + { + id: groupId, + orgId + }, + { + name: displayName + } + ); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + if (members) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }); + + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); + + const directMemberUserIds = ( + await userGroupMembershipDAL.find({ + groupId: group.id, + isPending: false + }) + ).map((membership) => membership.userId); + + const pendingGroupAdditionsUserIds = ( + await userGroupMembershipDAL.find({ + groupId: group.id, + isPending: true + }) + ).map((pendingGroupAddition) => pendingGroupAddition.userId); + + const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); + const allMembersUserIdsSet = new Set(allMembersUserIds); + + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); + const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); + + if (toAddUserIds.length) { + await addUsersToGroupByUserIds({ + group, + userIds: toAddUserIds.map((member) => member.userId as string), + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + } + + if (toRemoveUserIds.length) { + await removeUsersFromGroupByUserIds({ + group, + userIds: toRemoveUserIds, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx + }); + } + } + + return group; + }); + + return buildScimGroup({ + groupId: updatedGroup.id, + name: updatedGroup.name, + members + }); + }; + + // TODO: add support for add/remove op + const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group." + }); + + const org = await orgDAL.findById(orgId); + + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + let group: TGroups | undefined; + for await (const operation of operations) { + switch (operation.op) { + case "replace": { + await groupDAL.update( + { + id: groupId, + orgId + }, + { + name: operation.value.displayName + } + ); + break; + } + case "add": { + // TODO + break; + } + case "remove": { + // TODO + break; + } + default: { + throw new ScimRequestError({ + detail: "Invalid Operation", + status: 400 + }); + } + } + } + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }); + }; + + const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to delete SCIM group due to plan restriction. Upgrade plan to delete SCIM group." + }); + + const org = await orgDAL.findById(orgId); + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const [group] = await groupDAL.delete({ + id: groupId, + orgId + }); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + return {}; // intentionally return empty object upon success + }; + + const fnValidateScimToken = async (token: TScimTokenJwtPayload) => { + const scimToken = await scimDAL.findById(token.scimTokenId); + if (!scimToken) throw new UnauthorizedError(); + + const { ttlDays, createdAt } = scimToken; + + // ttl check + if (Number(ttlDays) > 0) { + const currentDate = new Date(); + const scimTokenCreatedAt = new Date(createdAt); + const ttlInMilliseconds = Number(scimToken.ttlDays) * 86400 * 1000; + const expirationDate = new Date(scimTokenCreatedAt.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) + throw new ScimRequestError({ + detail: "The access token expired", + status: 401 + }); + } + + return { scimTokenId: scimToken.id, orgId: scimToken.orgId }; + }; + + return { + createScimToken, + listScimTokens, + deleteScimToken, + listScimUsers, + getScimUser, + createScimUser, + updateScimUser, + replaceScimUser, + deleteScimUser, + listScimGroups, + createScimGroup, + getScimGroup, + deleteScimGroup, + updateScimGroupNamePut, + updateScimGroupNamePatch, + fnValidateScimToken + }; +}; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts new file mode 100644 index 000000000..46ab90b8f --- /dev/null +++ b/backend/src/ee/services/scim/scim-types.ts @@ -0,0 +1,178 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TCreateScimTokenDTO = { + description: string; + ttlDays: number; +} & TOrgPermission; + +export type TDeleteScimTokenDTO = { + scimTokenId: string; +} & Omit; + +// SCIM server endpoint types + +export type TListScimUsersDTO = { + startIndex: number; + limit: number; + filter?: string; + orgId: string; +}; + +export type TListScimUsers = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]; + totalResults: number; + Resources: TScimUser[]; + itemsPerPage: number; + startIndex: number; +}; + +export type TGetScimUserDTO = { + orgMembershipId: string; + orgId: string; +}; + +export type TCreateScimUserDTO = { + externalId: string; + email?: string; + firstName: string; + lastName: string; + orgId: string; +}; + +export type TUpdateScimUserDTO = { + orgMembershipId: string; + orgId: string; + operations: { + op: string; + path?: string; + value?: + | string + | { + active: boolean; + }; + }[]; +}; + +export type TReplaceScimUserDTO = { + orgMembershipId: string; + active: boolean; + orgId: string; +}; + +export type TDeleteScimUserDTO = { + orgMembershipId: string; + orgId: string; +}; + +export type TListScimGroupsDTO = { + startIndex: number; + limit: number; + orgId: string; +}; + +export type TListScimGroups = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]; + totalResults: number; + Resources: TScimGroup[]; + itemsPerPage: number; + startIndex: number; +}; + +export type TCreateScimGroupDTO = { + displayName: string; + orgId: string; + members?: { + // TODO: account for members with value and display (is this optional?) + value: string; + display: string; + }[]; +}; + +export type TGetScimGroupDTO = { + groupId: string; + orgId: string; +}; + +export type TUpdateScimGroupNamePutDTO = { + groupId: string; + orgId: string; + displayName: string; + members: { + value: string; + display: string; + }[]; +}; + +export type TUpdateScimGroupNamePatchDTO = { + groupId: string; + orgId: string; + operations: (TRemoveOp | TReplaceOp | TAddOp)[]; +}; + +type TReplaceOp = { + op: "replace"; + value: { + id: string; + displayName: string; + }; +}; + +type TRemoveOp = { + op: "remove"; + path: string; +}; + +type TAddOp = { + op: "add"; + value: { + value: string; + display?: string; + }; +}; + +export type TDeleteScimGroupDTO = { + groupId: string; + orgId: string; +}; + +export type TScimTokenJwtPayload = { + scimTokenId: string; + authTokenType: string; +}; + +export type TScimUser = { + schemas: string[]; + id: string; + userName: string; + displayName: string; + name: { + givenName: string; + middleName: null; + familyName: string; + }; + emails: { + primary: boolean; + value: string; + type: string; + }[]; + active: boolean; + groups: string[]; + meta: { + resourceType: string; + location: null; + }; +}; + +export type TScimGroup = { + schemas: string[]; + id: string; + displayName: string; + members: { + value: string; + display: string; + }[]; + meta: { + resourceType: string; + location: null; + }; +}; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index b5688545c..8ddadb9bf 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -44,6 +44,8 @@ export const secretApprovalPolicyServiceFactory = ({ name, actor, actorId, + actorOrgId, + actorAuthMethod, approvals, approvers, projectId, @@ -53,7 +55,13 @@ export const secretApprovalPolicyServiceFactory = ({ if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval @@ -96,13 +104,21 @@ export const secretApprovalPolicyServiceFactory = ({ name, actorId, actor, + actorOrgId, + actorAuthMethod, approvals, secretPolicyId }: TUpdateSapDTO) => { const secretApprovalPolicy = await secretApprovalPolicyDAL.findById(secretPolicyId); if (!secretApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, secretApprovalPolicy.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + secretApprovalPolicy.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const updatedSap = await secretApprovalPolicyDAL.transaction(async (tx) => { @@ -145,11 +161,23 @@ export const secretApprovalPolicyServiceFactory = ({ }; }; - const deleteSecretApprovalPolicy = async ({ secretPolicyId, actor, actorId }: TDeleteSapDTO) => { + const deleteSecretApprovalPolicy = async ({ + secretPolicyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteSapDTO) => { const sapPolicy = await secretApprovalPolicyDAL.findById(secretPolicyId); if (!sapPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, sapPolicy.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + sapPolicy.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval @@ -159,8 +187,20 @@ export const secretApprovalPolicyServiceFactory = ({ return sapPolicy; }; - const getSecretApprovalPolicyByProjectId = async ({ actorId, actor, projectId }: TListSapDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getSecretApprovalPolicyByProjectId = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TListSapDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); const sapPolicies = await secretApprovalPolicyDAL.find({ projectId }); @@ -188,10 +228,18 @@ export const secretApprovalPolicyServiceFactory = ({ projectId, actor, actorId, + actorOrgId, + actorAuthMethod, environment, secretPath }: TGetBoardSapDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { secretPath, environment }) diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index 9b4742255..736cd253e 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -1,8 +1,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { SecretApprovalRequestsSecretsSchema, TableName, TSecretTags } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { + SecretApprovalRequestsSecretsSchema, + TableName, + TSecretApprovalRequestsSecrets, + TSecretTags +} from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TSecretApprovalRequestSecretDALFactory = ReturnType; @@ -11,6 +16,35 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { const secretApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret); const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag); + const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => { + try { + const existingApprovalSecrets = await secretApprovalRequestSecretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingApprovalSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secret approvals do not exist" }); + } + + if (data.length === 0) return []; + + const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedApprovalSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + const findByRequestId = async (requestId: string, tx?: Knex) => { try { const doc = await (tx || db)({ @@ -190,6 +224,7 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { return { ...secretApprovalRequestSecretOrm, findByRequestId, + bulkUpdateNoVersionIncrement, insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany }; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index d1ecd51ed..690d308d2 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -7,13 +7,19 @@ import { SecretType, TSecretApprovalRequestsSecretsInsert } from "@app/db/schemas"; +import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { groupBy, pick, unique } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "@app/services/auth/auth-type"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { getAllNestedSecretReferences } from "@app/services/secret/secret-fns"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; @@ -43,10 +49,14 @@ type TSecretApprovalRequestServiceFactoryDep = { secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory; secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory; folderDAL: Pick; - secretTagDAL: Pick; + secretDAL: TSecretDALFactory; + secretTagDAL: Pick; secretBlindIndexDAL: Pick; snapshotService: Pick; - secretVersionDAL: Pick; + secretVersionDAL: Pick; + secretVersionTagDAL: Pick; + projectDAL: Pick; + projectBotService: Pick; secretService: Pick< TSecretServiceFactory, | "fnSecretBulkInsert" @@ -62,21 +72,31 @@ export type TSecretApprovalRequestServiceFactory = ReturnType { - const requestCount = async ({ projectId, actor, actorId }: TApprovalRequestCountDTO) => { + const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); - const { membership } = await permissionService.getProjectPermission(actor as ActorType.USER, actorId, projectId); + const { membership } = await permissionService.getProjectPermission( + actor as ActorType.USER, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); const count = await secretApprovalRequestDAL.findProjectRequestCount(projectId, membership.id); return count; @@ -86,6 +106,8 @@ export const secretApprovalRequestServiceFactory = ({ projectId, actorId, actor, + actorAuthMethod, + actorOrgId, status, environment, committer, @@ -94,7 +116,13 @@ export const secretApprovalRequestServiceFactory = ({ }: TListApprovalsDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { membership } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); const approvals = await secretApprovalRequestDAL.findByProjectId({ projectId, committer, @@ -107,20 +135,28 @@ export const secretApprovalRequestServiceFactory = ({ return approvals; }; - const getSecretApprovalDetails = async ({ actor, actorId, id }: TSecretApprovalDetailsDTO) => { + const getSecretApprovalDetails = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + id + }: TSecretApprovalDetailsDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, - secretApprovalRequest.projectId + secretApprovalRequest.projectId, + actorAuthMethod, + actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -134,19 +170,28 @@ export const secretApprovalRequestServiceFactory = ({ return { ...secretApprovalRequest, secretPath: secretPath?.[0]?.path || "/", commits: secrets }; }; - const reviewApproval = async ({ approvalId, actor, status, actorId }: TReviewRequestDTO) => { + const reviewApproval = async ({ + approvalId, + actor, + status, + actorId, + actorAuthMethod, + actorOrgId + }: TReviewRequestDTO) => { const secretApprovalRequest = await secretApprovalRequestDAL.findById(approvalId); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, - secretApprovalRequest.projectId + secretApprovalRequest.projectId, + actorAuthMethod, + actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -175,19 +220,28 @@ export const secretApprovalRequestServiceFactory = ({ return reviewStatus; }; - const updateApprovalStatus = async ({ actorId, status, approvalId, actor }: TStatusChangeDTO) => { + const updateApprovalStatus = async ({ + actorId, + status, + approvalId, + actor, + actorOrgId, + actorAuthMethod + }: TStatusChangeDTO) => { const secretApprovalRequest = await secretApprovalRequestDAL.findById(approvalId); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission( + const { membership, hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, - secretApprovalRequest.projectId + secretApprovalRequest.projectId, + actorAuthMethod, + actorOrgId ); if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -207,15 +261,28 @@ export const secretApprovalRequestServiceFactory = ({ return { ...secretApprovalRequest, ...updatedRequest }; }; - const mergeSecretApprovalRequest = async ({ approvalId, actor, actorId }: TMergeSecretApprovalRequestDTO) => { + const mergeSecretApprovalRequest = async ({ + approvalId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TMergeSecretApprovalRequestDTO) => { const secretApprovalRequest = await secretApprovalRequestDAL.findById(approvalId); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); const { policy, folderId, projectId } = secretApprovalRequest; - const { membership } = await permissionService.getProjectPermission(ActorType.USER, actorId, projectId); + const { membership, hasRole } = await permissionService.getProjectPermission( + ActorType.USER, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + if ( - membership.role !== ProjectMembershipRole.Admin && + !hasRole(ProjectMembershipRole.Admin) && secretApprovalRequest.committerId !== membership.id && !policy.approvers.find((approverId) => approverId === membership.id) ) { @@ -290,7 +357,7 @@ export const secretApprovalRequestServiceFactory = ({ } const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Delete); - + const botKey = await projectBotService.getBotKey(projectId).catch(() => null); const mergeStatus = await secretApprovalRequestDAL.transaction(async (tx) => { const newSecrets = secretCreationCommits.length ? await secretService.fnSecretBulkInsert({ @@ -317,8 +384,22 @@ export const secretApprovalRequestServiceFactory = ({ ]), tags: el?.tags.map(({ id }) => id), version: 1, - type: SecretType.Shared - })) + type: SecretType.Shared, + references: botKey + ? getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag, + key: botKey + }) + ) + : undefined + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) : []; const updatedSecrets = secretUpdationCommits.length @@ -348,9 +429,23 @@ export const secretApprovalRequestServiceFactory = ({ "secretReminderNote", "secretReminderRepeatDays", "secretBlindIndex" - ]) + ]), + references: botKey + ? getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag, + key: botKey + }) + ) + : undefined } - })) + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) : []; const deletedSecret = secretDeletionCommits.length @@ -401,6 +496,8 @@ export const secretApprovalRequestServiceFactory = ({ data, actorId, actor, + actorOrgId, + actorAuthMethod, policy, projectId, secretPath, @@ -408,14 +505,26 @@ export const secretApprovalRequestServiceFactory = ({ }: TGenerateSecretApprovalRequestDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); - const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission, membership } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "GenSecretApproval" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -430,7 +539,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: createdSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); commits.push( @@ -457,7 +567,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: updatedSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); // now find any secret that needs to update its name @@ -467,7 +578,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: nameUpdatedSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secsGroupedByBlindIndex = groupBy(secretsToBeUpdated, (el) => el.secretBlindIndex as string); @@ -506,7 +618,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: deletedSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secretsGroupedByBlindIndex = groupBy(secrets, (i) => { if (!i.secretBlindIndex) throw new BadRequestError({ message: "Missing secret blind index" }); diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts index c67477bfd..93f63a685 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts @@ -9,6 +9,7 @@ import jmespath from "jmespath"; import knex from "knex"; import { getConfig } from "@app/lib/config/env"; +import { getDbConnectionHost } from "@app/lib/knex"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TAssignOp, TDbProviderClients, TDirectAssignOp, THttpProviderFunction } from "../templates/types"; @@ -89,7 +90,21 @@ export const secretRotationDbFn = async ({ const appCfg = getConfig(); const ssl = ca ? { rejectUnauthorized: false, ca } : undefined; - if (host === "localhost" || host === "127.0.0.1" || appCfg.DB_CONNECTION_URI.includes(host)) + const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not + const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + if ( + isCloud && + // internal ips + (host === "host.docker.internal" || host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new Error("Invalid db host"); + if ( + host === "localhost" || + host === "127.0.0.1" || + // database infisical uses + dbHost === host + ) throw new Error("Invalid db host"); const db = knex({ diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index 9e69f0a8f..140a9b671 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -1,3 +1,10 @@ +import { + CreateAccessKeyCommand, + DeleteAccessKeyCommand, + GetAccessKeyLastUsedCommand, + IAMClient +} from "@aws-sdk/client-iam"; + import { SecretKeyEncoding, SecretType } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { @@ -18,7 +25,12 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { TSecretRotationDALFactory } from "../secret-rotation-dal"; import { rotationTemplates } from "../templates"; -import { TDbProviderClients, TProviderFunctionTypes, TSecretRotationProviderTemplate } from "../templates/types"; +import { + TAwsProviderSystems, + TDbProviderClients, + TProviderFunctionTypes, + TSecretRotationProviderTemplate +} from "../templates/types"; import { getDbSetQuery, secretRotationDbFn, @@ -127,7 +139,10 @@ export const secretRotationQueueFactory = ({ internal: {} }; - // when its a database we keep cycling the variables accordingly + /* Rotation Function For Database + * A database like sql cannot have multiple password for a user + * thus we ask users to create two users with required permission and then we keep cycling between these two db users + */ if (provider.template.type === TProviderFunctionTypes.DB) { const lastCred = variables.creds.at(-1); if (lastCred && variables.creds.length === 1) { @@ -170,6 +185,65 @@ export const secretRotationQueueFactory = ({ if (variables.creds.length === 2) variables.creds.pop(); } + /* + * Rotation Function For AWS Services + * Due to complexity in AWS Authorization hashing signature process we keep it as seperate entity instead of http template mode + * We first delete old key before creating a new one because aws iam has a quota limit of 2 keys + * */ + if (provider.template.type === TProviderFunctionTypes.AWS) { + if (provider.template.client === TAwsProviderSystems.IAM) { + const client = new IAMClient({ + region: newCredential.inputs.manager_user_aws_region as string, + credentials: { + accessKeyId: newCredential.inputs.manager_user_access_key as string, + secretAccessKey: newCredential.inputs.manager_user_secret_key as string + } + }); + + const iamUserName = newCredential.inputs.iam_username as string; + + if (variables.creds.length === 2) { + const deleteCycleCredential = variables.creds.pop(); + if (deleteCycleCredential) { + const deletedIamAccessKey = await client.send( + new DeleteAccessKeyCommand({ + UserName: iamUserName, + AccessKeyId: deleteCycleCredential.outputs.iam_user_access_key as string + }) + ); + + if ( + !deletedIamAccessKey?.$metadata?.httpStatusCode || + deletedIamAccessKey?.$metadata?.httpStatusCode > 300 + ) { + throw new DisableRotationErrors({ + message: "Failed to delete aws iam access key. Check managed iam user policy" + }); + } + } + } + + const newIamAccessKey = await client.send(new CreateAccessKeyCommand({ UserName: iamUserName })); + if (!newIamAccessKey.AccessKey) + throw new DisableRotationErrors({ message: "Failed to create access key. Check managed iam user policy" }); + + // test + const testAccessKey = await client.send( + new GetAccessKeyLastUsedCommand({ AccessKeyId: newIamAccessKey.AccessKey.AccessKeyId }) + ); + if (testAccessKey?.UserName !== iamUserName) + throw new DisableRotationErrors({ message: "Failed to create access key. Check managed iam user policy" }); + + newCredential.outputs.iam_user_access_key = newIamAccessKey.AccessKey.AccessKeyId; + newCredential.outputs.iam_user_secret_key = newIamAccessKey.AccessKey.SecretAccessKey; + } + } + + /* Rotation function of HTTP infisical template + * This is a generic http based template system for rotation + * we use this for sendgrid and for custom secret rotation + * This will ensure user provided rotation is easier to make + * */ if (provider.template.type === TProviderFunctionTypes.HTTP) { if (provider.template.functions.set?.pre) { secretRotationPreSetFn(provider.template.functions.set.pre, newCredential); @@ -185,6 +259,9 @@ export const secretRotationQueueFactory = ({ } } } + + // insert the new variables to start + // encrypt the data - save it variables.creds.unshift({ outputs: newCredential.outputs, internal: newCredential.internal @@ -200,6 +277,7 @@ export const secretRotationQueueFactory = ({ key ) })); + // map the final values to output keys in the board await secretRotationDAL.transaction(async (tx) => { await secretRotationDAL.updateById( rotationId, @@ -240,7 +318,7 @@ export const secretRotationQueueFactory = ({ ); }); - telemetryService.sendPostHogEvents({ + await telemetryService.sendPostHogEvents({ event: PostHogEventTypes.SecretRotated, distinctId: "", properties: { diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts index e10d7fa63..1e1648a66 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -14,13 +14,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/pr import { TSecretRotationDALFactory } from "./secret-rotation-dal"; import { TSecretRotationQueueFactory } from "./secret-rotation-queue"; import { TSecretRotationEncData } from "./secret-rotation-queue/secret-rotation-queue-types"; -import { - TCreateSecretRotationDTO, - TDeleteDTO, - TGetByIdDTO, - TListByProjectIdDTO, - TRestartDTO -} from "./secret-rotation-types"; +import { TCreateSecretRotationDTO, TDeleteDTO, TListByProjectIdDTO, TRestartDTO } from "./secret-rotation-types"; import { rotationTemplates } from "./templates"; type TSecretRotationServiceFactoryDep = { @@ -45,8 +39,20 @@ export const secretRotationServiceFactory = ({ folderDAL, secretDAL }: TSecretRotationServiceFactoryDep) => { - const getProviderTemplates = async ({ actor, actorId, projectId }: TProjectPermission) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getProviderTemplates = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); return { @@ -59,6 +65,8 @@ export const secretRotationServiceFactory = ({ projectId, actorId, actor, + actorOrgId, + actorAuthMethod, inputs, outputs, interval, @@ -66,7 +74,13 @@ export const secretRotationServiceFactory = ({ secretPath, environment }: TCreateSecretRotationDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretRotation @@ -144,23 +158,20 @@ export const secretRotationServiceFactory = ({ return secretRotation; }; - const getById = async ({ rotationId, actor, actorId }: TGetByIdDTO) => { - const [doc] = await secretRotationDAL.find({ id: rotationId }); - if (!doc) throw new BadRequestError({ message: "Rotation not found" }); - - const { permission } = await permissionService.getProjectPermission(actor, actorId, doc.projectId); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); - return doc; - }; - - const getByProjectId = async ({ actorId, projectId, actor }: TListByProjectIdDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getByProjectId = async ({ actorId, projectId, actor, actorOrgId, actorAuthMethod }: TListByProjectIdDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); const doc = await secretRotationDAL.find({ projectId }); return doc; }; - const restartById = async ({ actor, actorId, rotationId }: TRestartDTO) => { + const restartById = async ({ actor, actorId, actorOrgId, actorAuthMethod, rotationId }: TRestartDTO) => { const doc = await secretRotationDAL.findById(rotationId); if (!doc) throw new BadRequestError({ message: "Rotation not found" }); @@ -171,18 +182,30 @@ export const secretRotationServiceFactory = ({ message: "Failed to add secret rotation due to plan restriction. Upgrade plan to add secret rotation." }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, doc.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + doc.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation); await secretRotationQueue.removeFromQueue(doc.id, doc.interval); await secretRotationQueue.addToQueue(doc.id, doc.interval); return doc; }; - const deleteById = async ({ actor, actorId, rotationId }: TDeleteDTO) => { + const deleteById = async ({ actor, actorId, actorOrgId, actorAuthMethod, rotationId }: TDeleteDTO) => { const doc = await secretRotationDAL.findById(rotationId); if (!doc) throw new BadRequestError({ message: "Rotation not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, doc.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + doc.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, ProjectPermissionSub.SecretRotation @@ -197,7 +220,6 @@ export const secretRotationServiceFactory = ({ return { getProviderTemplates, - getById, getByProjectId, createRotation, restartById, diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-types.ts b/backend/src/ee/services/secret-rotation/secret-rotation-types.ts index 52d248765..990bf3eca 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-types.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-types.ts @@ -18,7 +18,3 @@ export type TDeleteDTO = { export type TRestartDTO = { rotationId: string; } & Omit; - -export type TGetByIdDTO = { - rotationId: string; -} & Omit; diff --git a/backend/src/ee/services/secret-rotation/templates/aws-iam.ts b/backend/src/ee/services/secret-rotation/templates/aws-iam.ts new file mode 100644 index 000000000..d4506c26e --- /dev/null +++ b/backend/src/ee/services/secret-rotation/templates/aws-iam.ts @@ -0,0 +1,21 @@ +import { TAwsProviderSystems, TProviderFunctionTypes } from "./types"; + +export const AWS_IAM_TEMPLATE = { + type: TProviderFunctionTypes.AWS as const, + client: TAwsProviderSystems.IAM, + inputs: { + type: "object" as const, + properties: { + manager_user_access_key: { type: "string" as const }, + manager_user_secret_key: { type: "string" as const }, + manager_user_aws_region: { type: "string" as const }, + iam_username: { type: "string" as const } + }, + required: ["manager_user_access_key", "manager_user_secret_key", "manager_user_aws_region", "iam_username"], + additionalProperties: false + }, + outputs: { + iam_user_access_key: { type: "string" }, + iam_user_secret_key: { type: "string" } + } +}; diff --git a/backend/src/ee/services/secret-rotation/templates/index.ts b/backend/src/ee/services/secret-rotation/templates/index.ts index 3d9fb2298..05811d5bd 100644 --- a/backend/src/ee/services/secret-rotation/templates/index.ts +++ b/backend/src/ee/services/secret-rotation/templates/index.ts @@ -1,3 +1,4 @@ +import { AWS_IAM_TEMPLATE } from "./aws-iam"; import { MYSQL_TEMPLATE } from "./mysql"; import { POSTGRES_TEMPLATE } from "./postgres"; import { SENDGRID_TEMPLATE } from "./sendgrid"; @@ -24,5 +25,12 @@ export const rotationTemplates: TSecretRotationProviderTemplate[] = [ image: "mysql.png", description: "Rotate MySQL@7/MariaDB user credentials", template: MYSQL_TEMPLATE + }, + { + name: "aws-iam", + title: "AWS IAM", + image: "aws-iam.svg", + description: "Rotate AWS IAM User credentials", + template: AWS_IAM_TEMPLATE } ]; diff --git a/backend/src/ee/services/secret-rotation/templates/types.ts b/backend/src/ee/services/secret-rotation/templates/types.ts index cb48a7782..690b6ccf0 100644 --- a/backend/src/ee/services/secret-rotation/templates/types.ts +++ b/backend/src/ee/services/secret-rotation/templates/types.ts @@ -1,6 +1,7 @@ export enum TProviderFunctionTypes { HTTP = "http", - DB = "database" + DB = "database", + AWS = "aws" } export enum TDbProviderClients { @@ -10,6 +11,10 @@ export enum TDbProviderClients { MySql = "mysql" } +export enum TAwsProviderSystems { + IAM = "iam" +} + export enum TAssignOp { Direct = "direct", JmesPath = "jmesopath" @@ -42,7 +47,7 @@ export type TSecretRotationProviderTemplate = { title: string; image?: string; description?: string; - template: THttpProviderTemplate | TDbProviderTemplate; + template: THttpProviderTemplate | TDbProviderTemplate | TAwsProviderTemplate; }; export type THttpProviderTemplate = { @@ -70,3 +75,14 @@ export type TDbProviderTemplate = { }; outputs: Record; }; + +export type TAwsProviderTemplate = { + type: TProviderFunctionTypes.AWS; + client: TAwsProviderSystems; + inputs: { + type: "object"; + properties: Record; + required?: string[]; + }; + outputs: Record; +}; diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts index aab8d1218..1b19fd7f5 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts @@ -64,7 +64,7 @@ export const secretScanningQueueFactory = ({ orgId: organizationId, role: OrgMembershipRole.Admin }); - return adminsOfWork.map((userObject) => userObject.email); + return adminsOfWork.filter((userObject) => userObject.email).map((userObject) => userObject.email as string); }; queueService.start(QueueName.SecretPushEventScan, async (job) => { @@ -149,7 +149,7 @@ export const secretScanningQueueFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SecretLeakIncident, subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, - recipients: adminEmails, + recipients: adminEmails.filter((email) => email).map((email) => email), substitutions: { numberOfSecrets: Object.keys(allFindingsByFingerprint).length, pusher_email: pusher.email, @@ -158,7 +158,7 @@ export const secretScanningQueueFactory = ({ }); } - telemetryService.sendPostHogEvents({ + await telemetryService.sendPostHogEvents({ event: PostHogEventTypes.SecretScannerPush, distinctId: repository.fullName, properties: { @@ -221,14 +221,14 @@ export const secretScanningQueueFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SecretLeakIncident, subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, - recipients: adminEmails, + recipients: adminEmails.filter((email) => email).map((email) => email), substitutions: { numberOfSecrets: findings.length } }); } - telemetryService.sendPostHogEvents({ + await telemetryService.sendPostHogEvents({ event: PostHogEventTypes.SecretScannerFull, distinctId: repository.fullName, properties: { diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts index e150f30f3..ef511deb8 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts @@ -39,8 +39,14 @@ export const secretScanningServiceFactory = ({ permissionService, secretScanningQueue }: TSecretScanningServiceFactoryDep) => { - const createInstallationSession = async ({ actor, orgId, actorId }: TInstallAppSessionDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const createInstallationSession = async ({ + actor, + orgId, + actorId, + actorAuthMethod, + actorOrgId + }: TInstallAppSessionDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const sessionId = crypto.randomBytes(16).toString("hex"); @@ -48,11 +54,24 @@ export const secretScanningServiceFactory = ({ return { sessionId }; }; - const linkInstallationToOrg = async ({ sessionId, actorId, installationId, actor }: TLinkInstallSessionDTO) => { + const linkInstallationToOrg = async ({ + sessionId, + actorId, + installationId, + actor, + actorAuthMethod, + actorOrgId + }: TLinkInstallSessionDTO) => { const session = await gitAppInstallSessionDAL.findOne({ sessionId }); if (!session) throw new UnauthorizedError({ message: "Session not found" }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, session.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + session.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const installatedApp = await gitAppOrgDAL.transaction(async (tx) => { await gitAppInstallSessionDAL.deleteById(session.id, tx); @@ -71,35 +90,51 @@ export const secretScanningServiceFactory = ({ const { data: { repositories } } = await octokit.apps.listReposAccessibleToInstallation(); - await Promise.all( - repositories.map(({ id, full_name }) => - secretScanningQueue.startFullRepoScan({ - organizationId: session.orgId, - installationId, - repository: { id, fullName: full_name } - }) - ) - ); + if (!appCfg.DISABLE_SECRET_SCANNING) { + await Promise.all( + repositories.map(({ id, full_name }) => + secretScanningQueue.startFullRepoScan({ + organizationId: session.orgId, + installationId, + repository: { id, fullName: full_name } + }) + ) + ); + } return { installatedApp }; }; - const getOrgInstallationStatus = async ({ actorId, orgId, actor }: TGetOrgInstallStatusDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgInstallationStatus = async ({ + actorId, + orgId, + actor, + actorAuthMethod, + actorOrgId + }: TGetOrgInstallStatusDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const appInstallation = await gitAppOrgDAL.findOne({ orgId }); return Boolean(appInstallation); }; - const getRisksByOrg = async ({ actor, orgId, actorId }: TGetOrgRisksDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetOrgRisksDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const risks = await secretScanningDAL.find({ orgId }, { sort: [["createdAt", "desc"]] }); return { risks }; }; - const updateRiskStatus = async ({ actorId, orgId, actor, riskId, status }: TUpdateRiskStatusDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const updateRiskStatus = async ({ + actorId, + orgId, + actor, + actorOrgId, + actorAuthMethod, + riskId, + status + }: TUpdateRiskStatusDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); const isRiskResolved = Boolean( @@ -118,6 +153,7 @@ export const secretScanningServiceFactory = ({ }; const handleRepoPushEvent = async (payload: WebhookEventMap["push"]) => { + const appCfg = getConfig(); const { commits, repository, installation, pusher } = payload; if (!commits || !repository || !installation || !pusher) { return; @@ -128,13 +164,15 @@ export const secretScanningServiceFactory = ({ }); if (!installationLink) return; - await secretScanningQueue.startPushEventScan({ - commits, - pusher: { name: pusher.name, email: pusher.email }, - repository: { fullName: repository.full_name, id: repository.id }, - organizationId: installationLink.orgId, - installationId: String(installation?.id) - }); + if (!appCfg.DISABLE_SECRET_SCANNING) { + await secretScanningQueue.startPushEventScan({ + commits, + pusher: { name: pusher.name, email: pusher.email }, + repository: { fullName: repository.full_name, id: repository.id }, + organizationId: installationLink.orgId, + installationId: String(installation?.id) + }); + } }; const handleRepoDeleteEvent = async (installationId: string, repositoryIds: string[]) => { diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 26148958f..0e71ad126 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { TableName, TSecretTagJunctionInsert } from "@app/db/schemas"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; @@ -23,6 +23,7 @@ import { import { TSnapshotDALFactory } from "./snapshot-dal"; import { TSnapshotFolderDALFactory } from "./snapshot-folder-dal"; import { TSnapshotSecretDALFactory } from "./snapshot-secret-dal"; +import { getFullFolderPath } from "./snapshot-service-fns"; type TSecretSnapshotServiceFactoryDep = { snapshotDAL: TSnapshotDALFactory; @@ -33,7 +34,7 @@ type TSecretSnapshotServiceFactoryDep = { secretDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; - folderDAL: Pick; + folderDAL: Pick; permissionService: Pick; licenseService: Pick; }; @@ -58,11 +59,25 @@ export const secretSnapshotServiceFactory = ({ projectId, actorId, actor, + actorOrgId, + actorAuthMethod, path }: TProjectSnapshotCountDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + // We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder. + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found" }); @@ -75,13 +90,27 @@ export const secretSnapshotServiceFactory = ({ projectId, actorId, actor, + actorOrgId, + actorAuthMethod, path, limit = 20, offset = 0 }: TProjectSnapshotListDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + // We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder. + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found" }); @@ -89,11 +118,30 @@ export const secretSnapshotServiceFactory = ({ return snapshots; }; - const getSnapshotData = async ({ actorId, actor, id }: TGetSnapshotDataDTO) => { + const getSnapshotData = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TGetSnapshotDataDTO) => { const snapshot = await snapshotDAL.findSecretSnapshotDataById(id); if (!snapshot) throw new BadRequestError({ message: "Snapshot not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, snapshot.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + snapshot.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + + const fullFolderPath = await getFullFolderPath({ + folderDAL, + folderId: snapshot.folderId, + envId: snapshot.environment.id + }); + + // We need to check if the user has access to the secrets in the folder. If we don't do this, a user could theoretically access snapshot secret values even if they don't have read access to the secrets in the folder. + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: snapshot.environment.slug, secretPath: fullFolderPath }) + ); + return snapshot; }; @@ -143,11 +191,23 @@ export const secretSnapshotServiceFactory = ({ } }; - const rollbackSnapshot = async ({ id: snapshotId, actor, actorId }: TRollbackSnapshotDTO) => { + const rollbackSnapshot = async ({ + id: snapshotId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRollbackSnapshotDTO) => { const snapshot = await snapshotDAL.findById(snapshotId); if (!snapshot) throw new BadRequestError({ message: "Snapshot not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, snapshot.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + snapshot.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index 41524c6eb..cdd5a999b 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -101,6 +101,7 @@ export const snapshotDALFactory = (db: TDbClient) => { key: "snapshotId", parentMapper: ({ snapshotId: id, + folderId, projectId, envId, envSlug, @@ -109,6 +110,7 @@ export const snapshotDALFactory = (db: TDbClient) => { snapshotUpdatedAt: updatedAt }) => ({ id, + folderId, projectId, createdAt, updatedAt, diff --git a/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts b/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts new file mode 100644 index 000000000..51cb9c056 --- /dev/null +++ b/backend/src/ee/services/secret-snapshot/snapshot-service-fns.ts @@ -0,0 +1,28 @@ +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; + +type GetFullFolderPath = { + folderDAL: Pick; // Added findAllInEnv + folderId: string; + envId: string; +}; + +export const getFullFolderPath = async ({ folderDAL, folderId, envId }: GetFullFolderPath): Promise => { + // Helper function to remove duplicate slashes + const removeDuplicateSlashes = (path: string) => path.replace(/\/{2,}/g, "/"); + + // Fetch all folders at once based on environment ID to avoid multiple queries + const folders = await folderDAL.find({ envId }); + const folderMap = new Map(folders.map((folder) => [folder.id, folder])); + + const buildPath = (currFolderId: string): string => { + const folder = folderMap.get(currFolderId); + if (!folder) return ""; + const folderPathSegment = !folder.parentId && folder.name === "root" ? "/" : `/${folder.name}`; + if (folder.parentId) { + return removeDuplicateSlashes(`${buildPath(folder.parentId)}${folderPathSegment}`); + } + return removeDuplicateSlashes(folderPathSegment); + }; + + return buildPath(folderId); +}; diff --git a/backend/src/ee/services/trusted-ip/trusted-ip-service.ts b/backend/src/ee/services/trusted-ip/trusted-ip-service.ts index a443a32f0..ecd2b3070 100644 --- a/backend/src/ee/services/trusted-ip/trusted-ip-service.ts +++ b/backend/src/ee/services/trusted-ip/trusted-ip-service.ts @@ -26,8 +26,14 @@ export const trustedIpServiceFactory = ({ licenseService, projectDAL }: TTrustedIpServiceFactoryDep) => { - const listIpsByProjectId = async ({ projectId, actor, actorId }: TProjectPermission) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const listIpsByProjectId = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); const trustedIps = await trustedIpDAL.find({ projectId @@ -35,8 +41,23 @@ export const trustedIpServiceFactory = ({ return trustedIps; }; - const addProjectIp = async ({ projectId, actorId, actor, ipAddress: ip, comment, isActive }: TCreateIpDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const addProjectIp = async ({ + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + ipAddress: ip, + comment, + isActive + }: TCreateIpDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.IpAllowList); const project = await projectDAL.findById(projectId); @@ -65,8 +86,23 @@ export const trustedIpServiceFactory = ({ return { trustedIp, project }; // for audit log }; - const updateProjectIp = async ({ projectId, actorId, actor, ipAddress: ip, comment, trustedIpId }: TUpdateIpDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const updateProjectIp = async ({ + projectId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + ipAddress: ip, + comment, + trustedIpId + }: TUpdateIpDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.IpAllowList); const project = await projectDAL.findById(projectId); @@ -97,8 +133,21 @@ export const trustedIpServiceFactory = ({ return { trustedIp, project }; // for audit log }; - const deleteProjectIp = async ({ projectId, actorId, actor, trustedIpId }: TDeleteIpDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteProjectIp = async ({ + projectId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + trustedIpId + }: TDeleteIpDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.IpAllowList); const project = await projectDAL.findById(projectId); diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts new file mode 100644 index 000000000..5e2c3aab3 --- /dev/null +++ b/backend/src/keystore/keystore.ts @@ -0,0 +1,20 @@ +import { Redis } from "ioredis"; + +export type TKeyStoreFactory = ReturnType; + +export const keyStoreFactory = (redisUrl: string) => { + const redis = new Redis(redisUrl); + + const setItem = async (key: string, value: string | number | Buffer) => redis.set(key, value); + + const getItem = async (key: string) => redis.get(key); + + const setItemWithExpiry = async (key: string, exp: number | string, value: string | number | Buffer) => + redis.setex(key, exp, value); + + const deleteItem = async (key: string) => redis.del(key); + + const incrementBy = async (key: string, value: number) => redis.incrby(key, value); + + return { setItem, getItem, setItemWithExpiry, deleteItem, incrementBy }; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts new file mode 100644 index 000000000..6ae5a9d33 --- /dev/null +++ b/backend/src/lib/api-docs/constants.ts @@ -0,0 +1,717 @@ +export const GROUPS = { + CREATE: { + name: "The name of the group to create.", + slug: "The slug of the group to create.", + role: "The role of the group to create." + }, + UPDATE: { + currentSlug: "The current slug of the group to update.", + name: "The new name of the group to update to.", + slug: "The new slug of the group to update to.", + role: "The new role of the group to update to." + }, + DELETE: { + slug: "The slug of the group to delete" + }, + LIST_USERS: { + slug: "The slug of the group to list users for", + offset: "The offset to start from. If you enter 10, it will start from the 10th user.", + limit: "The number of users to return.", + username: "The username to search for." + }, + ADD_USER: { + slug: "The slug of the group to add the user to.", + username: "The username of the user to add to the group." + }, + DELETE_USER: { + slug: "The slug of the group to remove the user from.", + username: "The username of the user to remove from the group." + } +} as const; + +export const IDENTITIES = { + CREATE: { + name: "The name of the identity to create.", + organizationId: "The organization ID to which the identity belongs.", + role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'." + }, + UPDATE: { + identityId: "The ID of the identity to update.", + name: "The new name of the identity.", + role: "The new role of the identity." + }, + DELETE: { + identityId: "The ID of the identity to delete." + } +} as const; + +export const UNIVERSAL_AUTH = { + LOGIN: { + clientId: "Your Machine Identity Client ID.", + clientSecret: "Your Machine Identity Client Secret." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + clientSecretTrustedIps: + "A list of IPs or CIDR ranges that the Client Secret can be used from together with the Client ID to get back an access token. You can use 0.0.0.0/0, to allow usage from any network address.", + accessTokenTrustedIps: + "A list of IPs or CIDR ranges that access tokens can be used from. You can use 0.0.0.0/0, to allow usage from any network address.", + accessTokenTTL: "The lifetime for an access token in seconds. This value will be referenced at renewal time.", + accessTokenMaxTTL: + "The maximum lifetime for an access token in seconds. This value will be referenced at renewal time.", + accessTokenNumUsesLimit: + "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve." + }, + UPDATE: { + identityId: "The ID of the identity to update.", + clientSecretTrustedIps: "The new list of IPs or CIDR ranges that the Client Secret can be used from.", + accessTokenTrustedIps: "The new list of IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." + }, + CREATE_CLIENT_SECRET: { + identityId: "The ID of the identity to create a client secret for.", + description: "The description of the client secret.", + numUsesLimit: + "The maximum number of times that the client secret can be used; a value of 0 implies infinite number of uses.", + ttl: "The lifetime for the client secret in seconds." + }, + LIST_CLIENT_SECRETS: { + identityId: "The ID of the identity to list client secrets for." + }, + REVOKE_CLIENT_SECRET: { + identityId: "The ID of the identity to revoke the client secret from.", + clientSecretId: "The ID of the client secret to revoke." + }, + RENEW_ACCESS_TOKEN: { + accessToken: "The access token to renew." + }, + REVOKE_ACCESS_TOKEN: { + accessToken: "The access token to revoke." + } +} as const; + +export const AWS_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login.", + iamHttpRequestMethod: "The HTTP request method used in the signed request.", + iamRequestUrl: + "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/", + iamRequestBody: + "The base64-encoded body of the signed request. Most likely, the base64-encoding of Action=GetCallerIdentity&Version=2011-06-15.", + iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." + } +} as const; + +export const ORGANIZATIONS = { + LIST_USER_MEMBERSHIPS: { + organizationId: "The ID of the organization to get memberships from." + }, + UPDATE_USER_MEMBERSHIP: { + organizationId: "The ID of the organization to update the membership for.", + membershipId: "The ID of the membership to update.", + role: "The new role of the membership." + }, + DELETE_USER_MEMBERSHIP: { + organizationId: "The ID of the organization to delete the membership from.", + membershipId: "The ID of the membership to delete." + }, + LIST_IDENTITY_MEMBERSHIPS: { + orgId: "The ID of the organization to get identity memberships from." + }, + GET_PROJECTS: { + organizationId: "The ID of the organization to get projects from." + }, + LIST_GROUPS: { + organizationId: "The ID of the organization to list groups for." + } +} as const; + +export const PROJECTS = { + CREATE: { + organizationSlug: "The slug of the organization to create the project in.", + projectName: "The name of the project to create.", + slug: "An optional slug for the project." + }, + DELETE: { + workspaceId: "The ID of the project to delete." + }, + GET: { + workspaceId: "The ID of the project." + }, + UPDATE: { + workspaceId: "The ID of the project to update.", + name: "The new name of the project.", + autoCapitalization: "Disable or enable auto-capitalization for the project." + }, + GET_KEY: { + workspaceId: "The ID of the project to get the key from." + }, + GET_SNAPSHOTS: { + workspaceId: "The ID of the project to get snapshots from.", + environment: "The environment to get snapshots from.", + path: "The secret path to get snapshots from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th snapshot.", + limit: "The number of snapshots to return." + }, + ROLLBACK_TO_SNAPSHOT: { + secretSnapshotId: "The ID of the snapshot to rollback to." + }, + ADD_GROUP_TO_PROJECT: { + projectSlug: "The slug of the project to add the group to.", + groupSlug: "The slug of the group to add to the project.", + role: "The role for the group to assume in the project." + }, + UPDATE_GROUP_IN_PROJECT: { + projectSlug: "The slug of the project to update the group in.", + groupSlug: "The slug of the group to update in the project.", + roles: "A list of roles to update the group to." + }, + REMOVE_GROUP_FROM_PROJECT: { + projectSlug: "The slug of the project to delete the group from.", + groupSlug: "The slug of the group to delete from the project." + }, + LIST_GROUPS_IN_PROJECT: { + projectSlug: "The slug of the project to list groups for." + }, + LIST_INTEGRATION: { + workspaceId: "The ID of the project to list integrations for." + }, + LIST_INTEGRATION_AUTHORIZATION: { + workspaceId: "The ID of the project to list integration auths for." + } +} as const; + +export const PROJECT_USERS = { + INVITE_MEMBER: { + projectId: "The ID of the project to invite the member to.", + emails: "A list of organization member emails to invite to the project.", + usernames: "A list of usernames to invite to the project." + }, + REMOVE_MEMBER: { + projectId: "The ID of the project to remove the member from.", + emails: "A list of organization member emails to remove from the project.", + usernames: "A list of usernames to remove from the project." + }, + GET_USER_MEMBERSHIPS: { + workspaceId: "The ID of the project to get memberships from." + }, + GET_USER_MEMBERSHIP: { + workspaceId: "The ID of the project to get memberships from.", + username: "The username to get project membership of. Email is the default username." + }, + UPDATE_USER_MEMBERSHIP: { + workspaceId: "The ID of the project to update the membership for.", + membershipId: "The ID of the membership to update.", + roles: "A list of roles to update the membership to." + } +}; + +export const PROJECT_IDENTITIES = { + LIST_IDENTITY_MEMBERSHIPS: { + projectId: "The ID of the project to get identity memberships from." + }, + GET_IDENTITY_MEMBERSHIP_BY_ID: { + identityId: "The ID of the identity to get the membership for.", + projectId: "The ID of the project to get the identity membership for." + }, + UPDATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to update the identity membership for.", + identityId: "The ID of the identity to update the membership for.", + roles: { + description: "A list of role slugs to assign to the identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: "Whether the assigned role is temporary.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", + temporaryAccessStartTime: "Time to which the temporary access starts" + } + }, + DELETE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to delete the identity membership from.", + identityId: "The ID of the identity to delete the membership from." + }, + CREATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to create the identity membership from.", + identityId: "The ID of the identity to create the membership from.", + role: "The role slug to assign to the newly created identity project membership.", + roles: { + description: "A list of role slugs to assign to the newly created identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: "Whether the assigned role is temporary.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", + temporaryAccessStartTime: "Time to which the temporary access starts" + } + } +}; + +export const ENVIRONMENTS = { + CREATE: { + workspaceId: "The ID of the project to create the environment in.", + name: "The name of the environment to create.", + slug: "The slug of the environment to create." + }, + UPDATE: { + workspaceId: "The ID of the project to update the environment in.", + id: "The ID of the environment to update.", + name: "The new name of the environment.", + slug: "The new slug of the environment.", + position: "The new position of the environment. The lowest number will be displayed as the first environment." + }, + DELETE: { + workspaceId: "The ID of the project to delete the environment from.", + id: "The ID of the environment to delete." + } +} as const; + +export const FOLDERS = { + LIST: { + workspaceId: "The ID of the project to list folders from.", + environment: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + directory: "The directory to list folders from. (Deprecated in favor of path)" + }, + CREATE: { + workspaceId: "The ID of the project to create the folder in.", + environment: "The slug of the environment to create the folder in.", + name: "The name of the folder to create.", + path: "The path of the folder to create.", + directory: "The directory of the folder to create. (Deprecated in favor of path)" + }, + UPDATE: { + folderId: "The ID of the folder to update.", + environment: "The slug of the environment where the folder is located.", + name: "The new name of the folder.", + path: "The path of the folder to update.", + directory: "The new directory of the folder to update. (Deprecated in favor of path)", + projectSlug: "The slug of the project where the folder is located.", + workspaceId: "The ID of the project where the folder is located." + }, + DELETE: { + folderIdOrName: "The ID or name of the folder to delete.", + workspaceId: "The ID of the project to delete the folder from.", + environment: "The slug of the environment where the folder is located.", + directory: "The directory of the folder to delete. (Deprecated in favor of path)", + path: "The path of the folder to delete." + } +} as const; + +export const SECRETS = { + ATTACH_TAGS: { + secretName: "The name of the secret to attach tags to.", + secretPath: "The path of the secret to attach tags to.", + type: "The type of the secret to attach tags to. (shared/personal)", + environment: "The slug of the environment where the secret is located", + projectSlug: "The slug of the project where the secret is located", + tagSlugs: "An array of existing tag slugs to attach to the secret." + }, + DETACH_TAGS: { + secretName: "The name of the secret to detach tags from.", + secretPath: "The path of the secret to detach tags from.", + type: "The type of the secret to attach tags to. (shared/personal)", + environment: "The slug of the environment where the secret is located", + projectSlug: "The slug of the project where the secret is located", + tagSlugs: "An array of existing tag slugs to detach from the secret." + } +} as const; + +export const RAW_SECRETS = { + LIST: { + expand: "Whether or not to expand secret references", + recursive: + "Whether or not to fetch all secrets from the specified base path, and all of its subdirectories. Note, the max depth is 20 deep.", + workspaceId: "The ID of the project to list secrets from.", + workspaceSlug: + "The slug of the project to list secrets from. This parameter is only applicable by machine identities.", + environment: "The slug of the environment to list secrets from.", + secretPath: "The secret path to list secrets from.", + includeImports: "Weather to include imported secrets or not." + }, + CREATE: { + secretName: "The name of the secret to create.", + projectSlug: "The slug of the project to create the secret in.", + environment: "The slug of the environment to create the secret in.", + secretComment: "Attach a comment to the secret.", + secretPath: "The path to create the secret in.", + secretValue: "The value of the secret to create.", + skipMultilineEncoding: "Skip multiline encoding for the secret value.", + type: "The type of the secret to create.", + workspaceId: "The ID of the project to create the secret in." + }, + GET: { + secretName: "The name of the secret to get.", + workspaceId: "The ID of the project to get the secret from.", + workspaceSlug: "The slug of the project to get the secret from.", + environment: "The slug of the environment to get the secret from.", + secretPath: "The path of the secret to get.", + version: "The version of the secret to get.", + type: "The type of the secret to get.", + includeImports: "Weather to include imported secrets or not." + }, + UPDATE: { + secretName: "The name of the secret to update.", + secretComment: "Update comment to the secret.", + environment: "The slug of the environment where the secret is located.", + secretPath: "The path of the secret to update", + secretValue: "The new value of the secret.", + skipMultilineEncoding: "Skip multiline encoding for the secret value.", + type: "The type of the secret to update.", + projectSlug: "The slug of the project to update the secret in.", + workspaceId: "The ID of the project to update the secret in." + }, + DELETE: { + secretName: "The name of the secret to delete.", + environment: "The slug of the environment where the secret is located.", + secretPath: "The path of the secret.", + type: "The type of the secret to delete.", + projectSlug: "The slug of the project to delete the secret in.", + workspaceId: "The ID of the project where the secret is located." + } +} as const; + +export const SECRET_IMPORTS = { + LIST: { + workspaceId: "The ID of the project to list secret imports from.", + environment: "The slug of the environment to list secret imports from.", + path: "The path to list secret imports from." + }, + CREATE: { + environment: "The slug of the environment to import into.", + path: "The path to import into.", + workspaceId: "The ID of the project you are working in.", + import: { + environment: "The slug of the environment to import from.", + path: "The path to import from." + } + }, + UPDATE: { + secretImportId: "The ID of the secret import to update.", + environment: "The slug of the environment where the secret import is located.", + import: { + environment: "The new environment slug to import from.", + path: "The new path to import from.", + position: "The new position of the secret import. The lowest number will be displayed as the first import." + }, + path: "The path of the secret import to update.", + workspaceId: "The ID of the project where the secret import is located." + }, + DELETE: { + workspaceId: "The ID of the project to delete the secret import from.", + secretImportId: "The ID of the secret import to delete.", + environment: "The slug of the environment where the secret import is located.", + path: "The path of the secret import to delete." + } +} as const; + +export const AUDIT_LOGS = { + EXPORT: { + workspaceId: "The ID of the project to export audit logs from.", + eventType: "The type of the event to export.", + userAgentType: "Choose which consuming application to export audit logs for.", + startDate: "The date to start the export from.", + endDate: "The date to end the export at.", + offset: "The offset to start from. If you enter 10, it will start from the 10th audit log.", + limit: "The number of audit logs to return.", + actor: "The actor to filter the audit logs by." + } +} as const; + +export const DYNAMIC_SECRETS = { + LIST: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from." + }, + LIST_LEAES_BY_NAME: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + name: "The name of the dynamic secret." + }, + GET_BY_NAME: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + name: "The name of the dynamic secret." + }, + CREATE: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to create the dynamic secret in.", + path: "The path to create the dynamic secret in.", + name: "The name of the dynamic secret.", + provider: "The type of dynamic secret.", + defaultTTL: "The default TTL that will be applied for all the leases.", + maxTTL: "The maximum limit a TTL can be leases or renewed." + }, + UPDATE: { + projectSlug: "The slug of the project to update dynamic secret in.", + environmentSlug: "The slug of the environment to update the dynamic secret in.", + path: "The path to update the dynamic secret in.", + name: "The name of the dynamic secret.", + inputs: "The new partial values for the configurated provider of the dynamic secret", + defaultTTL: "The default TTL that will be applied for all the leases.", + maxTTL: "The maximum limit a TTL can be leases or renewed.", + newName: "The new name for the dynamic secret." + }, + DELETE: { + projectSlug: "The slug of the project to delete dynamic secret in.", + environmentSlug: "The slug of the environment to delete the dynamic secret in.", + path: "The path to delete the dynamic secret in.", + name: "The name of the dynamic secret.", + isForced: + "A boolean flag to delete the the dynamic secret from infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally." + } +} as const; + +export const DYNAMIC_SECRET_LEASES = { + GET_BY_LEASEID: { + projectSlug: "The slug of the project to create dynamic secret in.", + environmentSlug: "The slug of the environment to list folders from.", + path: "The path to list folders from.", + leaseId: "The ID of the dynamic secret lease." + }, + CREATE: { + projectSlug: "The slug of the project of the dynamic secret in.", + environmentSlug: "The slug of the environment of the dynamic secret in.", + path: "The path of the dynamic secret in.", + dynamicSecretName: "The name of the dynamic secret.", + ttl: "The lease lifetime ttl. If not provided the default TTL of dynamic secret will be used." + }, + RENEW: { + projectSlug: "The slug of the project of the dynamic secret in.", + environmentSlug: "The slug of the environment of the dynamic secret in.", + path: "The path of the dynamic secret in.", + leaseId: "The ID of the dynamic secret lease.", + ttl: "The renew TTL that gets added with current expiry (ensure it's below max TTL) for a total less than creation time + max TTL." + }, + DELETE: { + projectSlug: "The slug of the project of the dynamic secret in.", + environmentSlug: "The slug of the environment of the dynamic secret in.", + path: "The path of the dynamic secret in.", + leaseId: "The ID of the dynamic secret lease.", + isForced: + "A boolean flag to delete the the dynamic secret from infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally." + } +} as const; +export const SECRET_TAGS = { + LIST: { + projectId: "The ID of the project to list tags from." + }, + CREATE: { + projectId: "The ID of the project to create the tag in.", + name: "The name of the tag to create.", + slug: "The slug of the tag to create.", + color: "The color of the tag to create." + }, + DELETE: { + tagId: "The ID of the tag to delete.", + projectId: "The ID of the project to delete the tag from." + } +} as const; + +export const IDENTITY_ADDITIONAL_PRIVILEGE = { + CREATE: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to create.", + slug: "The slug of the privilege to create.", + permissions: `The permission object for the privilege. +- Read secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"]} +\`\`\` +- Read and Write secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"], {"action": "write", "subject": "secrets"]} +\`\`\` +- Read secrets scoped to an environment and secret path +\`\`\` +- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } +\`\`\` +`, + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + UPDATE: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to update.", + slug: "The slug of the privilege to update.", + newSlug: "The new slug of the privilege to update.", + permissions: `The permission object for the privilege. +- Read secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"]} +\`\`\` +- Read and Write secrets +\`\`\` +{ "permissions": [{"action": "read", "subject": "secrets"], {"action": "write", "subject": "secrets"]} +\`\`\` +- Read secrets scoped to an environment and secret path +\`\`\` +- { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } +\`\`\` +`, + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + DELETE: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to delete." + }, + GET_BY_SLUG: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to list.", + slug: "The slug of the privilege." + }, + LIST: { + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to list.", + unpacked: "Whether the system should send the permissions as unpacked" + } +}; + +export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { + CREATE: { + projectMembershipId: "Project membership id of user", + slug: "The slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + UPDATE: { + privilegeId: "The id of privilege object", + slug: "The slug of the privilege to create.", + newSlug: "The new slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + DELETE: { + privilegeId: "The id of privilege object" + }, + GET_BY_PRIVILEGEID: { + privilegeId: "The id of privilege object" + }, + LIST: { + projectMembershipId: "Project membership id of user" + } +}; + +export const INTEGRATION_AUTH = { + GET: { + integrationAuthId: "The id of integration authentication object." + }, + DELETE: { + integration: "The slug of the integration to be unauthorized.", + projectId: "The ID of the project to delete the integration auth from." + }, + DELETE_BY_ID: { + integrationAuthId: "The id of integration authentication object to delete." + }, + CREATE_ACCESS_TOKEN: { + workspaceId: "The ID of the project to create the integration auth for.", + integration: "The slug of integration for the auth object.", + accessId: "The unique authorized access id of the external integration provider.", + accessToken: "The unique authorized access token of the external integration provider.", + url: "", + namespace: "", + refreshToken: "The refresh token for integration authorization." + } +} as const; + +export const INTEGRATION = { + CREATE: { + integrationAuthId: "The ID of the integration auth object to link with integration.", + app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", + isActive: "Whether the integration should be active or disabled.", + appId: + "The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", + secretPath: "The path of the secrets to sync secrets from.", + sourceEnvironment: "The environment to sync secret from.", + targetEnvironment: + "The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.", + targetEnvironmentId: + "The target environment id of the integration provider. Used in cloudflare pages, teamcity, gitlab integrations.", + targetService: + "The service based grouping identifier of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank", + targetServiceId: + "The service based grouping identifier ID of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank", + owner: "External integration providers service entity owner. Used in Github.", + path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault", + region: "AWS region to sync secrets to.", + scope: "Scope of the provider. Used by Github, Qovery", + metadata: { + secretPrefix: "The prefix for the saved secret. Used by GCP.", + secretSuffix: "The suffix for the saved secret. Used by GCP.", + initialSyncBehavoir: "Type of syncing behavoir with the integration.", + mappingBehavior: "The mapping behavior of the integration.", + shouldAutoRedeploy: "Used by Render to trigger auto deploy.", + secretGCPLabel: "The label for GCP secrets.", + secretAWSTag: "The tags for AWS secrets.", + kmsKeyId: "The ID of the encryption key from AWS KMS.", + shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store." + } + }, + UPDATE: { + integrationId: "The ID of the integration object.", + app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", + appId: + "The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", + isActive: "Whether the integration should be active or disabled.", + secretPath: "The path of the secrets to sync secrets from.", + owner: "External integration providers service entity owner. Used in Github.", + targetEnvironment: + "The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.", + environment: "The environment to sync secrets from." + }, + DELETE: { + integrationId: "The ID of the integration object." + }, + SYNC: { + integrationId: "The ID of the integration object to manually sync" + } +}; + +export const AUDIT_LOG_STREAMS = { + CREATE: { + url: "The HTTP URL to push logs to.", + headers: { + desc: "The HTTP headers attached for the external prrovider requests.", + key: "The HTTP header key name.", + value: "The HTTP header value." + } + }, + UPDATE: { + id: "The ID of the audit log stream to update.", + url: "The HTTP URL to push logs to.", + headers: { + desc: "The HTTP headers attached for the external prrovider requests.", + key: "The HTTP header key name.", + value: "The HTTP header value." + } + }, + DELETE: { + id: "The ID of the audit log stream to delete." + }, + GET_BY_ID: { + id: "The ID of the audit log stream to get details." + } +}; diff --git a/backend/src/lib/api-docs/index.ts b/backend/src/lib/api-docs/index.ts new file mode 100644 index 000000000..b04bfcf75 --- /dev/null +++ b/backend/src/lib/api-docs/index.ts @@ -0,0 +1 @@ +export * from "./constants"; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 4542c7fc3..6ae8bf02d 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -13,10 +13,23 @@ const zodStrBool = z const envSchema = z .object({ PORT: z.coerce.number().default(4000), + DISABLE_SECRET_SCANNING: z + .enum(["true", "false"]) + .default("false") + .transform((el) => el === "true"), REDIS_URL: zpStr(z.string()), HOST: zpStr(z.string().default("localhost")), - DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")), + DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( + `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` + ), + MAX_LEASE_LIMIT: z.coerce.number().default(10000), DB_ROOT_CERT: zpStr(z.string().describe("Postgres database base64-encoded CA cert").optional()), + DB_HOST: zpStr(z.string().describe("Postgres database host").optional()), + DB_PORT: zpStr(z.string().describe("Postgres database port").optional()).default("5432"), + DB_USER: zpStr(z.string().describe("Postgres database username").optional()), + DB_PASSWORD: zpStr(z.string().describe("Postgres database password").optional()), + DB_NAME: zpStr(z.string().describe("Postgres database name").optional()), + NODE_ENV: z.enum(["development", "test", "production"]).default("production"), SALT_ROUNDS: z.coerce.number().default(10), INITIAL_ORGANIZATION_NAME: zpStr(z.string().optional()), @@ -94,17 +107,23 @@ const envSchema = z SECRET_SCANNING_WEBHOOK_SECRET: zpStr(z.string().optional()), SECRET_SCANNING_GIT_APP_ID: zpStr(z.string().optional()), SECRET_SCANNING_PRIVATE_KEY: zpStr(z.string().optional()), - // LICENCE + // LICENSE LICENSE_SERVER_URL: zpStr(z.string().optional().default("https://portal.infisical.com")), LICENSE_SERVER_KEY: zpStr(z.string().optional()), LICENSE_KEY: zpStr(z.string().optional()), + LICENSE_KEY_OFFLINE: zpStr(z.string().optional()), + + // GENERIC STANDALONE_MODE: z .enum(["true", "false"]) .transform((val) => val === "true") - .optional() + .optional(), + INFISICAL_CLOUD: zodStrBool.default("false"), + MAINTENANCE_MODE: zodStrBool.default("false") }) .transform((data) => ({ ...data, + isCloud: Boolean(data.LICENSE_SERVER_KEY), isSmtpConfigured: Boolean(data.SMTP_HOST), isRedisConfigured: Boolean(data.REDIS_URL), isDevelopmentMode: data.NODE_ENV === "development", diff --git a/backend/src/lib/crypto/encryption.ts b/backend/src/lib/crypto/encryption.ts index 74febccec..16a7f42e7 100644 --- a/backend/src/lib/crypto/encryption.ts +++ b/backend/src/lib/crypto/encryption.ts @@ -8,6 +8,9 @@ import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "../config/env"; +export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s); +export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u); + export type TDecryptSymmetricInput = { ciphertext: string; iv: string; @@ -44,7 +47,7 @@ export const encryptSymmetric = (plaintext: string, key: string) => { }; }; -export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string) => { +export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string | Buffer) => { const iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16); const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv); @@ -58,7 +61,12 @@ export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string) }; }; -export const decryptSymmetric128BitHexKeyUTF8 = ({ ciphertext, iv, tag, key }: TDecryptSymmetricInput): string => { +export const decryptSymmetric128BitHexKeyUTF8 = ({ + ciphertext, + iv, + tag, + key +}: Omit & { key: string | Buffer }): string => { const decipher = crypto.createDecipheriv(SecretEncryptionAlgo.AES_256_GCM, key, Buffer.from(iv, "base64")); decipher.setAuthTag(Buffer.from(tag, "base64")); diff --git a/backend/src/lib/crypto/index.ts b/backend/src/lib/crypto/index.ts index 62278de1d..db3d91fc8 100644 --- a/backend/src/lib/crypto/index.ts +++ b/backend/src/lib/crypto/index.ts @@ -1,12 +1,21 @@ export { buildSecretBlindIndexFromName, createSecretBlindIndex, + decodeBase64, decryptAsymmetric, decryptSymmetric, decryptSymmetric128BitHexKeyUTF8, + encodeBase64, encryptAsymmetric, encryptSymmetric, encryptSymmetric128BitHexKeyUTF8, generateAsymmetricKeyPair } from "./encryption"; +export { + decryptIntegrationAuths, + decryptSecretApprovals, + decryptSecrets, + decryptSecretVersions +} from "./secret-encryption"; +export { verifyOfflineLicense } from "./signing"; export { generateSrpServerKey, srpCheckClientProof } from "./srp"; diff --git a/backend/src/lib/crypto/license_public_key.pem b/backend/src/lib/crypto/license_public_key.pem new file mode 100644 index 000000000..0cda06f3c --- /dev/null +++ b/backend/src/lib/crypto/license_public_key.pem @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEApchBY3BXTu4zWGBguB7nM/pjpVLY3V7VGZOAxmR5ueQTJOwiGM13 +5HN3EM9fDlQnZu9VSc0OFqRM/bUeUaI1oLPE6WzTHjdHyKjDI/S+TLx3VGEsvhM1 +uukZpYX+3KX2w4wzRHBaBWyglFy0CVNth9UJhhpD+KKfv7dzcRmsbyoUWi9wGfJu +wLYCwaCwZRXIt1sLGmMncPz14vfwdnm2a5Tj1Jbt0GTyBl+1/ZqLbO6SsslLg2G+ +o7FfGS9z8OUTkvDdu16qxL+p2wCEFZMnOz5BB4oakuT2gS9iOO2l5AOPcT4WzPzy +PYbX3d7cN9BkOY9I5z0cX4wzqHjQTvGNLQIDAQAB +-----END RSA PUBLIC KEY----- \ No newline at end of file diff --git a/backend/src/lib/crypto/secret-encryption.ts b/backend/src/lib/crypto/secret-encryption.ts new file mode 100644 index 000000000..2e0492560 --- /dev/null +++ b/backend/src/lib/crypto/secret-encryption.ts @@ -0,0 +1,293 @@ +import crypto from "crypto"; +import { z } from "zod"; + +import { + IntegrationAuthsSchema, + SecretApprovalRequestsSecretsSchema, + SecretsSchema, + SecretVersionsSchema, + TIntegrationAuths, + TProjectKeys, + TSecretApprovalRequestsSecrets, + TSecrets, + TSecretVersions +} from "../../db/schemas"; +import { decryptAsymmetric } from "./encryption"; + +const DecryptedValuesSchema = z.object({ + id: z.string(), + secretKey: z.string(), + secretValue: z.string(), + secretComment: z.string().optional() +}); + +const DecryptedSecretSchema = z.object({ + decrypted: DecryptedValuesSchema, + original: SecretsSchema +}); + +const DecryptedIntegrationAuthsSchema = z.object({ + decrypted: z.object({ + id: z.string(), + access: z.string(), + accessId: z.string(), + refresh: z.string() + }), + original: IntegrationAuthsSchema +}); + +const DecryptedSecretVersionsSchema = z.object({ + decrypted: DecryptedValuesSchema, + original: SecretVersionsSchema +}); + +const DecryptedSecretApprovalsSchema = z.object({ + decrypted: DecryptedValuesSchema, + original: SecretApprovalRequestsSecretsSchema +}); + +type DecryptedSecret = z.infer; +type DecryptedSecretVersions = z.infer; +type DecryptedSecretApprovals = z.infer; +type DecryptedIntegrationAuths = z.infer; + +type TLatestKey = TProjectKeys & { + sender: { + publicKey: string; + }; +}; + +const decryptCipher = ({ + ciphertext, + iv, + tag, + key +}: { + ciphertext: string; + iv: string; + tag: string; + key: string | Buffer; +}) => { + const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64")); + decipher.setAuthTag(Buffer.from(tag, "base64")); + + let cleartext = decipher.update(ciphertext, "base64", "utf8"); + cleartext += decipher.final("utf8"); + + return cleartext; +}; + +const getDecryptedValues = (data: Array<{ ciphertext: string; iv: string; tag: string }>, key: string | Buffer) => { + const results: string[] = []; + + for (const { ciphertext, iv, tag } of data) { + if (!ciphertext || !iv || !tag) { + results.push(""); + } else { + results.push(decryptCipher({ ciphertext, iv, tag, key })); + } + } + + return results; +}; +export const decryptSecrets = (encryptedSecrets: TSecrets[], privateKey: string, latestKey: TLatestKey) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedSecrets: DecryptedSecret[] = []; + + encryptedSecrets.forEach((encSecret) => { + const [secretKey, secretValue, secretComment] = getDecryptedValues( + [ + { + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag + }, + { + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag + }, + { + ciphertext: encSecret.secretCommentCiphertext || "", + iv: encSecret.secretCommentIV || "", + tag: encSecret.secretCommentTag || "" + } + ], + key + ); + + const decryptedSecret: DecryptedSecret = { + decrypted: { + secretKey, + secretValue, + secretComment, + id: encSecret.id + }, + original: encSecret + }; + + decryptedSecrets.push(DecryptedSecretSchema.parse(decryptedSecret)); + }); + + return decryptedSecrets; +}; + +export const decryptSecretVersions = ( + encryptedSecretVersions: TSecretVersions[], + privateKey: string, + latestKey: TLatestKey +) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedSecrets: DecryptedSecretVersions[] = []; + + encryptedSecretVersions.forEach((encSecret) => { + const [secretKey, secretValue, secretComment] = getDecryptedValues( + [ + { + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag + }, + { + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag + }, + { + ciphertext: encSecret.secretCommentCiphertext || "", + iv: encSecret.secretCommentIV || "", + tag: encSecret.secretCommentTag || "" + } + ], + key + ); + + const decryptedSecret: DecryptedSecretVersions = { + decrypted: { + secretKey, + secretValue, + secretComment, + id: encSecret.id + }, + original: encSecret + }; + + decryptedSecrets.push(DecryptedSecretVersionsSchema.parse(decryptedSecret)); + }); + + return decryptedSecrets; +}; + +export const decryptSecretApprovals = ( + encryptedSecretApprovals: TSecretApprovalRequestsSecrets[], + privateKey: string, + latestKey: TLatestKey +) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedSecrets: DecryptedSecretApprovals[] = []; + + encryptedSecretApprovals.forEach((encApproval) => { + const [secretKey, secretValue, secretComment] = getDecryptedValues( + [ + { + ciphertext: encApproval.secretKeyCiphertext, + iv: encApproval.secretKeyIV, + tag: encApproval.secretKeyTag + }, + { + ciphertext: encApproval.secretValueCiphertext, + iv: encApproval.secretValueIV, + tag: encApproval.secretValueTag + }, + { + ciphertext: encApproval.secretCommentCiphertext || "", + iv: encApproval.secretCommentIV || "", + tag: encApproval.secretCommentTag || "" + } + ], + key + ); + + const decryptedSecret: DecryptedSecretApprovals = { + decrypted: { + secretKey, + secretValue, + secretComment, + id: encApproval.id + }, + original: encApproval + }; + + decryptedSecrets.push(DecryptedSecretApprovalsSchema.parse(decryptedSecret)); + }); + + return decryptedSecrets; +}; + +export const decryptIntegrationAuths = ( + encryptedIntegrationAuths: TIntegrationAuths[], + privateKey: string, + latestKey: TLatestKey +) => { + const key = decryptAsymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey + }); + + const decryptedIntegrationAuths: DecryptedIntegrationAuths[] = []; + + encryptedIntegrationAuths.forEach((encAuth) => { + const [access, accessId, refresh] = getDecryptedValues( + [ + { + ciphertext: encAuth.accessCiphertext || "", + iv: encAuth.accessIV || "", + tag: encAuth.accessTag || "" + }, + { + ciphertext: encAuth.accessIdCiphertext || "", + iv: encAuth.accessIdIV || "", + tag: encAuth.accessIdTag || "" + }, + { + ciphertext: encAuth.refreshCiphertext || "", + iv: encAuth.refreshIV || "", + tag: encAuth.refreshTag || "" + } + ], + key + ); + + decryptedIntegrationAuths.push({ + decrypted: { + id: encAuth.id, + access, + accessId, + refresh + }, + original: encAuth + }); + }); + + return decryptedIntegrationAuths; +}; diff --git a/backend/src/lib/crypto/signing.ts b/backend/src/lib/crypto/signing.ts new file mode 100644 index 000000000..36c858715 --- /dev/null +++ b/backend/src/lib/crypto/signing.ts @@ -0,0 +1,22 @@ +import crypto, { KeyObject } from "crypto"; +import fs from "fs/promises"; +import path from "path"; + +export const verifySignature = (data: string, signature: Buffer, publicKey: KeyObject) => { + const verify = crypto.createVerify("SHA256"); + verify.update(data); + verify.end(); + return verify.verify(publicKey, signature); +}; + +export const verifyOfflineLicense = async (licenseContents: string, signature: string) => { + const publicKeyPem = await fs.readFile(path.join(__dirname, "license_public_key.pem"), "utf8"); + + const publicKey = crypto.createPublicKey({ + key: publicKeyPem, + format: "pem", + type: "pkcs1" + }); + + return verifySignature(licenseContents, Buffer.from(signature, "base64"), publicKey); +}; diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index b05f3734d..bc29cdb3f 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -1,4 +1,12 @@ +import argon2 from "argon2"; +import crypto from "crypto"; import jsrp from "jsrp"; +import nacl from "tweetnacl"; +import tweetnacl from "tweetnacl-util"; + +import { TUserEncryptionKeys } from "@app/db/schemas"; + +import { decryptSymmetric, encryptAsymmetric, encryptSymmetric } from "./encryption"; export const generateSrpServerKey = async (salt: string, verifier: string) => { // eslint-disable-next-line new-cap @@ -24,3 +32,99 @@ export const srpCheckClientProof = async ( server.setClientPublicKey(clientPublicKey); return server.checkClientProof(clientProof); }; + +// Ghost user related: +// This functionality is intended for ghost user logic. This happens on the frontend when a user is being created. +// We replicate the same functionality on the backend when creating a ghost user. +export const generateUserSrpKeys = async (email: string, password: string) => { + const pair = nacl.box.keyPair(); + const secretKeyUint8Array = pair.secretKey; + const publicKeyUint8Array = pair.publicKey; + const privateKey = tweetnacl.encodeBase64(secretKeyUint8Array); + const publicKey = tweetnacl.encodeBase64(publicKeyUint8Array); + + // eslint-disable-next-line + const client = new jsrp.client(); + await new Promise((resolve) => { + client.init({ username: email, password }, () => resolve(null)); + }); + const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>((resolve, reject) => { + client.createVerifier((err, res) => { + if (err) return reject(err); + return resolve(res); + }); + }); + const derivedKey = await argon2.hash(password, { + salt: Buffer.from(salt), + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + hashLength: 32, + type: argon2.argon2id, + raw: true + }); + if (!derivedKey) throw new Error("Failed to derive key from password"); + + const key = crypto.randomBytes(32); + + // create encrypted private key by encrypting the private + // key with the symmetric key [key] + const { + ciphertext: encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + } = encryptSymmetric(privateKey, key.toString("base64")); + + // create the protected key by encrypting the symmetric key + // [key] with the derived key + const { + ciphertext: protectedKey, + iv: protectedKeyIV, + tag: protectedKeyTag + } = encryptSymmetric(key.toString("hex"), derivedKey.toString("base64")); + + return { + protectedKey, + plainPrivateKey: privateKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }; +}; + +export const getUserPrivateKey = async (password: string, user: TUserEncryptionKeys) => { + const derivedKey = await argon2.hash(password, { + salt: Buffer.from(user.salt), + memoryCost: 65536, + timeCost: 3, + parallelism: 1, + hashLength: 32, + type: argon2.argon2id, + raw: true + }); + if (!derivedKey) throw new Error("Failed to derive key from password"); + const key = decryptSymmetric({ + ciphertext: user.protectedKey!, + iv: user.protectedKeyIV!, + tag: user.protectedKeyTag!, + key: derivedKey.toString("base64") + }); + const privateKey = decryptSymmetric({ + ciphertext: user.encryptedPrivateKey, + iv: user.iv, + tag: user.tag, + key + }); + return privateKey; +}; + +export const buildUserProjectKey = async (privateKey: string, publickey: string) => { + const randomBytes = crypto.randomBytes(16).toString("hex"); + const { nonce, ciphertext } = encryptAsymmetric(randomBytes, publickey, privateKey); + return { nonce, ciphertext }; +}; diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index b4376f007..18b40acfd 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -58,3 +58,47 @@ export class BadRequestError extends Error { this.error = error; } } + +export class DisableRotationErrors extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message: string; name?: string; error?: unknown }) { + super(message); + this.name = name || "DisableRotationErrors"; + this.error = error; + } +} + +export class ScimRequestError extends Error { + name: string; + + schemas: string[]; + + detail: string; + + status: number; + + error: unknown; + + constructor({ + name, + error, + detail, + status + }: { + message?: string; + name?: string; + error?: unknown; + detail: string; + status: number; + }) { + super(detail ?? "The request is invalid"); + this.name = name || "ScimRequestError"; + this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"]; + this.error = error; + this.detail = detail; + this.status = status; + } +} diff --git a/backend/src/lib/fn/dates.ts b/backend/src/lib/fn/dates.ts index e69de29bb..f9ea4db10 100644 --- a/backend/src/lib/fn/dates.ts +++ b/backend/src/lib/fn/dates.ts @@ -0,0 +1,2 @@ +export const getLastMidnightDateISO = (last = 1) => + `${new Date(new Date().setDate(new Date().getDate() - last)).toISOString().slice(0, 10)}T00:00:00Z`; diff --git a/backend/src/lib/fn/index.ts b/backend/src/lib/fn/index.ts index 4b4a01a14..0d0f07e45 100644 --- a/backend/src/lib/fn/index.ts +++ b/backend/src/lib/fn/index.ts @@ -2,5 +2,6 @@ // Full credits goes to https://github.com/rayapps to those functions // Code taken to keep in in house and to adjust somethings for our needs export * from "./array"; +export * from "./dates"; export * from "./object"; export * from "./string"; diff --git a/backend/src/lib/knex/connection.ts b/backend/src/lib/knex/connection.ts new file mode 100644 index 000000000..993615a0b --- /dev/null +++ b/backend/src/lib/knex/connection.ts @@ -0,0 +1,11 @@ +import { URL } from "url"; // Import the URL class + +export const getDbConnectionHost = (urlString: string) => { + try { + const url = new URL(urlString); + // Split hostname and port (if provided) + return url.hostname.split(":")[0]; + } catch (error) { + return null; + } +}; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index 37fae624e..d78020809 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -4,6 +4,7 @@ import { Tables } from "knex/types/tables"; import { DatabaseError } from "../errors"; +export * from "./connection"; export * from "./join"; export * from "./select"; diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index c124fcf4c..5d1a63fc8 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -30,6 +30,37 @@ const loggerConfig = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("production") }); +const redactedKeys = [ + "accessToken", + "authToken", + "serviceToken", + "identityAccessToken", + "token", + "privateKey", + "serverPrivateKey", + "plainPrivateKey", + "plainProjectKey", + "encryptedPrivateKey", + "userPrivateKey", + "protectedKey", + "decryptKey", + "encryptedProjectKey", + "encryptedSymmetricKey", + "encryptedPrivateKey", + "backupPrivateKey", + "secretKey", + "SecretKey", + "botPrivateKey", + "encryptedKey", + "plaintextProjectKey", + "accessKey", + "botKey", + "decryptedSecret", + "secrets", + "key", + "password" +]; + export const initLogger = async () => { const cfg = loggerConfig.parse(process.env); const targets: pino.TransportMultiOptions["targets"][number][] = [ @@ -74,7 +105,9 @@ export const initLogger = async () => { hostname: bindings.hostname // node_version: process.version }) - } + }, + // redact until depth of three + redact: [...redactedKeys, ...redactedKeys.map((key) => `*.${key}`), ...redactedKeys.map((key) => `*.*.${key}`)] }, // eslint-disable-next-line @typescript-eslint/no-unsafe-argument transport diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 5c194d789..2c41f4d23 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -1,15 +1,40 @@ -import { ActorType } from "@app/services/auth/auth-type"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +export type TGenericPermission = { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; +}; + +/** + * TODO(dangtony98): ideally move service fns to use TGenericPermission + * because TOrgPermission [orgId] is not as relevant anymore with the + * introduction of organizationIds bound to all user tokens + */ export type TOrgPermission = { actor: ActorType; actorId: string; orgId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; }; export type TProjectPermission = { actor: ActorType; actorId: string; projectId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; +}; + +// same as TProjectPermission but with projectSlug requirement instead of projectId +export type TProjectSlugPermission = { + actor: ActorType; + actorId: string; + projectSlug: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; }; export type RequiredKeys = { diff --git a/backend/src/lib/validator/index.ts b/backend/src/lib/validator/index.ts index 6bc415680..6a70d8571 100644 --- a/backend/src/lib/validator/index.ts +++ b/backend/src/lib/validator/index.ts @@ -1 +1,2 @@ export { isDisposableEmail } from "./validate-email"; +export { validateLocalIps } from "./validate-url"; diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts new file mode 100644 index 000000000..9a953be1a --- /dev/null +++ b/backend/src/lib/validator/validate-url.ts @@ -0,0 +1,18 @@ +import { getConfig } from "../config/env"; +import { BadRequestError } from "../errors"; + +export const validateLocalIps = (url: string) => { + const validUrl = new URL(url); + const appCfg = getConfig(); + // on cloud local ips are not allowed + if ( + appCfg.isCloud && + (validUrl.host === "host.docker.internal" || + validUrl.host.match(/^10\.\d+\.\d+\.\d+/) || + validUrl.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Local IPs not allowed as URL" }); + + if (validUrl.host === "localhost" || validUrl.host === "127.0.0.1") + throw new BadRequestError({ message: "Localhost not allowed" }); +}; diff --git a/backend/src/main.ts b/backend/src/main.ts index fab576d3b..86681ef33 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import dotenv from "dotenv"; import { initDbConnection } from "./db"; +import { keyStoreFactory } from "./keystore/keystore"; import { formatSmtpConfig, initEnvConfig } from "./lib/config/env"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; @@ -19,8 +20,9 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); const queue = queueServiceFactory(appCfg.REDIS_URL); + const keyStore = keyStoreFactory(appCfg.REDIS_URL); - const server = await main({ db, smtp, logger, queue }); + const server = await main({ db, smtp, logger, queue, keyStore }); const bootstrap = await bootstrapCheck({ db }); // eslint-disable-next-line process.on("SIGINT", async () => { diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 58c829549..bc8ac88ff 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -1,6 +1,7 @@ import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq"; import Redis from "ioredis"; +import { SecretKeyEncoding } from "@app/db/schemas"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TScanFullRepoEventPayload, @@ -12,10 +13,13 @@ export enum QueueName { SecretReminder = "secret-reminder", AuditLog = "audit-log", AuditLogPrune = "audit-log-prune", + TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", SecretFullRepoScan = "secret-full-repo-scan", - SecretPushEventScan = "secret-push-event-scan" + SecretPushEventScan = "secret-push-event-scan", + UpgradeProjectToGhost = "upgrade-project-to-ghost", + DynamicSecretRevocation = "dynamic-secret-revocation" } export enum QueueJobs { @@ -24,8 +28,12 @@ export enum QueueJobs { AuditLog = "audit-log-job", AuditLogPrune = "audit-log-prune-job", SecWebhook = "secret-webhook-trigger", + TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", - SecretScan = "secret-scan" + SecretScan = "secret-scan", + UpgradeProjectToGhost = "upgrade-project-to-ghost-job", + DynamicSecretRevocation = "dynamic-secret-revocation", + DynamicSecretPruning = "dynamic-secret-pruning" } export type TQueueJobTypes = { @@ -53,17 +61,53 @@ export type TQueueJobTypes = { }; [QueueName.SecretWebhook]: { name: QueueJobs.SecWebhook; - payload: { projectId: string; environment: string; secretPath: string }; + payload: { projectId: string; environment: string; secretPath: string; depth?: number }; }; [QueueName.IntegrationSync]: { name: QueueJobs.IntegrationSync; - payload: { projectId: string; environment: string; secretPath: string }; + payload: { + projectId: string; + environment: string; + secretPath: string; + depth?: number; + deDupeQueue?: Record; + }; }; [QueueName.SecretFullRepoScan]: { name: QueueJobs.SecretScan; payload: TScanFullRepoEventPayload; }; [QueueName.SecretPushEventScan]: { name: QueueJobs.SecretScan; payload: TScanPushEventPayload }; + [QueueName.UpgradeProjectToGhost]: { + name: QueueJobs.UpgradeProjectToGhost; + payload: { + projectId: string; + startedByUserId: string; + encryptedPrivateKey: { + encryptedKey: string; + encryptedKeyIv: string; + encryptedKeyTag: string; + keyEncoding: SecretKeyEncoding; + }; + }; + }; + [QueueName.TelemetryInstanceStats]: { + name: QueueJobs.TelemetryInstanceStats; + payload: undefined; + }; + [QueueName.DynamicSecretRevocation]: + | { + name: QueueJobs.DynamicSecretRevocation; + payload: { + leaseId: string; + }; + } + | { + name: QueueJobs.DynamicSecretPruning; + payload: { + dynamicSecretCfgId: string; + }; + }; }; export type TQueueServiceFactory = ReturnType; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index ca6b9003a..51cef185a 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -14,6 +14,7 @@ import fasitfy from "fastify"; import { Knex } from "knex"; import { Logger } from "pino"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; import { TSmtpService } from "@app/services/smtp/smtp-service"; @@ -23,6 +24,7 @@ import { fastifyErrHandler } from "./plugins/error-handler"; import { registerExternalNextjs } from "./plugins/external-nextjs"; import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "./plugins/fastify-zod"; import { fastifyIp } from "./plugins/ip"; +import { maintenanceMode } from "./plugins/maintenanceMode"; import { fastifySwagger } from "./plugins/swagger"; import { registerRoutes } from "./routes"; @@ -31,13 +33,14 @@ type TMain = { smtp: TSmtpService; logger?: Logger; queue: TQueueServiceFactory; + keyStore: TKeyStoreFactory; }; // Run the server! -export const main = async ({ db, smtp, logger, queue }: TMain) => { +export const main = async ({ db, smtp, logger, queue, keyStore }: TMain) => { const appCfg = getConfig(); const server = fasitfy({ - logger, + logger: appCfg.NODE_ENV === "test" ? false : logger, trustProxy: true, connectionTimeout: 30 * 1000, ignoreTrailingSlash: true @@ -70,7 +73,9 @@ export const main = async ({ db, smtp, logger, queue }: TMain) => { } await server.register(helmet, { contentSecurityPolicy: false }); - await server.register(registerRoutes, { smtp, queue, db }); + await server.register(maintenanceMode); + + await server.register(registerRoutes, { smtp, queue, db, keyStore }); if (appCfg.isProductionMode) { await server.register(registerExternalNextjs, { diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 444158cbf..6c92de62c 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -18,14 +18,43 @@ export const globalRateLimiterCfg = (): RateLimitPluginOptions => { }; }; -export const authRateLimit: RateLimitOptions = { +// GET endpoints +export const readLimit: RateLimitOptions = { timeWindow: 60 * 1000, max: 600, keyGenerator: (req) => req.realIp }; -export const passwordRateLimit: RateLimitOptions = { +// POST, PATCH, PUT, DELETE endpoints +export const writeLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 600, + max: 50, + keyGenerator: (req) => req.realIp +}; + +// special endpoints +export const secretsLimit: RateLimitOptions = { + // secrets, folders, secret imports + timeWindow: 60 * 1000, + max: 60, + keyGenerator: (req) => req.realIp +}; + +export const authRateLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + max: 60, + keyGenerator: (req) => req.realIp +}; + +export const inviteUserRateLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + max: 30, + keyGenerator: (req) => req.realIp +}; + +export const creationLimit: RateLimitOptions = { + // identity, project, org + timeWindow: 60 * 1000, + max: 30, keyGenerator: (req) => req.realIp }; diff --git a/backend/src/server/lib/telemetry.ts b/backend/src/server/lib/telemetry.ts new file mode 100644 index 000000000..9d04d0357 --- /dev/null +++ b/backend/src/server/lib/telemetry.ts @@ -0,0 +1,17 @@ +import { FastifyRequest } from "fastify"; + +import { ActorType } from "@app/services/auth/auth-type"; + +// this is a unique id for sending posthog event +export const getTelemetryDistinctId = (req: FastifyRequest) => { + if (req.auth.actor === ActorType.USER) { + return req.auth.user.username; + } + if (req.auth.actor === ActorType.IDENTITY) { + return `identity-${req.auth.identityId}`; + } + if (req.auth.actor === ActorType.SERVICE) { + return req.auth.serviceToken.createdByEmail || `service-token-null-creator-${req.auth.serviceTokenId}`; // when user gets removed from system + } + return "unknown-auth-data"; +}; diff --git a/backend/src/server/plugins/audit-log.ts b/backend/src/server/plugins/audit-log.ts index b42cc2ff2..084b0cb54 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -44,6 +44,7 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { type: ActorType.USER, metadata: { email: req.auth.user.email, + username: req.auth.user.username, userId: req.permission.id } }; @@ -63,6 +64,11 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => { identityId: req.auth.identityId } }; + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + payload.actor = { + type: ActorType.SCIM_CLIENT, + metadata: {} + }; } else { throw new BadRequestError({ message: "Missing logic for other actor" }); } diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 04d4cbe0e..d8814dd40 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -3,9 +3,10 @@ import fp from "fastify-plugin"; import jwt, { JwtPayload } from "jsonwebtoken"; import { TServiceTokens, TUsers } from "@app/db/schemas"; +import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types"; import { getConfig } from "@app/lib/config/env"; import { UnauthorizedError } from "@app/lib/errors"; -import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; +import { ActorType, AuthMethod, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; export type TAuthMode = @@ -15,24 +16,39 @@ export type TAuthMode = userId: string; tokenVersionId: string; // the session id of token used user: TUsers; + orgId: string; + authMethod: AuthMethod; } | { authMode: AuthMode.API_KEY; + authMethod: null; actor: ActorType.USER; userId: string; user: TUsers; + orgId: string; } | { authMode: AuthMode.SERVICE_TOKEN; - serviceToken: TServiceTokens; + serviceToken: TServiceTokens & { createdByEmail: string }; actor: ActorType.SERVICE; serviceTokenId: string; + orgId: string; + authMethod: null; } | { authMode: AuthMode.IDENTITY_ACCESS_TOKEN; actor: ActorType.IDENTITY; identityId: string; identityName: string; + orgId: string; + authMethod: null; + } + | { + authMode: AuthMode.SCIM_TOKEN; + actor: ActorType.SCIM_CLIENT; + scimTokenId: string; + orgId: string; + authMethod: null; }; const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { @@ -41,6 +57,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { return { authMode: AuthMode.API_KEY, token: apiKey, actor: ActorType.USER } as const; } const authHeader = req.headers?.authorization; + if (!authHeader) return { authMode: null, token: null }; const authTokenValue = authHeader.slice(7); // slice of after Bearer @@ -53,6 +70,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { } const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload; + switch (decodedToken.authTokenType) { case AuthTokenType.ACCESS_TOKEN: return { @@ -61,6 +79,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { actor: ActorType.USER } as const; case AuthTokenType.API_KEY: + // throw new Error("API Key auth is no longer supported."); return { authMode: AuthMode.API_KEY, token: decodedToken, actor: ActorType.USER } as const; case AuthTokenType.IDENTITY_ACCESS_TOKEN: return { @@ -68,22 +87,42 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { token: decodedToken as TIdentityAccessTokenJwtPayload, actor: ActorType.IDENTITY } as const; + case AuthTokenType.SCIM_TOKEN: + return { + authMode: AuthMode.SCIM_TOKEN, + token: decodedToken as TScimTokenJwtPayload, + actor: ActorType.SCIM_CLIENT + } as const; default: return { authMode: null, token: null } as const; } }; +// ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity). export const injectIdentity = fp(async (server: FastifyZodProvider) => { server.decorateRequest("auth", null); server.addHook("onRequest", async (req) => { const appCfg = getConfig(); const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); + + if (req.url.includes("/api/v3/auth/")) { + return; + } + if (!authMode) return; switch (authMode) { case AuthMode.JWT: { - const { user, tokenVersionId } = await server.services.authToken.fnValidateJwtIdentity(token); - req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId, actor }; + const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + req.auth = { + authMode: AuthMode.JWT, + user, + userId: user.id, + tokenVersionId, + actor, + orgId: orgId as string, + authMethod: token.authMethod + }; break; } case AuthMode.IDENTITY_ACCESS_TOKEN: { @@ -91,24 +130,40 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, + orgId: identity.orgId, identityId: identity.identityId, - identityName: identity.name + identityName: identity.name, + authMethod: null }; break; } case AuthMode.SERVICE_TOKEN: { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); req.auth = { + orgId: serviceToken.orgId, authMode: AuthMode.SERVICE_TOKEN as const, serviceToken, serviceTokenId: serviceToken.id, - actor + actor, + authMethod: null }; break; } case AuthMode.API_KEY: { const user = await server.services.apiKey.fnValidateApiKey(token as string); - req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user }; + req.auth = { + authMode: AuthMode.API_KEY as const, + userId: user.id, + actor, + user, + orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! + authMethod: null + }; + break; + } + case AuthMode.SCIM_TOKEN: { + const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; break; } default: diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 410621611..11a94657b 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -1,5 +1,6 @@ import fp from "fastify-plugin"; +import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; // inject permission type needed based on auth extracted @@ -9,11 +10,49 @@ export const injectPermission = fp(async (server) => { if (!req.auth) return; if (req.auth.actor === ActorType.USER) { - req.permission = { type: ActorType.USER, id: req.auth.userId }; + req.permission = { + type: ActorType.USER, + id: req.auth.userId, + orgId: req.auth.orgId, // if the req.auth.authMode is AuthMode.API_KEY, the orgId will be "API_KEY" + authMethod: req.auth.authMethod // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.userId}] [type=${ActorType.USER}]` + ); } else if (req.auth.actor === ActorType.IDENTITY) { - req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId }; + req.permission = { + type: ActorType.IDENTITY, + id: req.auth.identityId, + orgId: req.auth.orgId, + authMethod: null + }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.identityId}] [type=${ActorType.IDENTITY}]` + ); } else if (req.auth.actor === ActorType.SERVICE) { - req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId }; + req.permission = { + type: ActorType.SERVICE, + id: req.auth.serviceTokenId, + orgId: req.auth.orgId, + authMethod: null + }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.serviceTokenId}] [type=${ActorType.SERVICE}]` + ); + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + req.permission = { + type: ActorType.SCIM_CLIENT, + id: req.auth.scimTokenId, + orgId: req.auth.orgId, + authMethod: null + }; + + logger.info( + `injectPermission: Injecting permissions for [permissionsForIdentity=${req.auth.scimTokenId}] [type=${ActorType.SCIM_CLIENT}]` + ); } }); }); diff --git a/backend/src/server/plugins/auth/verify-auth.ts b/backend/src/server/plugins/auth/verify-auth.ts index a1274f356..3b3a239f7 100644 --- a/backend/src/server/plugins/auth/verify-auth.ts +++ b/backend/src/server/plugins/auth/verify-auth.ts @@ -3,15 +3,26 @@ import { FastifyReply, FastifyRequest, HookHandlerDoneFunction } from "fastify"; import { UnauthorizedError } from "@app/lib/errors"; import { AuthMode } from "@app/services/auth/auth-type"; +interface TAuthOptions { + requireOrg: boolean; +} + export const verifyAuth = - (authStrats: AuthMode[]) => + (authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) => (req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => { - if (!Array.isArray(authStrats)) throw new Error("Auth strategy must be array"); + if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array"); if (!req.auth) throw new UnauthorizedError({ name: "Unauthorized access", message: "Token missing" }); - const isAccessAllowed = authStrats.some((strat) => strat === req.auth.authMode); + const isAccessAllowed = authStrategies.some((strategy) => strategy === req.auth.authMode); if (!isAccessAllowed) { throw new UnauthorizedError({ name: `${req.url} Unauthorized Access` }); } + + // New optional option. There are some routes which do not require an organization ID to be present on the request. + // An example of this is the /v1 auth routes. + if (req.auth.authMode === AuthMode.JWT && options.requireOrg === true && !req.permission.orgId) { + throw new UnauthorizedError({ name: `${req.url} Unauthorized Access, no organization found in request` }); + } + done(); }; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8587c93bd..c8da4077a 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -2,7 +2,13 @@ import { ForbiddenError } from "@casl/ability"; import fastifyPlugin from "fastify-plugin"; import { ZodError } from "zod"; -import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + DatabaseError, + InternalServerError, + ScimRequestError, + UnauthorizedError +} from "@app/lib/errors"; export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => { server.setErrorHandler((error, req, res) => { @@ -21,6 +27,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: "PermissionDenied", message: `You are not allowed to ${error.action} on ${error.subjectType}` }); + } else if (error instanceof ScimRequestError) { + void res.status(error.status).send({ + schemas: error.schemas, + status: error.status, + detail: error.detail + }); } else { void res.send(error); } diff --git a/backend/src/server/plugins/maintenanceMode.ts b/backend/src/server/plugins/maintenanceMode.ts new file mode 100644 index 000000000..201dd05b8 --- /dev/null +++ b/backend/src/server/plugins/maintenanceMode.ts @@ -0,0 +1,17 @@ +import fp from "fastify-plugin"; + +import { getConfig } from "@app/lib/config/env"; + +export const maintenanceMode = fp(async (fastify) => { + fastify.addHook("onRequest", async (req) => { + const serverEnvs = getConfig(); + if (serverEnvs.MAINTENANCE_MODE) { + // skip if its universal auth login or renew + if (req.url === "/api/v1/auth/universal-auth/login" && req.method === "POST") return; + if (req.url === "/api/v1/auth/token/renew" && req.method === "POST") return; + if (req.url !== "/api/v1/auth/checkAuth" && req.method !== "GET") { + throw new Error("Infisical is in maintenance mode. Please try again later."); + } + } + }); +}); diff --git a/backend/src/server/plugins/secret-scanner.ts b/backend/src/server/plugins/secret-scanner.ts index 8790d54d4..d20008de7 100644 --- a/backend/src/server/plugins/secret-scanner.ts +++ b/backend/src/server/plugins/secret-scanner.ts @@ -4,6 +4,7 @@ import SmeeClient from "smee-client"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; +import { writeLimit } from "@app/server/config/rateLimiter"; export const registerSecretScannerGhApp = async (server: FastifyZodProvider) => { const probotApp = (app: Probot) => { @@ -49,6 +50,9 @@ export const registerSecretScannerGhApp = async (server: FastifyZodProvider) => server.route({ method: "POST", url: "/", + config: { + rateLimit: writeLimit + }, handler: async (req, res) => { const eventName = req.headers["x-github-event"]; const signatureSHA256 = req.headers["x-hub-signature-256"] as string; diff --git a/backend/src/server/plugins/swagger.ts b/backend/src/server/plugins/swagger.ts index 482e8260b..99032bb6f 100644 --- a/backend/src/server/plugins/swagger.ts +++ b/backend/src/server/plugins/swagger.ts @@ -14,28 +14,22 @@ export const fastifySwagger = fp(async (fastify) => { version: "0.0.1" }, servers: [ - { - url: "http://localhost:8080", - description: "Local server" - }, { url: "https://app.infisical.com", description: "Production server" + }, + { + url: "http://localhost:8080", + description: "Local server" } ], components: { securitySchemes: { - bearer: { + bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT", - description: "A service token in Infisical" - }, - apiKey: { - type: "apiKey", - in: "header", - name: "X-API-Key", - description: "An API Key in Infisical" + description: "An access token in Infisical" } } } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 28f65ea99..75c43a9aa 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2,15 +2,41 @@ import { Knex } from "knex"; import { z } from "zod"; import { registerV1EERoutes } from "@app/ee/routes/v1"; +import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; +import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; +import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; +import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; +import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; +import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; +import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; +import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; +import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; +import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; +import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { groupDALFactory } from "@app/ee/services/group/group-dal"; +import { groupServiceFactory } from "@app/ee/services/group/group-service"; +import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; +import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; +import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; +import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; +import { ldapGroupMapDALFactory } from "@app/ee/services/ldap-config/ldap-group-map-dal"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; +import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; +import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; +import { scimServiceFactory } from "@app/ee/services/scim/scim-service"; import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -32,8 +58,10 @@ import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snaps import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; +import { readLimit } from "@app/server/config/rateLimiter"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { authDALFactory } from "@app/services/auth/auth-dal"; @@ -42,12 +70,22 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; +import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; +import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; +import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; +import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; @@ -62,7 +100,9 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; +import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -72,6 +112,7 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal" import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; +import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { secretDALFactory } from "@app/services/secret/secret-dal"; @@ -93,9 +134,12 @@ import { serviceTokenServiceFactory } from "@app/services/service-token/service- import { TSmtpService } from "@app/services/smtp/smtp-service"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { getServerCfg, superAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; +import { telemetryDALFactory } from "@app/services/telemetry/telemetry-dal"; +import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry-queue"; import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; +import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; @@ -109,15 +153,25 @@ import { registerV3Routes } from "./v3"; export const registerRoutes = async ( server: FastifyZodProvider, - { db, smtp: smtpService, queue: queueService }: { db: Knex; smtp: TSmtpService; queue: TQueueServiceFactory } + { + db, + smtp: smtpService, + queue: queueService, + keyStore + }: { db: Knex; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory } ) => { - await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + const appCfg = getConfig(); + if (!appCfg.DISABLE_SECRET_SCANNING) { + await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + } // db layers const userDAL = userDALFactory(db); + const userAliasDAL = userAliasDALFactory(db); const authDAL = authDALFactory(db); const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); + const orgMembershipDAL = orgMembershipDALFactory(db); const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db); @@ -126,6 +180,8 @@ export const registerRoutes = async ( const projectDAL = projectDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); + const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db); + const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db); const projectKeyDAL = projectKeyDALFactory(db); @@ -149,16 +205,33 @@ export const registerRoutes = async ( const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); + const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); + const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); const identityUaDAL = identityUaDALFactory(db); + const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); + const identityAwsAuthDAL = identityAwsAuthDALFactory(db); + + const identityGcpAuthDAL = identityGcpAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); + const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); + const telemetryDAL = telemetryDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); const samlConfigDAL = samlConfigDALFactory(db); + const scimDAL = scimDALFactory(db); + const ldapConfigDAL = ldapConfigDALFactory(db); + const ldapGroupMapDAL = ldapGroupMapDALFactory(db); + + const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); + const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); + const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); + const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); @@ -172,29 +245,43 @@ export const registerRoutes = async ( const gitAppInstallSessionDAL = gitAppInstallSessionDALFactory(db); const gitAppOrgDAL = gitAppDALFactory(db); + const groupDAL = groupDALFactory(db); + const groupProjectDAL = groupProjectDALFactory(db); + const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); + const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); const licenseDAL = licenseDALFactory(db); + const dynamicSecretDAL = dynamicSecretDALFactory(db); + const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, projectRoleDAL, - serviceTokenDAL + serviceTokenDAL, + projectDAL }); - const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL }); + const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); const trustedIpService = trustedIpServiceFactory({ licenseService, projectDAL, trustedIpDAL, permissionService }); + const auditLogQueue = auditLogQueueServiceFactory({ auditLogDAL, queueService, projectDAL, - licenseService + licenseService, + auditLogStreamDAL }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); + const auditLogStreamService = auditLogStreamServiceFactory({ + licenseService, + permissionService, + auditLogStreamDAL + }); const sapService = secretApprovalPolicyServiceFactory({ projectMembershipDAL, projectEnvDAL, @@ -202,19 +289,97 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL }); + const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); + const samlService = samlConfigServiceFactory({ permissionService, orgBotDAL, orgDAL, + orgMembershipDAL, userDAL, + userAliasDAL, samlConfigDAL, + licenseService, + tokenService, + smtpService + }); + const groupService = groupServiceFactory({ + userDAL, + groupDAL, + groupProjectDAL, + orgDAL, + userGroupMembershipDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, + permissionService, + licenseService + }); + const groupProjectService = groupProjectServiceFactory({ + groupDAL, + groupProjectDAL, + groupProjectMembershipRoleDAL, + userGroupMembershipDAL, + projectDAL, + projectKeyDAL, + projectBotDAL, + projectRoleDAL, + permissionService + }); + const scimService = scimServiceFactory({ + licenseService, + scimDAL, + userDAL, + userAliasDAL, + orgDAL, + orgMembershipDAL, + projectDAL, + projectMembershipDAL, + groupDAL, + groupProjectDAL, + userGroupMembershipDAL, + projectKeyDAL, + projectBotDAL, + permissionService, + smtpService + }); + + const ldapService = ldapConfigServiceFactory({ + ldapConfigDAL, + ldapGroupMapDAL, + orgDAL, + orgMembershipDAL, + orgBotDAL, + groupDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + userGroupMembershipDAL, + userDAL, + userAliasDAL, + permissionService, licenseService }); - const telemetryService = telemetryServiceFactory(); - const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL }); - const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService }); + const telemetryService = telemetryServiceFactory({ + keyStore, + licenseService + }); + const telemetryQueue = telemetryQueueServiceFactory({ + keyStore, + telemetryDAL, + queueService + }); + + const userService = userServiceFactory({ + userDAL, + userAliasDAL, + orgMembershipDAL, + tokenService, + smtpService + }); + const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, smtpService, @@ -222,6 +387,7 @@ export const registerRoutes = async ( userDAL }); const orgService = orgServiceFactory({ + userAliasDAL, licenseService, samlConfigDAL, orgRoleDAL, @@ -230,8 +396,11 @@ export const registerRoutes = async ( incidentContactDAL, tokenService, projectDAL, + projectMembershipDAL, + projectKeyDAL, smtpService, userDAL, + groupDAL, orgBotDAL }); const signupService = authSignupServiceFactory({ @@ -239,6 +408,11 @@ export const registerRoutes = async ( smtpService, authDAL, userDAL, + userGroupMembershipDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + groupProjectDAL, orgDAL, orgService, licenseService @@ -248,7 +422,8 @@ export const registerRoutes = async ( userDAL, authService: loginService, serverCfgDAL: superAdminDAL, - orgService + orgService, + keyStore }); const apiKeyService = apiKeyServiceFactory({ apiKeyDAL, userDAL }); @@ -266,26 +441,73 @@ export const registerRoutes = async ( secretScanningDAL, secretScanningQueue }); - const projectService = projectServiceFactory({ - permissionService, - projectDAL, - secretBlindIndexDAL, - projectEnvDAL, - projectMembershipDAL, - folderDAL, - licenseService - }); + const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL }); + const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDAL, + projectUserMembershipRoleDAL, projectDAL, permissionService, + projectBotDAL, orgDAL, userDAL, + userGroupMembershipDAL, smtpService, projectKeyDAL, projectRoleDAL, licenseService }); + const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ + permissionService, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL + }); + const projectKeyService = projectKeyServiceFactory({ + permissionService, + projectKeyDAL, + projectMembershipDAL + }); + + const projectQueueService = projectQueueFactory({ + queueService, + secretDAL, + folderDAL, + projectDAL, + orgDAL, + integrationAuthDAL, + orgService, + projectEnvDAL, + userDAL, + secretVersionDAL, + projectKeyDAL, + projectBotDAL, + projectMembershipDAL, + secretApprovalRequestDAL, + secretApprovalSecretDAL: sarSecretDAL, + projectUserMembershipRoleDAL + }); + + const projectService = projectServiceFactory({ + permissionService, + projectDAL, + projectQueue: projectQueueService, + secretBlindIndexDAL, + identityProjectDAL, + identityOrgMembershipDAL, + projectBotDAL, + projectKeyDAL, + userDAL, + projectEnvDAL, + orgDAL, + orgService, + projectMembershipDAL, + folderDAL, + licenseService, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL, + keyStore + }); + const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, @@ -293,12 +515,13 @@ export const registerRoutes = async ( projectDAL, folderDAL }); - const projectKeyService = projectKeyServiceFactory({ + + const projectRoleService = projectRoleServiceFactory({ permissionService, - projectKeyDAL, - projectMembershipDAL + projectRoleDAL, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL }); - const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDAL }); const snapshotService = secretSnapshotServiceFactory({ permissionService, @@ -325,16 +548,10 @@ export const registerRoutes = async ( folderDAL, folderVersionDAL, projectEnvDAL, - snapshotService + snapshotService, + projectDAL }); - const secretImportService = secretImportServiceFactory({ - projectEnvDAL, - folderDAL, - permissionService, - secretImportDAL, - secretDAL - }); - const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL }); + const integrationAuthService = integrationAuthServiceFactory({ integrationAuthDAL, integrationDAL, @@ -355,7 +572,21 @@ export const registerRoutes = async ( orgDAL, projectMembershipDAL, smtpService, - projectDAL + projectDAL, + projectBotDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL + }); + const secretImportService = secretImportServiceFactory({ + projectEnvDAL, + folderDAL, + permissionService, + secretImportDAL, + projectDAL, + secretDAL, + secretQueueService }); const secretBlindIndexService = secretBlindIndexServiceFactory({ permissionService, @@ -368,26 +599,56 @@ export const registerRoutes = async ( secretVersionTagDAL, secretBlindIndexDAL, permissionService, + projectDAL, secretDAL, secretTagDAL, snapshotService, secretQueueService, secretImportDAL, + projectEnvDAL, projectBotService }); const sarService = secretApprovalRequestServiceFactory({ permissionService, + projectBotService, folderDAL, + secretDAL, secretTagDAL, secretApprovalRequestSecretDAL: sarSecretDAL, secretApprovalRequestReviewerDAL: sarReviewerDAL, + projectDAL, secretVersionDAL, secretBlindIndexDAL, secretApprovalRequestDAL, secretService, snapshotService, + secretVersionTagDAL, secretQueueService }); + + const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + permissionService, + projectEnvDAL, + projectMembershipDAL, + projectDAL + }); + + const accessApprovalRequestService = accessApprovalRequestServiceFactory({ + projectDAL, + permissionService, + accessApprovalRequestReviewerDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + projectMembershipDAL, + accessApprovalPolicyDAL, + accessApprovalRequestDAL, + projectEnvDAL, + userDAL, + smtpService, + accessApprovalPolicyApproverDAL + }); + const secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -418,7 +679,8 @@ export const registerRoutes = async ( projectEnvDAL, serviceTokenDAL, userDAL, - permissionService + permissionService, + projectDAL }); const identityService = identityServiceFactory({ @@ -426,12 +688,23 @@ export const registerRoutes = async ( identityDAL, identityOrgMembershipDAL }); - const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL }); + const identityAccessTokenService = identityAccessTokenServiceFactory({ + identityAccessTokenDAL, + identityOrgMembershipDAL + }); const identityProjectService = identityProjectServiceFactory({ permissionService, projectDAL, identityProjectDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + identityProjectMembershipRoleDAL, + projectRoleDAL + }); + const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({ + projectDAL, + identityProjectAdditionalPrivilegeDAL, + permissionService, + identityProjectDAL }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, @@ -442,17 +715,77 @@ export const registerRoutes = async ( identityUaDAL, licenseService }); + const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ + identityKubernetesAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + orgBotDAL, + permissionService, + licenseService + }); + const identityGcpAuthService = identityGcpAuthServiceFactory({ + identityGcpAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); + + const identityAwsAuthService = identityAwsAuthServiceFactory({ + identityAccessTokenDAL, + identityAwsAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService + }); + + const dynamicSecretProviders = buildDynamicSecretProviders(); + const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ + queueService, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL + }); + const dynamicSecretService = dynamicSecretServiceFactory({ + projectDAL, + dynamicSecretQueueService, + dynamicSecretDAL, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + folderDAL, + permissionService, + licenseService + }); + const dynamicSecretLeaseService = dynamicSecretLeaseServiceFactory({ + projectDAL, + permissionService, + dynamicSecretQueueService, + dynamicSecretDAL, + dynamicSecretLeaseDAL, + dynamicSecretProviders, + folderDAL, + licenseService + }); await superAdminService.initServerCfg(); - await auditLogQueue.startAuditLogPruneJob(); + // // setup the communication with license key server await licenseService.init(); + + await auditLogQueue.startAuditLogPruneJob(); + await telemetryQueue.startTelemetryCheck(); + // inject all services server.decorate("services", { login: loginService, password: passwordService, signup: signupService, user: userService, + group: groupService, + groupProject: groupProjectService, permission: permissionService, org: orgService, orgRole: orgRoleService, @@ -477,17 +810,29 @@ export const registerRoutes = async ( identityAccessToken: identityAccessTokenService, identityProject: identityProjectService, identityUa: identityUaService, + identityKubernetesAuth: identityKubernetesAuthService, + identityGcpAuth: identityGcpAuthService, + identityAwsAuth: identityAwsAuthService, secretApprovalPolicy: sapService, + accessApprovalPolicy: accessApprovalPolicyService, + accessApprovalRequest: accessApprovalRequestService, secretApprovalRequest: sarService, secretRotation: secretRotationService, + dynamicSecret: dynamicSecretService, + dynamicSecretLease: dynamicSecretLeaseService, snapshot: snapshotService, saml: samlService, + ldap: ldapService, auditLog: auditLogService, + auditLogStream: auditLogStreamService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, + scim: scimService, secretBlindIndex: secretBlindIndexService, - telemetry: telemetryService + telemetry: telemetryService, + projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService }); server.decorate("store", { @@ -499,8 +844,11 @@ export const registerRoutes = async ( await server.register(injectAuditLogInfo); server.route({ - url: "/api/status", method: "GET", + url: "/api/status", + config: { + rateLimit: readLimit + }, schema: { response: { 200: z.object({ @@ -537,4 +885,8 @@ export const registerRoutes = async ( ); await server.register(registerV2Routes, { prefix: "/api/v2" }); await server.register(registerV3Routes, { prefix: "/api/v3" }); + + server.addHook("onClose", async () => { + await telemetryService.flushAll(); + }); }; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 03e48c247..cf9f23851 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -1,6 +1,14 @@ import { z } from "zod"; -import { IntegrationAuthsSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; +import { + DynamicSecretsSchema, + IdentityProjectAdditionalPrivilegeSchema, + IntegrationAuthsSchema, + SecretApprovalPoliciesSchema, + UsersSchema +} from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; // sometimes the return data must be santizied to avoid leaking important values // always prefer pick over omit in zod @@ -56,3 +64,45 @@ export const secretRawSchema = z.object({ secretValue: z.string(), secretComment: z.string().optional() }); + +export const ProjectPermissionSchema = z.object({ + action: z + .nativeEnum(ProjectPermissionActions) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read"), + subject: z + .nativeEnum(ProjectPermissionSub) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("The environment slug this permission should allow.").optional(), + secretPath: z + .object({ + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") + }) + .optional() + }) + .describe("When specified, only matching conditions will be allowed to access given resource.") + .optional() +}); + +export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + +export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ + inputIV: true, + inputTag: true, + inputCiphertext: true, + keyEncoding: true, + algorithm: true +}); + +export const SanitizedAuditLogStreamSchema = z.object({ + id: z.string(), + url: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index e23f68c3a..572409d9b 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,8 +1,9 @@ import { z } from "zod"; -import { SuperAdminSchema, UsersSchema } from "@app/db/schemas"; +import { OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { UnauthorizedError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -11,27 +12,46 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerAdminRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/config", method: "GET", + url: "/config", + config: { + rateLimit: readLimit + }, schema: { response: { 200: z.object({ - config: SuperAdminSchema + config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }).extend({ + isMigrationModeOn: z.boolean(), + isSecretScanningDisabled: z.boolean() + }) }) } }, handler: async () => { const config = await getServerCfg(); - return { config }; + const serverEnvs = getConfig(); + return { + config: { + ...config, + isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, + isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING + } + }; } }); server.route({ - url: "/config", method: "PATCH", + url: "/config", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ - allowSignUp: z.boolean().optional() + allowSignUp: z.boolean().optional(), + allowedSignUpDomain: z.string().optional().nullable(), + trustSamlEmails: z.boolean().optional(), + trustLdapEmails: z.boolean().optional() }), response: { 200: z.object({ @@ -51,8 +71,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/signup", method: "POST", + url: "/signup", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ email: z.string().email().trim(), @@ -72,7 +95,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { 200: z.object({ message: z.string(), user: UsersSchema, - token: z.string() + organization: OrganizationsSchema, + token: z.string(), + new: z.string() }) } }, @@ -81,17 +106,18 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { const serverCfg = await getServerCfg(); if (serverCfg.initialized) throw new UnauthorizedError({ name: "Admin sign up", message: "Admin has been created" }); - const { user, token } = await server.services.superAdmin.adminSignUp({ + const { user, token, organization } = await server.services.superAdmin.adminSignUp({ ...req.body, ip: req.realIp, userAgent: req.headers["user-agent"] || "" }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.AdminInit, - distinctId: user.user.email, + distinctId: user.user.username ?? "", properties: { - email: user.user.email, + username: user.user.username, + email: user.user.email ?? "", lastName: user.user.lastName || "", firstName: user.user.firstName || "" } @@ -107,7 +133,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { return { message: "Successfully set up admin account", user: user.user, - token: token.access + token: token.access, + organization, + new: "123" }; } }); diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 2dc0a5d50..7f09a904b 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode, AuthModeRefreshJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; @@ -21,7 +21,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: async (req, res) => { const appCfg = getConfig(); if (req.auth.authMode === AuthMode.JWT) { @@ -38,8 +38,11 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }); server.route({ - url: "/checkAuth", method: "POST", + url: "/checkAuth", + config: { + rateLimit: writeLimit + }, schema: { response: { 200: z.object({ @@ -52,8 +55,11 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }); server.route({ - url: "/token", method: "POST", + url: "/token", + config: { + rateLimit: writeLimit + }, schema: { response: { 200: z.object({ @@ -85,10 +91,12 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { const token = jwt.sign( { + authMethod: decodedToken.authMethod, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: decodedToken.userId, tokenVersionId: tokenVersion.id, - accessVersion: tokenVersion.accessVersion + accessVersion: tokenVersion.accessVersion, + organizationId: decodedToken.organizationId }, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_AUTH_LIFETIME } diff --git a/backend/src/server/routes/v1/bot-router.ts b/backend/src/server/routes/v1/bot-router.ts index 4c6e07ffe..34a34f843 100644 --- a/backend/src/server/routes/v1/bot-router.ts +++ b/backend/src/server/routes/v1/bot-router.ts @@ -1,13 +1,17 @@ import { z } from "zod"; import { ProjectBotsSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectBotRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:projectId", method: "GET", + url: "/:projectId", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -29,6 +33,8 @@ export const registerProjectBotRouter = async (server: FastifyZodProvider) => { const bot = await server.services.projectBot.findBotByProjectId({ actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, projectId: req.params.projectId }); return { bot }; @@ -36,8 +42,11 @@ export const registerProjectBotRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:botId/active", method: "PATCH", + url: "/:botId/active", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ isActive: z.boolean(), @@ -68,6 +77,8 @@ export const registerProjectBotRouter = async (server: FastifyZodProvider) => { const bot = await server.services.projectBot.setBotActiveState({ actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, botId: req.params.botId, botKey: req.body.botKey, isActive: req.body.isActive diff --git a/backend/src/server/routes/v1/identity-access-token-router.ts b/backend/src/server/routes/v1/identity-access-token-router.ts index 4ddf7b74a..7ed62e679 100644 --- a/backend/src/server/routes/v1/identity-access-token-router.ts +++ b/backend/src/server/routes/v1/identity-access-token-router.ts @@ -1,12 +1,19 @@ import { z } from "zod"; +import { UNIVERSAL_AUTH } from "@app/lib/api-docs"; +import { writeLimit } from "@app/server/config/rateLimiter"; + export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvider) => { server.route({ url: "/token/renew", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { + description: "Renew access token", body: z.object({ - accessToken: z.string().trim() + accessToken: z.string().trim().describe(UNIVERSAL_AUTH.RENEW_ACCESS_TOKEN.accessToken) }), response: { 200: z.object({ @@ -29,4 +36,29 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid }; } }); + + server.route({ + url: "/token/revoke", + method: "POST", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Revoke access token", + body: z.object({ + accessToken: z.string().trim().describe(UNIVERSAL_AUTH.REVOKE_ACCESS_TOKEN.accessToken) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + await server.services.identityAccessToken.revokeAccessToken(req.body.accessToken); + return { + message: "Successfully revoked access token" + }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts new file mode 100644 index 000000000..f8c045168 --- /dev/null +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; + +import { IdentityAwsAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AWS_AUTH } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { + validateAccountIds, + validatePrincipalArns +} from "@app/services/identity-aws-auth/identity-aws-auth-validators"; + +export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/aws-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with AWS Auth", + body: z.object({ + identityId: z.string().describe(AWS_AUTH.LOGIN.identityId), + iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), + iamRequestBody: z.string().describe(AWS_AUTH.LOGIN.iamRequestBody), + iamRequestHeaders: z.string().describe(AWS_AUTH.LOGIN.iamRequestHeaders) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAwsAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAwsAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAwsAuthId: identityAwsAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach AWS Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + stsEndpoint: z.string().trim().min(1).default("https://sts.amazonaws.com/"), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.attachAwsAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId, + stsEndpoint: identityAwsAuth.stsEndpoint, + allowedPrincipalArns: identityAwsAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsAuth.allowedAccountIds, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAwsAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update AWS Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z.object({ + stsEndpoint: z.string().trim().min(1).optional(), + allowedPrincipalArns: validatePrincipalArns, + allowedAccountIds: validateAccountIds, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.updateAwsAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId, + stsEndpoint: identityAwsAuth.stsEndpoint, + allowedPrincipalArns: identityAwsAuth.allowedPrincipalArns, + allowedAccountIds: identityAwsAuth.allowedAccountIds, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAwsAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAwsAuth }; + } + }); + + server.route({ + method: "GET", + url: "/aws-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve AWS Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAwsAuth: IdentityAwsAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAwsAuth = await server.services.identityAwsAuth.getAwsAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAwsAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AWS_AUTH, + metadata: { + identityId: identityAwsAuth.identityId + } + } + }); + return { identityAwsAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts new file mode 100644 index 000000000..58654f220 --- /dev/null +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -0,0 +1,268 @@ +import { z } from "zod"; + +import { IdentityGcpAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { validateGcpAuthField } from "@app/services/identity-gcp-auth/identity-gcp-auth-validators"; + +export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/gcp-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with GCP Auth", + body: z.object({ + identityId: z.string(), + jwt: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityGcpAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityGcpAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityGcpAuthId: identityGcpAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach GCP Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + type: z.enum(["iam", "gce"]), + allowedServiceAccounts: validateGcpAuthField, + allowedProjects: validateGcpAuthField, + allowedZones: validateGcpAuthField, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.attachGcpAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + type: identityGcpAuth.type, + allowedServiceAccounts: identityGcpAuth.allowedServiceAccounts, + allowedProjects: identityGcpAuth.allowedProjects, + allowedZones: identityGcpAuth.allowedZones, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityGcpAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityGcpAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + type: z.enum(["iam", "gce"]).optional(), + allowedServiceAccounts: validateGcpAuthField, + allowedProjects: validateGcpAuthField, + allowedZones: validateGcpAuthField, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.updateGcpAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId, + type: identityGcpAuth.type, + allowedServiceAccounts: identityGcpAuth.allowedServiceAccounts, + allowedProjects: identityGcpAuth.allowedProjects, + allowedZones: identityGcpAuth.allowedZones, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityGcpAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityGcpAuth }; + } + }); + + server.route({ + method: "GET", + url: "/gcp-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve GCP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityGcpAuth: IdentityGcpAuthsSchema + }) + } + }, + handler: async (req) => { + const identityGcpAuth = await server.services.identityGcpAuth.getGcpAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityGcpAuth.orgId, + event: { + type: EventType.GET_IDENTITY_GCP_AUTH, + metadata: { + identityId: identityGcpAuth.identityId + } + } + }); + + return { identityGcpAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts new file mode 100644 index 000000000..d20ea0edc --- /dev/null +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -0,0 +1,283 @@ +import { z } from "zod"; + +import { IdentityKubernetesAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; + +const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.omit({ + encryptedCaCert: true, + caCertIV: true, + caCertTag: true, + encryptedTokenReviewerJwt: true, + tokenReviewerJwtIV: true, + tokenReviewerJwtTag: true +}).extend({ + caCert: z.string(), + tokenReviewerJwt: z.string() +}); + +export const registerIdentityKubernetesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/kubernetes-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Kubernetes Auth", + body: z.object({ + identityId: z.string().trim(), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityKubernetesAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityKubernetesAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityKubernetesAuthId: identityKubernetesAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Kubernetes Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + kubernetesHost: z.string().trim().min(1), + caCert: z.string().trim().default(""), + tokenReviewerJwt: z.string().trim().min(1), + allowedNamespaces: z.string(), // TODO: validation + allowedNames: z.string(), + allowedAudience: z.string(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.attachKubernetesAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + kubernetesHost: identityKubernetesAuth.kubernetesHost, + allowedNamespaces: identityKubernetesAuth.allowedNamespaces, + allowedNames: identityKubernetesAuth.allowedNames, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityKubernetesAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; + } + }); + + server.route({ + method: "PATCH", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z.object({ + kubernetesHost: z.string().trim().min(1).optional(), + caCert: z.string().trim().optional(), + tokenReviewerJwt: z.string().trim().min(1).optional(), + allowedNamespaces: z.string().optional(), // TODO: validation + allowedNames: z.string().optional(), + allowedAudience: z.string().optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthsSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.updateKubernetesAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId, + kubernetesHost: identityKubernetesAuth.kubernetesHost, + allowedNamespaces: identityKubernetesAuth.allowedNamespaces, + allowedNames: identityKubernetesAuth.allowedNames, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityKubernetesAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityKubernetesAuth }; + } + }); + + server.route({ + method: "GET", + url: "/kubernetes-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Kubernetes Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityKubernetesAuth = await server.services.identityKubernetesAuth.getKubernetesAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityKubernetesAuth.orgId, + event: { + type: EventType.GET_IDENTITY_KUBERNETES_AUTH, + metadata: { + identityId: identityKubernetesAuth.identityId + } + } + }); + + return { identityKubernetesAuth: IdentityKubernetesAuthResponseSchema.parse(identityKubernetesAuth) }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index f86cc9528..e174cf974 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -2,19 +2,32 @@ import { z } from "zod"; import { IdentitiesSchema, OrgMembershipRole } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { IDENTITIES } from "@app/lib/api-docs"; +import { creationLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/", - onRequest: verifyAuth([AuthMode.JWT]), + config: { + rateLimit: creationLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Create identity", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - name: z.string().trim(), - organizationId: z.string().trim(), - role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess) + name: z.string().trim().describe(IDENTITIES.CREATE.name), + organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId), + role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role) }), response: { 200: z.object({ @@ -26,6 +39,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { const identity = await server.services.identity.createIdentity({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, orgId: req.body.organizationId }); @@ -42,6 +57,17 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }); + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.MachineIdentityCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + orgId: req.body.organizationId, + name: identity.name, + identityId: identity.id, + ...req.auditLogInfo + } + }); + return { identity }; } }); @@ -49,14 +75,23 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", url: "/:identityId", - onRequest: verifyAuth([AuthMode.JWT]), + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Update identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string() + identityId: z.string().describe(IDENTITIES.UPDATE.identityId) }), body: z.object({ - name: z.string().trim().optional(), - role: z.string().trim().min(1).optional() + name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name), + role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role) }), response: { 200: z.object({ @@ -68,6 +103,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { const identity = await server.services.identity.updateIdentity({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.identityId, ...req.body }); @@ -91,10 +128,19 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:identityId", - onRequest: verifyAuth([AuthMode.JWT]), + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Delete identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string() + identityId: z.string().describe(IDENTITIES.DELETE.identityId) }), response: { 200: z.object({ @@ -106,6 +152,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { const identity = await server.services.identity.deleteIdentity({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.identityId }); diff --git a/backend/src/server/routes/v1/identity-ua.ts b/backend/src/server/routes/v1/identity-ua.ts index d92d2a61a..670f52416 100644 --- a/backend/src/server/routes/v1/identity-ua.ts +++ b/backend/src/server/routes/v1/identity-ua.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { IdentityUaClientSecretsSchema, IdentityUniversalAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { UNIVERSAL_AUTH } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -21,12 +23,16 @@ export const sanitizedClientSecretSchema = IdentityUaClientSecretsSchema.pick({ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/universal-auth/login", method: "POST", + url: "/universal-auth/login", + config: { + rateLimit: writeLimit + }, schema: { + description: "Login with Universal Auth", body: z.object({ - clientId: z.string().trim(), - clientSecret: z.string().trim() + clientId: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientId), + clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret) }), response: { 200: z.object({ @@ -38,11 +44,12 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityUa, accessToken, identityAccessToken, validClientSecretInfo } = + const { identityUa, accessToken, identityAccessToken, validClientSecretInfo, identityMembershipOrg } = await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, event: { type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH, metadata: { @@ -63,12 +70,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/universal-auth/identities/:identityId", method: "POST", + url: "/universal-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Attach Universal Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string().trim() + identityId: z.string().trim().describe(UNIVERSAL_AUTH.ATTACH.identityId) }), body: z.object({ clientSecretTrustedIps: z @@ -77,14 +93,16 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }) .array() .min(1) - .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(UNIVERSAL_AUTH.ATTACH.clientSecretTrustedIps), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() }) .array() .min(1) - .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(UNIVERSAL_AUTH.ATTACH.accessTokenTrustedIps), accessTokenTTL: z .number() .int() @@ -92,15 +110,22 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .refine((value) => value !== 0, { message: "accessTokenTTL must have a non zero number" }) - .default(2592000), + .default(2592000) + .describe(UNIVERSAL_AUTH.ATTACH.accessTokenTTL), // 30 days accessTokenMaxTTL: z .number() .int() .refine((value) => value !== 0, { message: "accessTokenMaxTTL must have a non zero number" }) - .default(2592000), // 30 days - accessTokenNumUsesLimit: z.number().int().min(0).default(0) + .default(2592000) + .describe(UNIVERSAL_AUTH.ATTACH.accessTokenMaxTTL), // 30 days + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit) }), response: { 200: z.object({ @@ -112,6 +137,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { const identityUniversalAuth = await server.services.identityUa.attachUa({ actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, ...req.body, identityId: req.params.identityId }); @@ -136,12 +163,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/universal-auth/identities/:identityId", method: "PATCH", + url: "/universal-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Update Universal Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string() + identityId: z.string().describe(UNIVERSAL_AUTH.UPDATE.identityId) }), body: z.object({ clientSecretTrustedIps: z @@ -150,16 +186,23 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }) .array() .min(1) - .optional(), + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.clientSecretTrustedIps), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() }) .array() .min(1) - .optional(), - accessTokenTTL: z.number().int().min(0).optional(), - accessTokenNumUsesLimit: z.number().int().min(0).optional(), + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z.number().int().min(0).optional().describe(UNIVERSAL_AUTH.UPDATE.accessTokenTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenNumUsesLimit), accessTokenMaxTTL: z .number() .int() @@ -167,6 +210,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { message: "accessTokenMaxTTL must have a non zero number" }) .optional() + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenMaxTTL) }), response: { 200: z.object({ @@ -178,6 +222,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { const identityUniversalAuth = await server.services.identityUa.updateUa({ actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, ...req.body, identityId: req.params.identityId }); @@ -203,12 +249,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/universal-auth/identities/:identityId", method: "GET", + url: "/universal-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Retrieve Universal Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string() + identityId: z.string().describe(UNIVERSAL_AUTH.RETRIEVE.identityId) }), response: { 200: z.object({ @@ -220,6 +275,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { const identityUniversalAuth = await server.services.identityUa.getIdentityUa({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId }); @@ -239,17 +296,26 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/universal-auth/identities/:identityId/client-secrets", method: "POST", + url: "/universal-auth/identities/:identityId/client-secrets", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Create Universal Auth Client Secret for identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string() + identityId: z.string().describe(UNIVERSAL_AUTH.CREATE_CLIENT_SECRET.identityId) }), body: z.object({ - description: z.string().trim().default(""), - numUsesLimit: z.number().min(0).default(0), - ttl: z.number().min(0).default(0) + description: z.string().trim().default("").describe(UNIVERSAL_AUTH.CREATE_CLIENT_SECRET.description), + numUsesLimit: z.number().min(0).default(0).describe(UNIVERSAL_AUTH.CREATE_CLIENT_SECRET.numUsesLimit), + ttl: z.number().min(0).default(0).describe(UNIVERSAL_AUTH.CREATE_CLIENT_SECRET.ttl) }), response: { 200: z.object({ @@ -262,6 +328,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { const { clientSecret, clientSecretData, orgId } = await server.services.identityUa.createUaClientSecret({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId, ...req.body }); @@ -283,12 +351,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/universal-auth/identities/:identityId/client-secrets", method: "GET", + url: "/universal-auth/identities/:identityId/client-secrets", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "List Universal Auth Client Secrets for identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string() + identityId: z.string().describe(UNIVERSAL_AUTH.LIST_CLIENT_SECRETS.identityId) }), response: { 200: z.object({ @@ -300,6 +377,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { const { clientSecrets: clientSecretData, orgId } = await server.services.identityUa.getUaClientSecrets({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId }); @@ -318,13 +397,22 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/universal-auth/identities/:identityId/client-secrets/:clientSecretId/revoke", method: "POST", + url: "/universal-auth/identities/:identityId/client-secrets/:clientSecretId/revoke", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Revoke Universal Auth Client Secrets for identity", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - identityId: z.string(), - clientSecretId: z.string() + identityId: z.string().describe(UNIVERSAL_AUTH.REVOKE_CLIENT_SECRET.identityId), + clientSecretId: z.string().describe(UNIVERSAL_AUTH.REVOKE_CLIENT_SECRET.clientSecretId) }), response: { 200: z.object({ @@ -336,6 +424,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { const clientSecretData = await server.services.identityUa.revokeUaClientSecret({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId, clientSecretId: req.params.clientSecretId }); diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 744ba6ebf..262e3cb20 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -2,6 +2,9 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; +import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; +import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; +import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityUaRouter } from "./identity-ua"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -27,7 +30,10 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (authRouter) => { await authRouter.register(registerAuthRoutes); await authRouter.register(registerIdentityUaRouter); + await authRouter.register(registerIdentityKubernetesRouter); + await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); + await authRouter.register(registerIdentityAwsAuthRouter); }, { prefix: "/auth" } ); @@ -48,6 +54,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await projectRouter.register(registerProjectMembershipRouter); await projectRouter.register(registerSecretTagRouter); }, + { prefix: "/workspace" } ); diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 1d92813f2..d9db7404e 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { INTEGRATION_AUTH } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,10 +10,19 @@ import { integrationAuthPubSchema } from "../sanitizedSchemas"; export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/integration-options", method: "GET", - onRequest: verifyAuth([AuthMode.JWT]), + url: "/integration-options", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "List of integrations available.", + security: [ + { + bearerAuth: [] + } + ], response: { 200: z.object({ integrationOptions: z @@ -36,12 +47,21 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId", method: "GET", - onRequest: verifyAuth([AuthMode.JWT]), + url: "/:integrationAuthId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Get details of an integration authorization by auth object id.", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - integrationAuthId: z.string().trim() + integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.GET.integrationAuthId) }), response: { 200: z.object({ @@ -53,6 +73,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const integrationAuth = await server.services.integrationAuth.getIntegrationAuth({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId }); return { integrationAuth }; @@ -60,13 +82,22 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/", method: "DELETE", - onRequest: verifyAuth([AuthMode.JWT]), + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Remove all integration's auth object from the project.", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - integration: z.string().trim(), - projectId: z.string().trim() + integration: z.string().trim().describe(INTEGRATION_AUTH.DELETE.integration), + projectId: z.string().trim().describe(INTEGRATION_AUTH.DELETE.projectId) }), response: { 200: z.object({ @@ -78,6 +109,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const integrationAuth = await server.services.integrationAuth.deleteIntegrationAuths({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, integration: req.query.integration, projectId: req.query.projectId }); @@ -98,12 +131,21 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId", method: "DELETE", - onRequest: verifyAuth([AuthMode.JWT]), + url: "/:integrationAuthId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Remove an integration auth object by object id.", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - integrationAuthId: z.string().trim() + integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.DELETE_BY_ID.integrationAuthId) }), response: { 200: z.object({ @@ -115,6 +157,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const integrationAuth = await server.services.integrationAuth.deleteIntegrationAuthById({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId }); @@ -134,8 +178,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/oauth-token", method: "POST", + url: "/oauth-token", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ @@ -154,6 +201,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const integrationAuth = await server.services.integrationAuth.oauthExchange({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.body.workspaceId, ...req.body }); @@ -173,18 +222,27 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/access-token", method: "POST", - onRequest: verifyAuth([AuthMode.JWT]), + url: "/access-token", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Create the integration authentication object required for syncing secrets.", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - workspaceId: z.string().trim(), - integration: z.string().trim(), - accessId: z.string().trim().optional(), - accessToken: z.string().trim().optional(), - url: z.string().url().trim().optional(), - namespace: z.string().trim().optional(), - refreshToken: z.string().trim().optional() + workspaceId: z.string().trim().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.workspaceId), + integration: z.string().trim().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), + accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), + url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url), + namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace), + refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken) }), response: { 200: z.object({ @@ -196,6 +254,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const integrationAuth = await server.services.integrationAuth.saveIntegrationToken({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.body.workspaceId, ...req.body }); @@ -215,8 +275,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/apps", method: "GET", + url: "/:integrationAuthId/apps", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -242,6 +305,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const apps = await server.services.integrationAuth.getIntegrationApps({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, ...req.query }); @@ -250,8 +315,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/teams", method: "GET", + url: "/:integrationAuthId/teams", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -272,6 +340,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const teams = await server.services.integrationAuth.getIntegrationAuthTeams({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId }); return { teams }; @@ -279,8 +349,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/vercel/branches", method: "GET", + url: "/:integrationAuthId/vercel/branches", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -299,6 +372,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const branches = await server.services.integrationAuth.getVercelBranches({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, appId: req.query.appId }); @@ -307,8 +382,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/checkly/groups", method: "GET", + url: "/:integrationAuthId/checkly/groups", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -327,6 +405,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const groups = await server.services.integrationAuth.getChecklyGroups({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, accountId: req.query.accountId }); @@ -335,8 +415,79 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/qovery/orgs", method: "GET", + url: "/:integrationAuthId/github/orgs", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + orgs: z.object({ name: z.string(), orgId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const orgs = await server.services.integrationAuth.getGithubOrgs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.integrationAuthId + }); + if (!orgs) throw new Error("No organization found."); + + return { orgs }; + } + }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/github/envs", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + repoOwner: z.string().trim(), + repoName: z.string().trim() + }), + response: { + 200: z.object({ + envs: z.object({ name: z.string(), envId: z.string() }).array() + }) + } + }, + handler: async (req) => { + const envs = await server.services.integrationAuth.getGithubEnvs({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId, + actorAuthMethod: req.permission.authMethod, + repoName: req.query.repoName, + repoOwner: req.query.repoOwner + }); + if (!envs) throw new Error("No organization found."); + + return { envs }; + } + }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/qovery/orgs", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -352,6 +503,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const orgs = await server.services.integrationAuth.getQoveryOrgs({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId }); return { orgs }; @@ -359,8 +512,44 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/qovery/projects", method: "GET", + url: "/:integrationAuthId/aws-secrets-manager/kms-keys", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + region: z.string().trim() + }), + response: { + 200: z.object({ + kmsKeys: z.object({ id: z.string(), alias: z.string() }).array() + }) + } + }, + handler: async (req) => { + const kmsKeys = await server.services.integrationAuth.getAwsKmsKeys({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId, + region: req.query.region + }); + return { kmsKeys }; + } + }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/qovery/projects", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -379,6 +568,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const projects = await server.services.integrationAuth.getQoveryProjects({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, orgId: req.query.orgId }); @@ -387,8 +578,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/qovery/environments", method: "GET", + url: "/:integrationAuthId/qovery/environments", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -407,6 +601,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const environments = await server.services.integrationAuth.getQoveryEnvs({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, projectId: req.query.projectId }); @@ -415,8 +611,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/qovery/apps", method: "GET", + url: "/:integrationAuthId/qovery/apps", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -435,6 +634,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const apps = await server.services.integrationAuth.getQoveryApps({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, environmentId: req.query.environmentId }); @@ -443,8 +644,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/qovery/containers", method: "GET", + url: "/:integrationAuthId/qovery/containers", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -463,6 +667,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const containers = await server.services.integrationAuth.getQoveryContainers({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, environmentId: req.query.environmentId }); @@ -471,8 +677,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/qovery/jobs", method: "GET", + url: "/:integrationAuthId/qovery/jobs", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -491,6 +700,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const jobs = await server.services.integrationAuth.getQoveryJobs({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, environmentId: req.query.environmentId }); @@ -499,8 +710,46 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/railway/environments", method: "GET", + url: "/:integrationAuthId/heroku/pipelines", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + pipelines: z + .object({ + app: z.object({ appId: z.string() }), + stage: z.string(), + pipeline: z.object({ name: z.string(), pipelineId: z.string() }) + }) + .array() + }) + } + }, + handler: async (req) => { + const pipelines = await server.services.integrationAuth.getHerokuPipelines({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + return { pipelines }; + } + }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/railway/environments", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -519,6 +768,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const environments = await server.services.integrationAuth.getRailwayEnvironments({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, appId: req.query.appId }); @@ -527,8 +778,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/railway/services", method: "GET", + url: "/:integrationAuthId/railway/services", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -547,6 +801,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const services = await server.services.integrationAuth.getRailwayServices({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, appId: req.query.appId }); @@ -555,8 +811,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/bitbucket/workspaces", method: "GET", + url: "/:integrationAuthId/bitbucket/workspaces", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -582,6 +841,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const workspaces = await server.services.integrationAuth.getBitbucketWorkspaces({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId }); return { workspaces }; @@ -589,8 +850,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/northflank/secret-groups", method: "GET", + url: "/:integrationAuthId/northflank/secret-groups", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -614,6 +878,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const secretGroups = await server.services.integrationAuth.getNorthFlankSecretGroups({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, appId: req.query.appId }); @@ -622,8 +888,11 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) }); server.route({ - url: "/:integrationAuthId/teamcity/build-configs", method: "GET", + url: "/:integrationAuthId/teamcity/build-configs", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -647,6 +916,8 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) const buildConfigs = await server.services.integrationAuth.getTeamcityBuildConfigs({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationAuthId, appId: req.query.appId }); diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index a7df57a54..f23abc45b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -2,40 +2,189 @@ import { z } from "zod"; import { IntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { INTEGRATION } from "@app/lib/api-docs"; import { removeTrailingSlash, shake } from "@app/lib/fn"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { IntegrationMappingBehavior } from "@app/services/integration-auth/integration-list"; +import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, schema: { + description: "Create an integration to sync secrets.", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - integrationAuthId: z.string().trim(), - app: z.string().trim().optional(), - isActive: z.boolean(), - appId: z.string().trim().optional(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - sourceEnvironment: z.string().trim(), - targetEnvironment: z.string().trim().optional(), - targetEnvironmentId: z.string().trim().optional(), - targetService: z.string().trim().optional(), - targetServiceId: z.string().trim().optional(), - owner: z.string().trim().optional(), - path: z.string().trim().optional(), - region: z.string().trim().optional(), - scope: z.string().trim().optional(), + integrationAuthId: z.string().trim().describe(INTEGRATION.CREATE.integrationAuthId), + app: z.string().trim().optional().describe(INTEGRATION.CREATE.app), + isActive: z.boolean().describe(INTEGRATION.CREATE.isActive).default(true), + appId: z.string().trim().optional().describe(INTEGRATION.CREATE.appId), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(INTEGRATION.CREATE.secretPath), + sourceEnvironment: z.string().trim().describe(INTEGRATION.CREATE.sourceEnvironment), + targetEnvironment: z.string().trim().optional().describe(INTEGRATION.CREATE.targetEnvironment), + targetEnvironmentId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetEnvironmentId), + targetService: z.string().trim().optional().describe(INTEGRATION.CREATE.targetService), + targetServiceId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetServiceId), + owner: z.string().trim().optional().describe(INTEGRATION.CREATE.owner), + path: z.string().trim().optional().describe(INTEGRATION.CREATE.path), + region: z.string().trim().optional().describe(INTEGRATION.CREATE.region), + scope: z.string().trim().optional().describe(INTEGRATION.CREATE.scope), metadata: z .object({ - secretPrefix: z.string().optional(), - secretSuffix: z.string().optional(), + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), + secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z + .nativeEnum(IntegrationMappingBehavior) + .optional() + .describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), secretGCPLabel: z .object({ labelName: z.string(), labelValue: z.string() }) .optional() + .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) + }) + .default({}) + }), + response: { + 200: z.object({ + integration: IntegrationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { integration, integrationAuth } = await server.services.integration.createIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + const createIntegrationEventProperty = shake({ + integrationId: integration.id.toString(), + integration: integration.integration, + environment: req.body.sourceEnvironment, + secretPath: req.body.secretPath, + url: integration.url, + app: integration.app, + appId: integration.appId, + targetEnvironment: integration.targetEnvironment, + targetEnvironmentId: integration.targetEnvironmentId, + targetService: integration.targetService, + targetServiceId: integration.targetServiceId, + path: integration.path, + region: integration.region + }) as TIntegrationCreatedEvent["properties"]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integrationAuth.projectId, + event: { + type: EventType.CREATE_INTEGRATION, + // eslint-disable-next-line + metadata: createIntegrationEventProperty + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IntegrationCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + ...createIntegrationEventProperty, + projectId: integrationAuth.projectId, + ...req.auditLogInfo + } + }); + return { integration }; + } + }); + + server.route({ + method: "PATCH", + url: "/:integrationId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update an integration by integration id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationId: z.string().trim().describe(INTEGRATION.UPDATE.integrationId) + }), + body: z.object({ + app: z.string().trim().optional().describe(INTEGRATION.UPDATE.app), + appId: z.string().trim().optional().describe(INTEGRATION.UPDATE.appId), + isActive: z.boolean().describe(INTEGRATION.UPDATE.isActive), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(INTEGRATION.UPDATE.secretPath), + targetEnvironment: z.string().trim().describe(INTEGRATION.UPDATE.targetEnvironment), + owner: z.string().trim().describe(INTEGRATION.UPDATE.owner), + environment: z.string().trim().describe(INTEGRATION.UPDATE.environment), + metadata: z + .object({ + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), + secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), + secretGCPLabel: z + .object({ + labelName: z.string(), + labelValue: z.string() + }) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) }) .optional() }), @@ -45,68 +194,13 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { integration, integrationAuth } = await server.services.integration.createIntegration({ - actorId: req.permission.id, - actor: req.permission.type, - ...req.body - }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: integrationAuth.projectId, - event: { - type: EventType.CREATE_INTEGRATION, - // eslint-disable-next-line - metadata: shake({ - integrationId: integration.id.toString(), - integration: integration.integration, - environment: req.body.sourceEnvironment, - secretPath: req.body.secretPath, - url: integration.url, - app: integration.app, - appId: integration.appId, - targetEnvironment: integration.targetEnvironment, - targetEnvironmentId: integration.targetEnvironmentId, - targetService: integration.targetService, - targetServiceId: integration.targetServiceId, - path: integration.path, - region: integration.region - // eslint-disable-next-line - }) as any - } - }); - return { integration }; - } - }); - - server.route({ - url: "/:integrationId", - method: "PATCH", - schema: { - params: z.object({ - integrationId: z.string().trim() - }), - body: z.object({ - app: z.string().trim(), - appId: z.string().trim(), - isActive: z.boolean(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - targetEnvironment: z.string().trim(), - owner: z.string().trim(), - environment: z.string().trim() - }), - response: { - 200: z.object({ - integration: IntegrationsSchema - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const integration = await server.services.integration.updateIntegration({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.integrationId, ...req.body }); @@ -115,11 +209,20 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:integrationId", method: "DELETE", + url: "/:integrationId", + config: { + rateLimit: writeLimit + }, schema: { + description: "Remove an integration using the integration object ID", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - integrationId: z.string().trim() + integrationId: z.string().trim().describe(INTEGRATION.DELETE.integrationId) }), response: { 200: z.object({ @@ -127,11 +230,13 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const integration = await server.services.integration.deleteIntegration({ actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, actor: req.permission.type, + actorOrgId: req.permission.orgId, id: req.params.integrationId }); @@ -163,5 +268,64 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { } }); - // TODO(akhilmhdh-pg): manual sync + server.route({ + method: "POST", + url: "/:integrationId/sync", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Manually trigger sync of an integration by integration id", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + integrationId: z.string().trim().describe(INTEGRATION.SYNC.integrationId) + }), + response: { + 200: z.object({ + integration: IntegrationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const integration = await server.services.integration.syncIntegration({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integration.projectId, + event: { + type: EventType.MANUAL_SYNC_INTEGRATION, + // eslint-disable-next-line + metadata: shake({ + integrationId: integration.id, + integration: integration.integration, + environment: integration.environment.slug, + secretPath: integration.secretPath, + url: integration.url, + app: integration.app, + appId: integration.appId, + targetEnvironment: integration.targetEnvironment, + targetEnvironmentId: integration.targetEnvironmentId, + targetService: integration.targetService, + targetServiceId: integration.targetServiceId, + path: integration.path, + region: integration.region + // eslint-disable-next-line + }) as any + } + }); + + return { integration }; + } + }); }; diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 67503ac02..873710f10 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,12 +1,18 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; +import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup", + config: { + rateLimit: inviteUserRateLimit + }, method: "POST", schema: { body: z.object({ @@ -26,7 +32,18 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { const completeInviteLink = await server.services.org.inviteUserToOrganization({ orgId: req.body.organizationId, userId: req.permission.id, - inviteeEmail: req.body.inviteeEmail + inviteeEmail: req.body.inviteeEmail, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.UserOrgInvitation, + distinctId: getTelemetryDistinctId(req), + properties: { + inviteeEmail: req.body.inviteeEmail, + ...req.auditLogInfo + } }); return { @@ -39,6 +56,9 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/verify", method: "POST", + config: { + rateLimit: inviteUserRateLimit + }, schema: { body: z.object({ email: z.string().trim().email(), diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 1d74e8b1a..808f125bb 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,6 +1,15 @@ import { z } from "zod"; -import { IncidentContactsSchema, OrganizationsSchema, OrgMembershipsSchema, UsersSchema } from "@app/db/schemas"; +import { + GroupsSchema, + IncidentContactsSchema, + OrganizationsSchema, + OrgMembershipsSchema, + OrgRolesSchema, + UsersSchema +} from "@app/db/schemas"; +import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,6 +17,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/", + config: { + rateLimit: readLimit + }, schema: { response: { 200: z.object({ @@ -15,7 +27,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: async (req) => { const organizations = await server.services.org.findAllOrganizationOfUser(req.permission.id); return { organizations }; @@ -25,6 +37,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -37,7 +52,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const organization = await server.services.org.findOrganizationById(req.permission.id, req.params.organizationId); + const organization = await server.services.org.findOrganizationById( + req.permission.id, + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId + ); return { organization }; } }); @@ -45,6 +65,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId/users", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -54,6 +77,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { users: OrgMembershipsSchema.merge( z.object({ user: UsersSchema.pick({ + username: true, email: true, firstName: true, lastName: true, @@ -68,17 +92,35 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const users = await server.services.org.findAllOrgMembers(req.permission.id, req.params.organizationId); + const users = await server.services.org.findAllOrgMembers( + req.permission.id, + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId + ); return { users }; } }); server.route({ method: "PATCH", - url: "/:organizationId/name", + url: "/:organizationId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), - body: z.object({ name: z.string().trim() }), + body: z.object({ + name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), + slug: z + .string() + .trim() + .max(64, { message: "Slug must be 64 or fewer characters" }) + .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") + .optional(), + authEnforced: z.boolean().optional(), + scimEnabled: z.boolean().optional() + }), response: { 200: z.object({ message: z.string(), @@ -88,11 +130,15 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const organization = await server.services.org.updateOrgName( - req.permission.id, - req.params.organizationId, - req.body.name - ); + const organization = await server.services.org.updateOrg({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + orgId: req.params.organizationId, + data: req.body + }); + return { message: "Successfully changed organization name", organization @@ -103,6 +149,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId/incidentContactOrg", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), response: { @@ -115,7 +164,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const incidentContactsOrg = await req.server.services.org.findIncidentContacts( req.permission.id, - req.params.organizationId + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId ); return { incidentContactsOrg }; } @@ -124,6 +175,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:organizationId/incidentContactOrg", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ email: z.string().email().trim() }), @@ -138,7 +192,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const incidentContactsOrg = await req.server.services.org.createIncidentContact( req.permission.id, req.params.organizationId, - req.body.email + req.body.email, + req.permission.authMethod, + req.permission.orgId ); return { incidentContactsOrg }; } @@ -147,6 +203,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:organizationId/incidentContactOrg/:incidentContactId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim(), incidentContactId: z.string().trim() }), response: { @@ -160,9 +219,48 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const incidentContactsOrg = await req.server.services.org.deleteIncidentContact( req.permission.id, req.params.organizationId, - req.params.incidentContactId + req.params.incidentContactId, + req.permission.authMethod, + req.permission.orgId ); return { incidentContactsOrg }; } }); + + server.route({ + method: "GET", + url: "/:organizationId/groups", + schema: { + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_GROUPS.organizationId) + }), + response: { + 200: z.object({ + groups: GroupsSchema.merge( + z.object({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional() + }) + ).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const groups = await server.services.org.getOrgGroups({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index d5c5054df..a8ef3fb77 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { BackupPrivateKeySchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { passwordRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { validateSignUpAuthorization } from "@app/services/auth/auth-fns"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -12,7 +12,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/srp1", config: { - rateLimit: passwordRateLimit + rateLimit: authRateLimit }, schema: { body: z.object({ @@ -39,7 +39,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/change-password", config: { - rateLimit: passwordRateLimit + rateLimit: authRateLimit }, schema: { body: z.object({ @@ -78,7 +78,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-reset", config: { - rateLimit: passwordRateLimit + rateLimit: authRateLimit }, schema: { body: z.object({ @@ -103,7 +103,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-reset-verify", config: { - rateLimit: passwordRateLimit + rateLimit: authRateLimit }, schema: { body: z.object({ @@ -133,7 +133,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/backup-private-key", config: { - rateLimit: passwordRateLimit + rateLimit: authRateLimit }, onRequest: verifyAuth([AuthMode.JWT]), schema: { @@ -168,7 +168,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "GET", url: "/backup-private-key", config: { - rateLimit: passwordRateLimit + rateLimit: authRateLimit }, schema: { response: { @@ -190,6 +190,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/password-reset", + config: { + rateLimit: authRateLimit + }, schema: { body: z.object({ protectedKey: z.string().trim(), diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index 44be1a3d6..341b8a184 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,21 +1,39 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ENVIRONMENTS } from "@app/lib/api-docs"; +import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:workspaceId/environments", method: "POST", + url: "/:workspaceId/environments", + config: { + rateLimit: writeLimit + }, schema: { + description: "Create environment", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(ENVIRONMENTS.CREATE.workspaceId) }), body: z.object({ - name: z.string().trim(), - slug: z.string().trim() + name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), + slug: z + .string() + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -30,6 +48,8 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { const environment = await server.services.projectEnv.createEnvironment({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, projectId: req.params.workspaceId, ...req.body }); @@ -54,17 +74,33 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/environments/:id", method: "PATCH", + url: "/:workspaceId/environments/:id", + config: { + rateLimit: writeLimit + }, schema: { + description: "Update environment", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim(), - id: z.string().trim() + workspaceId: z.string().trim().describe(ENVIRONMENTS.UPDATE.workspaceId), + id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z.string().trim().optional(), - name: z.string().trim().optional(), - position: z.number().optional() + slug: z + .string() + .trim() + .optional() + .refine((v) => !v || slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(ENVIRONMENTS.UPDATE.slug), + name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), + position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), response: { 200: z.object({ @@ -79,6 +115,8 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { const { environment, old } = await server.services.projectEnv.updateEnvironment({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, id: req.params.id, ...req.body @@ -109,12 +147,21 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/environments/:id", method: "DELETE", + url: "/:workspaceId/environments/:id", + config: { + rateLimit: writeLimit + }, schema: { + description: "Delete environment", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim(), - id: z.string().trim() + workspaceId: z.string().trim().describe(ENVIRONMENTS.DELETE.workspaceId), + id: z.string().trim().describe(ENVIRONMENTS.DELETE.id) }), response: { 200: z.object({ @@ -129,6 +176,8 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { const environment = await server.services.projectEnv.deleteEnvironment({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, id: req.params.id }); diff --git a/backend/src/server/routes/v1/project-key-router.ts b/backend/src/server/routes/v1/project-key-router.ts index 482392947..bb35794e9 100644 --- a/backend/src/server/routes/v1/project-key-router.ts +++ b/backend/src/server/routes/v1/project-key-router.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -7,6 +8,9 @@ export const registerProjectKeyRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:workspaceId/key", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -30,6 +34,8 @@ export const registerProjectKeyRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId, actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, nonce: req.body.key.nonce, receiverId: req.body.key.userId, encryptedKey: req.body.key.encryptedKey diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 7d28f470a..6bbb8d7ef 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,30 +1,61 @@ +import ms from "ms"; import { z } from "zod"; -import { OrgMembershipsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + OrgMembershipsSchema, + ProjectMembershipsSchema, + ProjectUserMembershipRolesSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PROJECT_USERS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:workspaceId/memberships", method: "GET", + url: "/:workspaceId/memberships", + config: { + rateLimit: readLimit + }, schema: { + description: "Return project user memberships", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIPS.workspaceId) }), response: { 200: z.object({ - memberships: ProjectMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - email: true, - firstName: true, - lastName: true, - id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) - }) - ) + memberships: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) .omit({ createdAt: true, updatedAt: true }) .array() }) @@ -35,6 +66,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider const memberships = await server.services.projectMembership.getProjectMemberships({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); return { memberships }; @@ -42,8 +75,71 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }); server.route({ - url: "/:workspaceId/memberships", method: "POST", + url: "/:workspaceId/memberships/details", + config: { + rateLimit: readLimit + }, + schema: { + description: "Return project user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.workspaceId) + }), + body: z.object({ + username: z.string().min(1).trim().describe(PROJECT_USERS.GET_USER_MEMBERSHIP.username) + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }).omit({ createdAt: true, updatedAt: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.projectMembership.getProjectMembershipByUsername({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + username: req.body.username + }); + return { membership }; + } + }); + + server.route({ + method: "POST", + url: "/:workspaceId/memberships", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -70,6 +166,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider const data = await server.services.projectMembership.addUsersToProject({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, members: req.body.members }); @@ -91,53 +189,91 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }); server.route({ - url: "/:workspaceId/memberships/:membershipId", method: "PATCH", + url: "/:workspaceId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, schema: { + description: "Update project user membership", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim(), - membershipId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.workspaceId), + membershipId: z.string().trim().describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.membershipId) }), body: z.object({ - role: z.string().trim() + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least one long lived role is required") + .describe(PROJECT_USERS.UPDATE_USER_MEMBERSHIP.roles) }), response: { 200: z.object({ - membership: ProjectMembershipsSchema + roles: ProjectUserMembershipRolesSchema.array() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.updateProjectMembership({ + const roles = await server.services.projectMembership.updateProjectMembership({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, membershipId: req.params.membershipId, - role: req.body.role + roles: req.body.roles }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.UPDATE_USER_WORKSPACE_ROLE, - metadata: { - userId: membership.userId, - newRole: req.body.role, - oldRole: membership.role, - email: "" - } - } - }); - return { membership }; + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: req.params.workspaceId, + // event: { + // type: EventType.UPDATE_USER_WORKSPACE_ROLE, + // metadata: { + // userId: membership.userId, + // newRole: req.body.role, + // oldRole: membership.role, + // email: "" + // } + // } + // }); + return { roles }; } }); server.route({ - url: "/:workspaceId/memberships/:membershipId", method: "DELETE", + url: "/:workspaceId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, schema: { + description: "Delete project user membership", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ workspaceId: z.string().trim(), membershipId: z.string().trim() @@ -153,6 +289,8 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider const membership = await server.services.projectMembership.deleteProjectMembership({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, membershipId: req.params.membershipId }); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index b7574e35c..1cf655a97 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -2,15 +2,16 @@ import { z } from "zod"; import { IntegrationsSchema, - ProjectKeysSchema, ProjectMembershipsSchema, ProjectsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; -import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PROJECTS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectFilterType } from "@app/services/project/project-types"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; import { sanitizedServiceTokenSchema } from "../v2/service-token-router"; @@ -24,8 +25,11 @@ const projectWithEnv = ProjectsSchema.merge( export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:workspaceId/keys", method: "GET", + url: "/:workspaceId/keys", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -46,6 +50,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const publicKeys = await server.services.projectKey.getProjectPublicKeys({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); return { publicKeys }; @@ -53,24 +59,40 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/users", method: "GET", + url: "/:workspaceId/users", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() }), response: { 200: z.object({ - users: ProjectMembershipsSchema.merge( - z.object({ - user: UsersSchema.pick({ - email: true, - firstName: true, - lastName: true, - id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) - }) - ) + users: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) .omit({ createdAt: true, updatedAt: true }) .array() }) @@ -81,15 +103,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const users = await server.services.projectMembership.getProjectMemberships({ actorId: req.permission.id, actor: req.permission.type, - projectId: req.params.workspaceId + actorAuthMethod: req.permission.authMethod, + projectId: req.params.workspaceId, + actorOrgId: req.permission.orgId }); return { users }; } }); server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, schema: { response: { 200: z.object({ @@ -105,11 +132,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId", method: "GET", + url: "/:workspaceId", + config: { + rateLimit: readLimit + }, schema: { + description: "Get project", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECTS.GET.workspaceId) }), response: { 200: z.object({ @@ -117,49 +153,37 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspace = await server.services.project.getAProject({ + filter: { + type: ProjectFilterType.ID, + projectId: req.params.workspaceId + }, + actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, actor: req.permission.type, - projectId: req.params.workspaceId + actorOrgId: req.permission.orgId }); return { workspace }; } }); server.route({ - url: "/", - method: "POST", - schema: { - body: z.object({ - workspaceName: z.string().trim(), - organizationId: z.string().trim() - }), - response: { - 200: z.object({ - workspace: projectWithEnv - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const workspace = await server.services.project.createProject({ - actorId: req.permission.id, - actor: req.permission.type, - orgId: req.body.organizationId, - workspaceName: req.body.workspaceName - }); - return { workspace }; - } - }); - - server.route({ - url: "/:workspaceId", method: "DELETE", + url: "/:workspaceId", + config: { + rateLimit: writeLimit + }, schema: { + description: "Delete project", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECTS.DELETE.workspaceId) }), response: { 200: z.object({ @@ -167,12 +191,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspace = await server.services.project.deleteProject({ + filter: { + type: ProjectFilterType.ID, + projectId: req.params.workspaceId + }, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, actor: req.permission.type, - projectId: req.params.workspaceId + actorOrgId: req.permission.orgId }); return { workspace }; } @@ -181,6 +210,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:workspaceId/name", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -200,6 +232,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const workspace = await server.services.project.updateName({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, name: req.body.name }); @@ -211,8 +245,64 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/auto-capitalization", + method: "PATCH", + url: "/:workspaceId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + workspaceId: z.string().trim().describe(PROJECTS.UPDATE.workspaceId) + }), + body: z.object({ + name: z + .string() + .trim() + .max(64, { message: "Name must be 64 or fewer characters" }) + .optional() + .describe(PROJECTS.UPDATE.name), + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) + }), + response: { + 200: z.object({ + workspace: ProjectsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspace = await server.services.project.updateProject({ + filter: { + type: ProjectFilterType.ID, + projectId: req.params.workspaceId + }, + update: { + name: req.body.name, + autoCapitalization: req.body.autoCapitalization + }, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + return { + workspace + }; + } + }); + + server.route({ method: "POST", + url: "/:workspaceId/auto-capitalization", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -232,6 +322,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const workspace = await server.services.project.toggleAutoCapitalization({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, autoCapitalization: req.body.autoCapitalization }); @@ -243,52 +335,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/invite-signup", - method: "POST", - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - body: z.object({ - email: z.string().trim() - }), - response: { - 200: z.object({ - invitee: UsersSchema, - latestKey: ProjectKeysSchema.optional() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { invitee, latestKey } = await server.services.projectMembership.inviteUserToProject({ - actorId: req.permission.id, - actor: req.permission.type, - projectId: req.params.workspaceId, - email: req.body.email - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.ADD_WORKSPACE_MEMBER, - metadata: { - userId: invitee.id, - email: invitee.email - } - } - }); - return { invitee, latestKey }; - } - }); - - server.route({ - url: "/:workspaceId/integrations", method: "GET", + url: "/:workspaceId/integrations", + config: { + rateLimit: readLimit + }, schema: { + description: "List integrations for a project.", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION.workspaceId) }), response: { 200: z.object({ @@ -304,11 +364,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const integrations = await server.services.integration.listIntegrationByProject({ actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, actor: req.permission.type, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); return { integrations }; @@ -316,11 +378,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/authorizations", method: "GET", + url: "/:workspaceId/authorizations", + config: { + rateLimit: readLimit + }, schema: { + description: "List integration auth objects for a workspace.", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECTS.LIST_INTEGRATION_AUTHORIZATION.workspaceId) }), response: { 200: z.object({ @@ -328,11 +399,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const authorizations = await server.services.integrationAuth.listIntegrationAuthByProjectId({ actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, actor: req.permission.type, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); return { authorizations }; @@ -340,8 +413,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:workspaceId/service-token-data", method: "GET", + url: "/:workspaceId/service-token-data", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ workspaceId: z.string().trim() @@ -356,7 +432,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const serviceTokenData = await server.services.serviceToken.getProjectServiceTokens({ actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, actor: req.permission.type, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); return { serviceTokenData }; diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 4a152f52e..1a1747f64 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { SecretFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { FOLDERS } from "@app/lib/api-docs"; import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -10,14 +12,23 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => server.route({ url: "/", method: "POST", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Create folders", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - name: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().describe(FOLDERS.CREATE.workspaceId), + environment: z.string().trim().describe(FOLDERS.CREATE.environment), + name: z.string().trim().describe(FOLDERS.CREATE.name), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.CREATE.path), // backward compatiability with cli - directory: z.string().trim().default("/").transform(removeTrailingSlash) + directory: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.CREATE.directory) }), response: { 200: z.object({ @@ -31,6 +42,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => const folder = await server.services.folder.createFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId, path @@ -55,18 +68,27 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => server.route({ url: "/:folderId", method: "PATCH", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Update folder", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ // old way this was name - folderId: z.string() + folderId: z.string().describe(FOLDERS.UPDATE.folderId) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - name: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().describe(FOLDERS.UPDATE.workspaceId), + environment: z.string().trim().describe(FOLDERS.UPDATE.environment), + name: z.string().trim().describe(FOLDERS.UPDATE.name), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.UPDATE.path), // backward compatiability with cli - directory: z.string().trim().default("/").transform(removeTrailingSlash) + directory: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.UPDATE.directory) }), response: { 200: z.object({ @@ -80,6 +102,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => const { folder, old } = await server.services.folder.updateFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId, id: req.params.folderId, @@ -104,18 +128,92 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/:folderId", - method: "DELETE", + url: "/batch", + method: "PATCH", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Update folders by batch", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().describe(FOLDERS.UPDATE.projectSlug), + folders: z + .object({ + id: z.string().describe(FOLDERS.UPDATE.folderId), + environment: z.string().trim().describe(FOLDERS.UPDATE.environment), + name: z.string().trim().describe(FOLDERS.UPDATE.name), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.UPDATE.path) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + folders: SecretFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { newFolders, oldFolders, projectId } = await server.services.folder.updateManyFolders({ + ...req.body, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await Promise.all( + req.body.folders.map(async (folder, index) => { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.UPDATE_FOLDER, + metadata: { + environment: oldFolders[index].envId, + folderId: oldFolders[index].id, + folderPath: folder.path, + newFolderName: newFolders[index].name, + oldFolderName: oldFolders[index].name + } + } + }); + }) + ); + + return { folders: newFolders }; + } + }); + + // TODO(daniel): Expose this route in api reference and write docs for it. + server.route({ + method: "DELETE", + url: "/:folderIdOrName", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Delete a folder", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - folderId: z.string() + folderIdOrName: z.string().describe(FOLDERS.DELETE.folderIdOrName) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().describe(FOLDERS.DELETE.workspaceId), + environment: z.string().trim().describe(FOLDERS.DELETE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.DELETE.path), // keep this here as cli need directory - directory: z.string().trim().default("/").transform(removeTrailingSlash) + directory: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.DELETE.directory) }), response: { 200: z.object({ @@ -129,9 +227,11 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => const folder = await server.services.folder.deleteFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId, - id: req.params.folderId, + idOrName: req.params.folderIdOrName, path }); await server.services.auditLog.createAuditLog({ @@ -152,15 +252,24 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, schema: { + description: "Get folders", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().describe(FOLDERS.LIST.workspaceId), + environment: z.string().trim().describe(FOLDERS.LIST.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.LIST.path), // backward compatiability with cli - directory: z.string().trim().default("/").transform(removeTrailingSlash) + directory: z.string().trim().default("/").transform(removeTrailingSlash).describe(FOLDERS.LIST.directory) }), response: { 200: z.object({ @@ -174,6 +283,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => const folders = await server.services.folder.getFolders({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.query, projectId: req.query.workspaceId, path diff --git a/backend/src/server/routes/v1/secret-import-router.ts b/backend/src/server/routes/v1/secret-import-router.ts index 80f980a90..d036fdbdd 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v1/secret-import-router.ts @@ -2,22 +2,33 @@ import { z } from "zod"; import { SecretImportsSchema, SecretsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SECRET_IMPORTS } from "@app/lib/api-docs"; import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, secretsLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretImportRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", method: "POST", + url: "/", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Create secret imports", + security: [ + { + bearerAuth: [] + } + ], body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().describe(SECRET_IMPORTS.CREATE.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.path), import: z.object({ - environment: z.string().trim(), - path: z.string().trim().transform(removeTrailingSlash) + environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment), + path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path) }) }), response: { @@ -36,6 +47,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => const secretImport = await server.services.secretImport.createImport({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId, data: req.body.import @@ -61,24 +74,34 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/:secretImportId", method: "PATCH", + url: "/:secretImportId", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Update secret imports", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretImportId: z.string().trim() + secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path), import: z.object({ - environment: z.string().trim().optional(), + environment: z.string().trim().optional().describe(SECRET_IMPORTS.UPDATE.import.environment), path: z .string() .trim() .optional() - .transform((val) => (val ? removeTrailingSlash(val) : val)), - position: z.number().optional() + .transform((val) => (val ? removeTrailingSlash(val) : val)) + .describe(SECRET_IMPORTS.UPDATE.import.path), + position: z.number().optional().describe(SECRET_IMPORTS.UPDATE.import.position) }) }), response: { @@ -97,6 +120,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => const secretImport = await server.services.secretImport.updateImport({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.secretImportId, ...req.body, projectId: req.body.workspaceId, @@ -123,16 +148,25 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/:secretImportId", method: "DELETE", + url: "/:secretImportId", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Delete secret imports", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretImportId: z.string().trim() + secretImportId: z.string().trim().describe(SECRET_IMPORTS.DELETE.secretImportId) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash) + workspaceId: z.string().trim().describe(SECRET_IMPORTS.DELETE.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.DELETE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.DELETE.path) }), response: { 200: z.object({ @@ -150,6 +184,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => const secretImport = await server.services.secretImport.deleteImport({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.secretImportId, ...req.body, projectId: req.body.workspaceId @@ -175,13 +211,22 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, schema: { + description: "Get secret imports", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - path: z.string().trim().default("/").transform(removeTrailingSlash) + workspaceId: z.string().trim().describe(SECRET_IMPORTS.LIST.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.LIST.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.LIST.path) }), response: { 200: z.object({ @@ -201,6 +246,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => const secretImports = await server.services.secretImport.getImports({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.query, projectId: req.query.workspaceId }); @@ -224,6 +271,9 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => server.route({ url: "/secrets", method: "GET", + config: { + rateLimit: secretsLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim(), @@ -253,6 +303,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => const importedSecrets = await server.services.secretImport.getSecretsFromImports({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.query, projectId: req.query.workspaceId }); diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 3cafb11d2..1715aa3c3 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,16 +1,21 @@ import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; +import { SECRET_TAGS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretTagRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:projectId/tags", method: "GET", + url: "/:projectId/tags", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ - projectId: z.string().trim() + projectId: z.string().trim().describe(SECRET_TAGS.LIST.projectId) }), response: { 200: z.object({ @@ -23,6 +28,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { const workspaceTags = await server.services.secretTag.getProjectTags({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.projectId }); return { workspaceTags }; @@ -30,16 +37,19 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:projectId/tags", method: "POST", + url: "/:projectId/tags", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ - projectId: z.string().trim() + projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - name: z.string().trim(), - slug: z.string().trim(), - color: z.string() + name: z.string().trim().describe(SECRET_TAGS.CREATE.name), + slug: z.string().trim().describe(SECRET_TAGS.CREATE.slug), + color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { 200: z.object({ @@ -52,6 +62,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { const workspaceTag = await server.services.secretTag.createTag({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.projectId, ...req.body }); @@ -60,12 +72,15 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:projectId/tags/:tagId", method: "DELETE", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ - projectId: z.string().trim(), - tagId: z.string().trim() + projectId: z.string().trim().describe(SECRET_TAGS.DELETE.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.DELETE.tagId) }), response: { 200: z.object({ @@ -78,6 +93,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { const workspaceTag = await server.services.secretTag.deleteTag({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.tagId }); return { workspaceTag }; diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index bfcf2f6ae..60bbec7db 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -18,7 +18,6 @@ import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { fetchGithubEmails } from "@app/lib/requests/github"; import { AuthMethod } from "@app/services/auth/auth-type"; -import { getServerCfg } from "@app/services/super-admin/super-admin-service"; export const registerSsoRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -42,7 +41,6 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { async (req, _accessToken, _refreshToken, profile, cb) => { try { const email = profile?.emails?.[0]?.value; - const serverCfg = await getServerCfg(); if (!email) throw new BadRequestError({ message: "Email not found", @@ -54,8 +52,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort: req.query.state as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp) + callbackPort: req.query.state as string }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -84,14 +81,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { try { const ghEmails = await fetchGithubEmails(accessToken); const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; - const serverCfg = await getServerCfg(); const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITHUB, - callbackPort: req.query.state as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp) + callbackPort: req.query.state as string }); return cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -120,14 +115,12 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { try { const email = profile.emails[0].value; - const serverCfg = await getServerCfg(); const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ email, firstName: profile.displayName, lastName: "", authMethod: AuthMethod.GITLAB, - callbackPort: req.query.state as string, - isSignupAllowed: Boolean(serverCfg.allowSignUp) + callbackPort: req.query.state as string }); return cb(null, { isUserCompleted, providerAuthToken }); diff --git a/backend/src/server/routes/v1/user-action-router.ts b/backend/src/server/routes/v1/user-action-router.ts index c730cdb91..5a2ae484e 100644 --- a/backend/src/server/routes/v1/user-action-router.ts +++ b/backend/src/server/routes/v1/user-action-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { UserActionsSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,6 +9,9 @@ export const registerUserActionRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ action: z.string().trim() @@ -29,6 +33,9 @@ export const registerUserActionRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "GET", + config: { + rateLimit: readLimit + }, schema: { querystring: z.object({ action: z.string().trim() diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index ca5148659..bdede8a3a 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,6 +9,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/", + config: { + rateLimit: readLimit + }, schema: { response: { 200: z.object({ @@ -15,7 +19,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: async (req) => { const user = await server.services.user.getMe(req.permission.id); return { user }; diff --git a/backend/src/server/routes/v1/webhook-router.ts b/backend/src/server/routes/v1/webhook-router.ts index 2b3e66398..1698c0c4b 100644 --- a/backend/src/server/routes/v1/webhook-router.ts +++ b/backend/src/server/routes/v1/webhook-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { WebhooksSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -27,6 +28,9 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ @@ -47,6 +51,8 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { const webhook = await server.services.webhook.createWebhook({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.body.workspaceId, ...req.body }); @@ -73,6 +79,9 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", url: "/:webhookId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -92,6 +101,8 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { const webhook = await server.services.webhook.updateWebhook({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.webhookId, isDisabled: req.body.isDisabled }); @@ -118,6 +129,9 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:webhookId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -128,6 +142,8 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { const webhook = await server.services.webhook.deleteWebhook({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.webhookId }); @@ -153,6 +169,9 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:webhookId/test", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -169,6 +188,8 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { const webhook = await server.services.webhook.testWebhook({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.webhookId }); return { message: "Successfully tested webhook", webhook }; @@ -178,6 +199,9 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { querystring: z.object({ @@ -200,6 +224,8 @@ export const registerWebhookRouter = async (server: FastifyZodProvider) => { const webhooks = await server.services.webhook.listWebhooks({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.query, projectId: req.query.workspaceId }); diff --git a/backend/src/server/routes/v2/group-project-router.ts b/backend/src/server/routes/v2/group-project-router.ts new file mode 100644 index 000000000..6d438c1ff --- /dev/null +++ b/backend/src/server/routes/v2/group-project-router.ts @@ -0,0 +1,201 @@ +import ms from "ms"; +import { z } from "zod"; + +import { + GroupProjectMembershipsSchema, + GroupsSchema, + ProjectMembershipRole, + ProjectUserMembershipRolesSchema +} from "@app/db/schemas"; +import { PROJECTS } from "@app/lib/api-docs"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; + +export const registerGroupProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Add group to project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectSlug), + groupSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupSlug) + }), + body: z.object({ + role: z + .string() + .trim() + .min(1) + .default(ProjectMembershipRole.NoAccess) + .describe(PROJECTS.ADD_GROUP_TO_PROJECT.role) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.addGroupToProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectSlug: req.params.projectSlug, + role: req.body.role + }); + return { groupMembership }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update group in project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.projectSlug), + groupSlug: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.groupSlug) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.roles) + }), + response: { + 200: z.object({ + roles: ProjectUserMembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const roles = await server.services.groupProject.updateGroupInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectSlug: req.params.projectSlug, + roles: req.body.roles + }); + return { roles }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Remove group from project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.projectSlug), + groupSlug: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.groupSlug) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.removeGroupFromProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectSlug: req.params.projectSlug + }); + return { groupMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/groups", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Return list of groups in project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.LIST_GROUPS_IN_PROJECT.projectSlug) + }), + response: { + 200: z.object({ + groupMemberships: z + .object({ + id: z.string(), + groupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + group: GroupsSchema.pick({ name: true, id: true, slug: true }) + }) + .array() + }) + } + }, + handler: async (req) => { + const groupMemberships = await server.services.groupProject.listGroupsInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectSlug: req.params.projectSlug + }); + return { groupMemberships }; + } + }); +}; diff --git a/backend/src/server/routes/v2/identity-org-router.ts b/backend/src/server/routes/v2/identity-org-router.ts index f1beb4e86..aab84ef8d 100644 --- a/backend/src/server/routes/v2/identity-org-router.ts +++ b/backend/src/server/routes/v2/identity-org-router.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,10 +10,19 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:orgId/identity-memberships", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Return organization identity memberships", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - orgId: z.string().trim() + orgId: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orgId) }), response: { 200: z.object({ @@ -34,8 +45,11 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { const identityMemberships = await server.services.identity.listOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, orgId: req.params.orgId }); + return { identityMemberships }; } }); diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index ea797e0cb..d259a46fd 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -1,26 +1,70 @@ +import ms from "ms"; import { z } from "zod"; import { IdentitiesSchema, IdentityProjectMembershipsSchema, ProjectMembershipRole, - ProjectRolesSchema + ProjectUserMembershipRolesSchema } from "@app/db/schemas"; +import { PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ projectId: z.string().trim(), identityId: z.string().trim() }), body: z.object({ - role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + // @depreciated + role: z.string().trim().optional().default(ProjectMembershipRole.NoAccess), + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .optional() }), response: { 200: z.object({ @@ -29,12 +73,17 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { + const { role, roles } = req.body; + if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); + const identityMembership = await server.services.identityProject.createProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId, - role: req.body.role + roles: roles || [{ role }] }); return { identityMembership }; } @@ -43,41 +92,89 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) server.route({ method: "PATCH", url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Update project identity memberships", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - identityId: z.string().trim() + projectId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.identityId) }), body: z.object({ - role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.description) }), response: { 200: z.object({ - identityMembership: IdentityProjectMembershipsSchema + roles: ProjectUserMembershipRolesSchema.array() }) } }, handler: async (req) => { - const identityMembership = await server.services.identityProject.updateProjectIdentity({ + const roles = await server.services.identityProject.updateProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId, - role: req.body.role + roles: req.body.roles }); - return { identityMembership }; + return { roles }; } }); server.route({ method: "DELETE", url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Delete project identity memberships", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - identityId: z.string().trim() + projectId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) }), response: { 200: z.object({ @@ -89,6 +186,8 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) const identityMembership = await server.services.identityProject.deleteProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId }); @@ -99,25 +198,45 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) server.route({ method: "GET", url: "/:projectId/identity-memberships", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Return project identity memberships", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim() + projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId) }), response: { 200: z.object({ - identityMemberships: IdentityProjectMembershipsSchema.merge( - z.object({ - customRole: ProjectRolesSchema.pick({ - id: true, - name: true, - slug: true, - permissions: true, - description: true - }).optional(), + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) }) - ).array() + .array() }) } }, @@ -125,9 +244,68 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) const identityMemberships = await server.services.identityProject.listProjectIdentities({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.projectId }); return { identityMemberships }; } }); + + server.route({ + method: "GET", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Return project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.GET_IDENTITY_MEMBERSHIP_BY_ID.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByIdentityId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + identityId: req.params.identityId + }); + return { identityMembership }; + } + }); }; diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index 1f423c084..3d7581a70 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,7 +1,9 @@ +import { registerGroupProjectRouter } from "./group-project-router"; import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; +import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerServiceTokenRouter } from "./service-token-router"; import { registerUserRouter } from "./user-router"; @@ -21,6 +23,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (projectServer) => { await projectServer.register(registerProjectRouter); await projectServer.register(registerIdentityProjectRouter); + await projectServer.register(registerGroupProjectRouter); + await projectServer.register(registerProjectMembershipRouter); }, { prefix: "/workspace" } ); diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index cbe7f1cbf..973804c7c 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -2,6 +2,7 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; +import { writeLimit } from "@app/server/config/rateLimiter"; import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; export const registerMfaRouter = async (server: FastifyZodProvider) => { @@ -26,12 +27,15 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { const user = await server.store.user.findById(decodedToken.userId); if (!user) throw new Error("User not found"); - req.mfa = { userId: user.id, user }; + req.mfa = { userId: user.id, user, orgId: decodedToken.organizationId }; }); server.route({ - url: "/mfa/send", method: "POST", + url: "/mfa/send", + config: { + rateLimit: writeLimit + }, schema: { response: { 200: z.object({ @@ -48,6 +52,9 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { server.route({ url: "/mfa/verify", method: "POST", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ mfaToken: z.string().trim() @@ -68,13 +75,17 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { }, handler: async (req, res) => { const userAgent = req.headers["user-agent"]; + const mfaJwtToken = req.headers.authorization?.replace("Bearer ", ""); if (!userAgent) throw new Error("user agent header is required"); + if (!mfaJwtToken) throw new Error("authorization header is required"); const appCfg = getConfig(); const { user, token } = await server.services.login.verifyMfaToken({ userAgent, + mfaJwtToken, ip: req.realIp, userId: req.mfa.userId, + orgId: req.mfa.orgId, mfaToken: req.body.mfaToken }); diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index df94f65c7..07074eba3 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { OrganizationsSchema, OrgMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { ORGANIZATIONS } from "@app/lib/api-docs"; +import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -8,15 +10,25 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId/memberships", + config: { + rateLimit: readLimit + }, schema: { + description: "Return organization user memberships", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - organizationId: z.string().trim() + organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_USER_MEMBERSHIPS.organizationId) }), response: { 200: z.object({ users: OrgMembershipsSchema.merge( z.object({ user: UsersSchema.pick({ + username: true, email: true, firstName: true, lastName: true, @@ -32,8 +44,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - - const users = await server.services.org.findAllOrgMembers(req.permission.id, req.params.organizationId); + const users = await server.services.org.findAllOrgMembers( + req.permission.id, + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId + ); return { users }; } }); @@ -41,9 +57,18 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:organizationId/workspaces", + config: { + rateLimit: readLimit + }, schema: { + description: "Return projects in organization that user is part of", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - organizationId: z.string().trim() + organizationId: z.string().trim().describe(ORGANIZATIONS.GET_PROJECTS.organizationId) }), response: { 200: z.object({ @@ -51,6 +76,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .object({ id: z.string(), name: z.string(), + slug: z.string(), organization: z.string(), environments: z .object({ @@ -68,6 +94,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const workspaces = await server.services.org.findAllWorkspaces({ actor: req.permission.type, actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId }); @@ -78,10 +106,22 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", url: "/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, schema: { - params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }), + description: "Update organization user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.organizationId), + membershipId: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.membershipId) + }), body: z.object({ - role: z.string().trim() + role: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role) }), response: { 200: z.object({ @@ -96,8 +136,10 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const membership = await server.services.org.updateOrgMembership({ userId: req.permission.id, role: req.body.role, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, - membershipId: req.params.membershipId + membershipId: req.params.membershipId, + actorOrgId: req.permission.orgId }); return { membership }; } @@ -106,8 +148,20 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, schema: { - params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }), + description: "Delete organization user memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.organizationId), + membershipId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.membershipId) + }), response: { 200: z.object({ membership: OrgMembershipsSchema @@ -120,8 +174,10 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const membership = await server.services.org.deleteOrgMembership({ userId: req.permission.id, + actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, - membershipId: req.params.membershipId + membershipId: req.params.membershipId, + actorOrgId: req.permission.orgId }); return { membership }; } @@ -130,6 +186,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/", + config: { + rateLimit: creationLimit + }, schema: { body: z.object({ name: z.string().trim() @@ -140,15 +199,16 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY], { requireOrg: false }), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const organization = await server.services.org.createOrganization( - req.permission.id, - req.auth.user.email, - req.body.name - ); + const organization = await server.services.org.createOrganization({ + userId: req.permission.id, + userEmail: req.auth.user.email, + orgName: req.body.name + }); + return { organization }; } }); @@ -156,6 +216,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:organizationId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ organizationId: z.string().trim() @@ -172,7 +235,9 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const organization = await server.services.org.deleteOrganizationById( req.permission.id, - req.params.organizationId + req.params.organizationId, + req.permission.authMethod, + req.permission.orgId ); return { organization }; } diff --git a/backend/src/server/routes/v2/project-membership-router.ts b/backend/src/server/routes/v2/project-membership-router.ts new file mode 100644 index 000000000..a9592faab --- /dev/null +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; + +import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PROJECT_USERS } from "@app/lib/api-docs"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Invite members to project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId) + }), + body: z.object({ + emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails), + usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames) + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.projectMembership.addUsersToProjectNonE2EE({ + projectId: req.params.projectId, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + emails: req.body.emails, + usernames: req.body.usernames + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.ADD_BATCH_WORKSPACE_MEMBER, + metadata: memberships.map(({ userId, id }) => ({ + userId: userId || "", + membershipId: id, + email: "" + })) + } + }); + + return { memberships }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/memberships", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Remove members from project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId) + }), + body: z.object({ + emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails), + usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames) + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.projectMembership.deleteProjectMemberships({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + emails: req.body.emails, + usernames: req.body.usernames + }); + + for (const membership of memberships) { + // eslint-disable-next-line no-await-in-loop + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.projectId, + event: { + type: EventType.REMOVE_WORKSPACE_MEMBER, + metadata: { + userId: membership.userId, + email: "" + } + } + }); + } + return { memberships }; + } + }); +}; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index e90a36060..a199cf0d4 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,17 +1,43 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectKeysSchema } from "@app/db/schemas"; +import { ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PROJECTS } from "@app/lib/api-docs"; +import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectFilterType } from "@app/services/project/project-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +const projectWithEnv = ProjectsSchema.merge( + z.object({ + _id: z.string(), + environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() + }) +); + +const slugSchema = z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be at least 5 character but no more than 36" + }); export const registerProjectRouter = async (server: FastifyZodProvider) => { + /* Get project key */ server.route({ - url: "/:workspaceId/encrypted-key", method: "GET", + url: "/:workspaceId/encrypted-key", + config: { + rateLimit: readLimit + }, schema: { + description: "Return encrypted project key", params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim().describe(PROJECTS.GET_KEY.workspaceId) }), response: { 200: ProjectKeysSchema.merge( @@ -28,6 +54,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const key = await server.services.projectKey.getLatestProjectKey({ actor: req.permission.type, actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, projectId: req.params.workspaceId }); @@ -45,4 +73,238 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return key; } }); + + /* Start upgrade of a project */ + server.route({ + method: "POST", + url: "/:projectId/upgrade", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + body: z.object({ + userPrivateKey: z.string().trim() + }), + response: { + 200: z.void() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.project.upgradeProject({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + projectId: req.params.projectId, + userPrivateKey: req.body.userPrivateKey + }); + } + }); + + /* Get upgrade status of project */ + server.route({ + url: "/:projectId/upgrade/status", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + status: z.string().nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const status = await server.services.project.getProjectUpgradeStatus({ + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + actor: req.permission.type, + actorId: req.permission.id + }); + + return { status }; + } + }); + + /* Create new project */ + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: creationLimit + }, + schema: { + description: "Create a new project", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(PROJECTS.CREATE.slug) + }), + response: { + 200: z.object({ + project: projectWithEnv + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.createProject({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + workspaceName: req.body.projectName, + slug: req.body.slug + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.ProjectCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + orgId: project.orgId, + name: project.name, + ...req.auditLogInfo + } + }); + + return { project }; + } + }); + + /* Delete a project by slug */ + server.route({ + method: "DELETE", + url: "/:slug", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slug: slugSchema.describe("The slug of the project to delete.") + }), + response: { + 200: ProjectsSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + + handler: async (req) => { + const project = await server.services.project.deleteProject({ + filter: { + type: ProjectFilterType.SLUG, + slug: req.params.slug, + orgId: req.permission.orgId + }, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + actor: req.permission.type + }); + + return project; + } + }); + + /* Get a project by slug */ + server.route({ + method: "GET", + url: "/:slug", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to get.") + }), + response: { + 200: projectWithEnv + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.getAProject({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return project; + } + }); + + /* Update a project by slug */ + server.route({ + method: "PATCH", + url: "/:slug", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to update.") + }), + body: z.object({ + name: z.string().trim().optional().describe("The new name of the project."), + autoCapitalization: z.boolean().optional().describe("The new auto-capitalization setting.") + }), + response: { + 200: ProjectsSchema + } + }, + + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.updateProject({ + filter: { + type: ProjectFilterType.SLUG, + slug: req.params.slug, + orgId: req.permission.orgId + }, + update: { + name: req.body.name, + autoCapitalization: req.body.autoCapitalization + }, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + + return project; + } + }); }; diff --git a/backend/src/server/routes/v2/service-token-router.ts b/backend/src/server/routes/v2/service-token-router.ts index ea9912dda..fb10f17db 100644 --- a/backend/src/server/routes/v2/service-token-router.ts +++ b/backend/src/server/routes/v2/service-token-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ServiceTokensSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -17,10 +18,19 @@ export const sanitizedServiceTokenSchema = ServiceTokensSchema.omit({ export const registerServiceTokenRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.SERVICE_TOKEN]), schema: { + description: "Return Infisical Token data", + security: [ + { + bearerAuth: [] + } + ], response: { 200: ServiceTokensSchema.merge( z.object({ @@ -40,6 +50,8 @@ export const registerServiceTokenRouter = async (server: FastifyZodProvider) => handler: async (req) => { const { serviceToken, user } = await server.services.serviceToken.getServiceToken({ actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, actor: req.permission.type }); @@ -61,8 +73,11 @@ export const registerServiceTokenRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/", method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { body: z.object({ @@ -92,6 +107,8 @@ export const registerServiceTokenRouter = async (server: FastifyZodProvider) => const { serviceToken, token } = await server.services.serviceToken.createServiceToken({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId }); @@ -112,8 +129,11 @@ export const registerServiceTokenRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/:serviceTokenId", method: "DELETE", + url: "/:serviceTokenId", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT]), schema: { params: z.object({ @@ -129,6 +149,8 @@ export const registerServiceTokenRouter = async (server: FastifyZodProvider) => const serviceTokenData = await server.services.serviceToken.deleteServiceToken({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, id: req.params.serviceTokenId }); diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 8061aa0e5..1f15008c7 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,13 +2,58 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/me/mfa", + method: "POST", + url: "/me/emails/code", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + username: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.user.sendEmailVerificationCode(req.body.username); + return {}; + } + }); + + server.route({ + method: "POST", + url: "/me/emails/verify", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + username: z.string().trim(), + code: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req) => { + await server.services.user.verifyEmailVerificationCode(req.body.username, req.body.code); + return {}; + } + }); + + server.route({ method: "PATCH", + url: "/me/mfa", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ isMfaEnabled: z.boolean() @@ -27,8 +72,11 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/me/name", method: "PATCH", + url: "/me/name", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ firstName: z.string().trim(), @@ -48,8 +96,11 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/me/auth-methods", method: "PUT", + url: "/me/auth-methods", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ authMethods: z.nativeEnum(AuthMethod).array().min(1) @@ -60,7 +111,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }) } }, - preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY], { requireOrg: false }), handler: async (req) => { const user = await server.services.user.updateAuthMethods(req.permission.id, req.body.authMethods); return { user }; @@ -70,7 +121,11 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/me/organizations", + config: { + rateLimit: readLimit + }, schema: { + description: "Return organizations that current user is part of", response: { 200: z.object({ organizations: OrganizationsSchema.array() @@ -87,6 +142,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/me/api-keys", + config: { + rateLimit: readLimit + }, schema: { response: { 200: ApiKeysSchema.omit({ secretHash: true }).array() @@ -102,6 +160,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/me/api-keys", + config: { + rateLimit: writeLimit + }, schema: { body: z.object({ name: z.string().trim(), @@ -124,6 +185,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/me/api-keys/:apiKeyDataId", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ apiKeyDataId: z.string().trim() @@ -144,6 +208,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/me/sessions", + config: { + rateLimit: readLimit + }, schema: { response: { 200: AuthTokenSessionsSchema.array() @@ -159,6 +226,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/me/sessions", + config: { + rateLimit: writeLimit + }, schema: { response: { 200: z.object({ @@ -178,14 +248,18 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/me", + config: { + rateLimit: readLimit + }, schema: { + description: "Retrieve the current user on the request", response: { 200: z.object({ user: UsersSchema.merge(UserEncryptionKeysSchema.omit({ verifier: true })) }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), handler: async (req) => { const user = await server.services.user.getMe(req.permission.id); return { user }; @@ -195,6 +269,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/me", + config: { + rateLimit: writeLimit + }, schema: { response: { 200: z.object({ diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 0cda8e5ff..900ad56d2 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -12,7 +12,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().email().trim(), + email: z.string().trim(), providerAuthToken: z.string().trim().optional(), clientPublicKey: z.string().trim() }), @@ -34,6 +34,42 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/select-organization", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + token: z.string() + }) + } + }, + handler: async (req, res) => { + const cfg = getConfig(); + const tokens = await server.services.login.selectOrganization({ + userAgent: req.headers["user-agent"], + authJwtToken: req.headers.authorization, + organizationId: req.body.organizationId, + ipAddress: req.realIp + }); + + void res.setCookie("jid", tokens.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED + }); + + return { token: tokens.access }; + } + }); + server.route({ method: "POST", url: "/login2", @@ -42,7 +78,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().email().trim(), + email: z.string().trim(), providerAuthToken: z.string().trim().optional(), clientProof: z.string().trim() }), diff --git a/backend/src/server/routes/v3/secret-blind-index-router.ts b/backend/src/server/routes/v3/secret-blind-index-router.ts index 17bf4c4eb..cfb27a58e 100644 --- a/backend/src/server/routes/v3/secret-blind-index-router.ts +++ b/backend/src/server/routes/v3/secret-blind-index-router.ts @@ -1,13 +1,17 @@ import { z } from "zod"; import { SecretsSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerSecretBlindIndexRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/:projectId/secrets/blind-index-status", method: "GET", + url: "/:projectId/secrets/blind-index-status", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -20,16 +24,21 @@ export const registerSecretBlindIndexRouter = async (server: FastifyZodProvider) handler: async (req) => { const count = await server.services.secretBlindIndex.getSecretBlindIndexStatus({ projectId: req.params.projectId, + actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, - actor: req.permission.type + actor: req.permission.type, + actorOrgId: req.permission.orgId }); return count === 0; } }); server.route({ - url: "/:projectId/secrets", method: "GET", + url: "/:projectId/secrets", + config: { + rateLimit: readLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -51,16 +60,21 @@ export const registerSecretBlindIndexRouter = async (server: FastifyZodProvider) handler: async (req) => { const secrets = await server.services.secretBlindIndex.getProjectSecrets({ projectId: req.params.projectId, + actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, - actor: req.permission.type + actor: req.permission.type, + actorOrgId: req.permission.orgId }); return { secrets }; } }); server.route({ - url: "/:projectId/secrets/names", method: "POST", + url: "/:projectId/secrets/names", + config: { + rateLimit: writeLimit + }, schema: { params: z.object({ projectId: z.string().trim() @@ -84,8 +98,10 @@ export const registerSecretBlindIndexRouter = async (server: FastifyZodProvider) await server.services.secretBlindIndex.updateProjectSecretName({ projectId: req.params.projectId, secretsToUpdate: req.body.secretsToUpdate, + actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, - actor: req.permission.type + actor: req.permission.type, + actorOrgId: req.permission.orgId }); return { message: "Successfully named workspace secrets" }; } diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 02f218b67..6fa574a69 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -1,4 +1,3 @@ -import { FastifyRequest } from "fastify"; import picomatch from "picomatch"; import { z } from "zod"; @@ -11,45 +10,185 @@ import { } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; +import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; +import { secretsLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { secretRawSchema } from "../sanitizedSchemas"; -const getDistinctId = (req: FastifyRequest) => { - if (req.auth.actor === ActorType.USER) { - return req.auth.user.email; - } - if (req.auth.actor === ActorType.IDENTITY) { - return `identity-${req.auth.identityId}`; - } - if (req.auth.actor === ActorType.SERVICE) { - return `service-token-${req.auth.serviceToken.id}`; - } - return "unknown-auth-data"; -}; - export const registerSecretRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/raw", - method: "GET", + method: "POST", + url: "/tags/:secretName", + config: { + rateLimit: writeLimit + }, schema: { + description: "Attach tags to a secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(SECRETS.ATTACH_TAGS.secretName) + }), + body: z.object({ + projectSlug: z.string().trim().describe(SECRETS.ATTACH_TAGS.projectSlug), + environment: z.string().trim().describe(SECRETS.ATTACH_TAGS.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(SECRETS.ATTACH_TAGS.secretPath), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(SECRETS.ATTACH_TAGS.type), + tagSlugs: z.string().array().min(1).describe(SECRETS.ATTACH_TAGS.tagSlugs) + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( + z.object({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }).array() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secret = await server.services.secret.attachTags({ + secretName: req.params.secretName, + tagSlugs: req.body.tagSlugs, + path: req.body.secretPath, + environment: req.body.environment, + type: req.body.type, + projectSlug: req.body.projectSlug, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { secret }; + } + }); + + server.route({ + method: "DELETE", + url: "/tags/:secretName", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Detach tags from a secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(SECRETS.DETACH_TAGS.secretName) + }), + body: z.object({ + projectSlug: z.string().trim().describe(SECRETS.DETACH_TAGS.projectSlug), + environment: z.string().trim().describe(SECRETS.DETACH_TAGS.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(SECRETS.DETACH_TAGS.secretPath), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(SECRETS.DETACH_TAGS.type), + tagSlugs: z.string().array().min(1).describe(SECRETS.DETACH_TAGS.tagSlugs) + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( + z.object({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }).array() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secret = await server.services.secret.detachTags({ + secretName: req.params.secretName, + tagSlugs: req.body.tagSlugs, + path: req.body.secretPath, + environment: req.body.environment, + type: req.body.type, + projectSlug: req.body.projectSlug, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { secret }; + } + }); + + server.route({ + method: "GET", + url: "/raw", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "List secrets", + security: [ + { + bearerAuth: [] + } + ], querystring: z.object({ - workspaceId: z.string().trim().optional(), - environment: z.string().trim().optional(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceId), + workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.LIST.workspaceSlug), + environment: z.string().trim().optional().describe(RAW_SECRETS.LIST.environment), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.LIST.secretPath), + expandSecretReferences: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + .describe(RAW_SECRETS.LIST.expand), + recursive: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + .describe(RAW_SECRETS.LIST.recursive), include_imports: z .enum(["true", "false"]) .default("false") .transform((value) => value === "true") + .describe(RAW_SECRETS.LIST.includeImports) }), response: { 200: z.object({ - secrets: secretRawSchema.array(), + secrets: secretRawSchema + .extend({ + secretPath: z.string().optional() + }) + .array(), imports: z .object({ secretPath: z.string(), @@ -74,6 +213,22 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { environment = scope[0].environment; workspaceId = req.auth.serviceToken.projectId; } + } else if (req.permission.type === ActorType.IDENTITY && req.query.workspaceSlug && !workspaceId) { + const workspace = await server.services.project.getAProject({ + filter: { + type: ProjectFilterType.SLUG, + orgId: req.permission.orgId, + slug: req.query.workspaceSlug + }, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + + if (!workspace) throw new BadRequestError({ message: `No project found with slug ${req.query.workspaceSlug}` }); + + workspaceId = workspace.id; } if (!workspaceId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); @@ -81,14 +236,18 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const { secrets, imports } = await server.services.secret.getSecretsRaw({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, environment, + expandSecretReferences: req.query.expandSecretReferences, + actorAuthMethod: req.permission.authMethod, projectId: workspaceId, path: secretPath, - includeImports: req.query.include_imports + includeImports: req.query.include_imports, + recursive: req.query.recursive }); await server.services.auditLog.createAuditLog({ - projectId: req.query.workspaceId, + projectId: workspaceId, ...req.auditLogInfo, event: { type: EventType.GET_SECRETS, @@ -100,9 +259,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: secrets.length, workspaceId, @@ -117,22 +276,33 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/raw/:secretName", method: "GET", + url: "/raw/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Get a secret by name", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretName: z.string().trim() + secretName: z.string().trim().describe(RAW_SECRETS.GET.secretName) }), querystring: z.object({ - workspaceId: z.string().trim().optional(), - environment: z.string().trim().optional(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - version: z.coerce.number().optional(), - type: z.nativeEnum(SecretType).default(SecretType.Shared), + workspaceId: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceId), + workspaceSlug: z.string().trim().optional().describe(RAW_SECRETS.GET.workspaceSlug), + environment: z.string().trim().optional().describe(RAW_SECRETS.GET.environment), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash).describe(RAW_SECRETS.GET.secretPath), + version: z.coerce.number().optional().describe(RAW_SECRETS.GET.version), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.GET.type), include_imports: z .enum(["true", "false"]) .default("false") .transform((value) => value === "true") + .describe(RAW_SECRETS.GET.includeImports) }), response: { 200: z.object({ @@ -142,6 +312,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { workspaceSlug } = req.query; let { secretPath, environment, workspaceId } = req.query; if (req.auth.actor === ActorType.SERVICE) { const scope = ServiceTokenScopes.parse(req.auth.serviceToken.scopes); @@ -153,13 +324,18 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } } - if (!workspaceId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); + if (!environment) throw new BadRequestError({ message: "Missing environment" }); + if (!workspaceId && !workspaceSlug) + throw new BadRequestError({ message: "You must provide workspaceSlug or workspaceId" }); const secret = await server.services.secret.getSecretByNameRaw({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, environment, projectId: workspaceId, + projectSlug: workspaceSlug, path: secretPath, secretName: req.params.secretName, type: req.query.type, @@ -168,7 +344,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); await server.services.auditLog.createAuditLog({ - projectId: req.query.workspaceId, + projectId: secret.workspace, ...req.auditLogInfo, event: { type: EventType.GET_SECRET, @@ -182,12 +358,12 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, - workspaceId, + workspaceId: secret.workspace, environment, secretPath: req.query.secretPath, channel: getUserAgentType(req.headers["user-agent"]), @@ -199,20 +375,37 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/raw/:secretName", method: "POST", + url: "/raw/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Create secret", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretName: z.string().trim() + secretName: z.string().trim().describe(RAW_SECRETS.CREATE.secretName) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - 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.nativeEnum(SecretType).default(SecretType.Shared) + workspaceId: z.string().trim().describe(RAW_SECRETS.CREATE.workspaceId), + environment: z.string().trim().describe(RAW_SECRETS.CREATE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.CREATE.secretPath), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .describe(RAW_SECRETS.CREATE.secretValue), + secretComment: z.string().trim().optional().default("").describe(RAW_SECRETS.CREATE.secretComment), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.CREATE.skipMultilineEncoding), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.CREATE.type) }), response: { 200: z.object({ @@ -225,7 +418,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.createSecretRaw({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, environment: req.body.environment, + actorAuthMethod: req.permission.authMethod, projectId: req.body.workspaceId, secretPath: req.body.secretPath, secretName: req.params.secretName, @@ -250,9 +445,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -268,19 +463,36 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/raw/:secretName", method: "PATCH", + url: "/raw/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Update secret", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretName: z.string().trim() + secretName: z.string().trim().describe(RAW_SECRETS.UPDATE.secretName) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - secretValue: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - skipMultilineEncoding: z.boolean().optional(), - type: z.nativeEnum(SecretType).default(SecretType.Shared) + workspaceId: z.string().trim().describe(RAW_SECRETS.UPDATE.workspaceId), + environment: z.string().trim().describe(RAW_SECRETS.UPDATE.environment), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .describe(RAW_SECRETS.UPDATE.secretValue), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.UPDATE.secretPath), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.UPDATE.type) }), response: { 200: z.object({ @@ -293,6 +505,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.updateSecretRaw({ actorId: req.permission.id, actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, environment: req.body.environment, projectId: req.body.workspaceId, secretPath: req.body.secretPath, @@ -317,9 +531,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -334,17 +548,31 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/raw/:secretName", method: "DELETE", + url: "/raw/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { + description: "Delete secret", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - secretName: z.string().trim() + secretName: z.string().trim().describe(RAW_SECRETS.DELETE.secretName) }), body: z.object({ - workspaceId: z.string().trim(), - environment: z.string().trim(), - secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - type: z.nativeEnum(SecretType).default(SecretType.Shared) + workspaceId: z.string().trim().describe(RAW_SECRETS.DELETE.workspaceId), + environment: z.string().trim().describe(RAW_SECRETS.DELETE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.DELETE.secretPath), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.DELETE.type) }), response: { 200: z.object({ @@ -357,6 +585,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.deleteSecretRaw({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, environment: req.body.environment, projectId: req.body.workspaceId, secretPath: req.body.secretPath, @@ -379,9 +609,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -397,13 +627,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/", method: "GET", + url: "/", + config: { + rateLimit: secretsLimit + }, schema: { querystring: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + recursive: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true"), include_imports: z .enum(["true", "false"]) .default("false") @@ -412,19 +649,18 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ secrets: SecretsSchema.omit({ secretBlindIndex: true }) - .merge( - z.object({ - _id: z.string(), - workspace: z.string(), - environment: z.string(), - tags: SecretTagsSchema.pick({ - id: true, - slug: true, - name: true, - color: true - }).array() - }) - ) + .extend({ + _id: z.string(), + workspace: z.string(), + environment: z.string(), + secretPath: z.string().optional(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }).array() + }) .array(), imports: z .object({ @@ -451,10 +687,13 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const { secrets, imports } = await server.services.secret.getSecrets({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, environment: req.query.environment, projectId: req.query.workspaceId, path: req.query.secretPath, - includeImports: req.query.include_imports + includeImports: req.query.include_imports, + recursive: req.query.recursive }); await server.services.auditLog.createAuditLog({ @@ -484,9 +723,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { (req.headers["user-agent"] !== "k8-operator" || shouldRecordK8Event); const approximateNumberTotalSecrets = secrets.length * 20; if (shouldCapture) { - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: shouldRecordK8Event ? approximateNumberTotalSecrets : secrets.length, workspaceId: req.query.workspaceId, @@ -503,8 +742,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:secretName", method: "GET", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { params: z.object({ secretName: z.string().trim() @@ -536,6 +778,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.getSecretByName({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, environment: req.query.environment, projectId: req.query.workspaceId, path: req.query.secretPath, @@ -560,9 +804,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.query.workspaceId, @@ -579,6 +823,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:secretName", method: "POST", + config: { + rateLimit: secretsLimit + }, schema: { body: z.object({ workspaceId: z.string().trim(), @@ -637,6 +884,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { if (req.body.type !== SecretType.Personal && req.permission.type === ActorType.USER) { const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, actor: req.permission.type, secretPath, environment, @@ -646,6 +895,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const approval = await server.services.secretApprovalRequest.generateSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId, @@ -688,6 +939,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.createSecret({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, path: secretPath, type, environment: req.body.environment, @@ -721,9 +974,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -739,8 +992,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:secretName", method: "PATCH", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { params: z.object({ secretName: z.string() @@ -811,6 +1067,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId @@ -819,6 +1077,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const approval = await server.services.secretApprovalRequest.generateSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId, @@ -863,6 +1123,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.updateSecret({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, path: secretPath, type, environment, @@ -900,9 +1162,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -917,8 +1179,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/:secretName", method: "DELETE", + url: "/:secretName", + config: { + rateLimit: secretsLimit + }, schema: { params: z.object({ secretName: z.string() @@ -952,6 +1217,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId @@ -960,6 +1227,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const approval = await server.services.secretApprovalRequest.generateSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId, @@ -992,6 +1261,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secret = await server.services.secret.deleteSecret({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, path: secretPath, type, environment, @@ -1015,9 +1286,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -1032,8 +1303,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/batch", method: "POST", + url: "/batch", + config: { + rateLimit: secretsLimit + }, schema: { body: z.object({ workspaceId: z.string().trim(), @@ -1042,7 +1316,6 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secrets: z .object({ secretName: z.string().trim(), - type: z.nativeEnum(SecretType).default(SecretType.Shared), secretKeyCiphertext: z.string().trim(), secretKeyIV: z.string().trim(), secretKeyTag: z.string().trim(), @@ -1074,6 +1347,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId @@ -1082,12 +1357,14 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const approval = await server.services.secretApprovalRequest.generateSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId, policy, data: { - [CommitType.Create]: inputSecrets.filter(({ type }) => type === "shared") + [CommitType.Create]: inputSecrets } }); @@ -1110,6 +1387,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secrets = await server.services.secret.createManySecret({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, path: secretPath, environment, projectId, @@ -1133,9 +1412,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: secrets.length, workspaceId: req.body.workspaceId, @@ -1150,8 +1429,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/batch", method: "PATCH", + url: "/batch", + config: { + rateLimit: secretsLimit + }, schema: { body: z.object({ workspaceId: z.string().trim(), @@ -1192,6 +1474,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId @@ -1200,6 +1484,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const approval = await server.services.secretApprovalRequest.generateSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId, @@ -1227,6 +1513,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secrets = await server.services.secret.updateManySecret({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, path: secretPath, environment, projectId, @@ -1250,9 +1538,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: secrets.length, workspaceId: req.body.workspaceId, @@ -1267,8 +1555,11 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); server.route({ - url: "/batch", method: "DELETE", + url: "/batch", + config: { + rateLimit: secretsLimit + }, schema: { body: z.object({ workspaceId: z.string().trim(), @@ -1298,6 +1589,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId @@ -1306,6 +1599,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const approval = await server.services.secretApprovalRequest.generateSecretApprovalRequest({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, secretPath, environment, projectId, @@ -1332,6 +1627,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { const secrets = await server.services.secret.deleteManySecret({ actorId: req.permission.id, actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, path: req.body.secretPath, environment, projectId, @@ -1355,9 +1652,9 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - server.services.telemetry.sendPostHogEvents({ + await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, - distinctId: getDistinctId(req), + distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: secrets.length, workspaceId: req.body.workspaceId, @@ -1370,4 +1667,300 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { return { secrets }; } }); + + server.route({ + method: "POST", + url: "/batch/raw", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Create many secrets", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().describe(RAW_SECRETS.CREATE.projectSlug), + environment: z.string().trim().describe(RAW_SECRETS.CREATE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.CREATE.secretPath), + secrets: z + .object({ + secretKey: z.string().trim().describe(RAW_SECRETS.CREATE.secretName), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .describe(RAW_SECRETS.CREATE.secretValue), + secretComment: z.string().trim().optional().default("").describe(RAW_SECRETS.CREATE.secretComment), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.CREATE.skipMultilineEncoding) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + secrets: secretRawSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, projectSlug, secretPath, secrets: inputSecrets } = req.body; + + const secrets = await server.services.secret.createManySecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretPath, + environment, + projectSlug, + secrets: inputSecrets + }); + + await server.services.auditLog.createAuditLog({ + projectId: secrets[0].workspace, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: secrets.map((secret, i) => ({ + secretId: secret.id, + secretKey: inputSecrets[i].secretKey, + secretVersion: secret.version + })) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId: secrets[0].workspace, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secrets }; + } + }); + + server.route({ + method: "PATCH", + url: "/batch/raw", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Update many secrets", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().describe(RAW_SECRETS.UPDATE.projectSlug), + environment: z.string().trim().describe(RAW_SECRETS.UPDATE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.UPDATE.secretPath), + secrets: z + .object({ + secretKey: z.string().trim().describe(RAW_SECRETS.UPDATE.secretName), + secretValue: z + .string() + .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .describe(RAW_SECRETS.UPDATE.secretValue), + secretComment: z.string().trim().optional().describe(RAW_SECRETS.UPDATE.secretComment), + skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + secrets: secretRawSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, projectSlug, secretPath, secrets: inputSecrets } = req.body; + const secrets = await server.services.secret.updateManySecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretPath, + environment, + projectSlug, + secrets: inputSecrets + }); + + await server.services.auditLog.createAuditLog({ + projectId: secrets[0].workspace, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: secrets.map((secret, i) => ({ + secretId: secret.id, + secretKey: inputSecrets[i].secretKey, + secretVersion: secret.version + })) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretUpdated, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId: secrets[0].workspace, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secrets }; + } + }); + + server.route({ + method: "DELETE", + url: "/batch/raw", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Delete many secrets", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectSlug: z.string().trim().describe(RAW_SECRETS.DELETE.projectSlug), + environment: z.string().trim().describe(RAW_SECRETS.DELETE.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.DELETE.secretPath), + secrets: z + .object({ + secretKey: z.string().trim().describe(RAW_SECRETS.DELETE.secretName) + }) + .array() + .min(1) + }), + response: { + 200: z.object({ + secrets: secretRawSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { environment, projectSlug, secretPath, secrets: inputSecrets } = req.body; + const secrets = await server.services.secret.deleteManySecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + environment, + projectSlug, + secretPath, + secrets: inputSecrets + }); + + await server.services.auditLog.createAuditLog({ + projectId: secrets[0].workspace, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_SECRETS, + metadata: { + environment: req.body.environment, + secretPath: req.body.secretPath, + secrets: secrets.map((secret, i) => ({ + secretId: secret.id, + secretKey: inputSecrets[i].secretKey, + secretVersion: secret.version + })) + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretDeleted, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId: secrets[0].workspace, + environment: req.body.environment, + secretPath: req.body.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + return { secrets }; + } + }); + + server.route({ + method: "POST", + url: "/backfill-secret-references", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Backfill secret references", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { projectId } = req.body; + const message = await server.services.secret.backfillSecretReferences({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId + }); + + return message; + } + }); }; diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 2a2f50f43..ac43df36d 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { authRateLimit } from "@app/server/config/rateLimiter"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; export const registerSignupRouter = async (server: FastifyZodProvider) => { @@ -23,8 +25,26 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - await server.services.signup.beginEmailSignupProcess(req.body.email); - return { message: `Sent an email verification code to ${req.body.email}` }; + const { email } = req.body; + + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } + + if (serverCfg?.allowedSignUpDomain) { + const domain = email.split("@")[1]; + const allowedDomains = serverCfg.allowedSignUpDomain.split(",").map((e) => e.trim()); + if (!allowedDomains.includes(domain)) { + throw new BadRequestError({ + message: `Email with a domain (@${domain}) is not supported` + }); + } + } + await server.services.signup.beginEmailSignupProcess(email); + return { message: `Sent an email verification code to ${email}` }; } }); @@ -48,6 +68,13 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } + const { token, user } = await server.services.signup.verifyEmailSignup(req.body.email, req.body.code); return { message: "Successfuly verified email", token, user }; } @@ -61,7 +88,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - email: z.string().email().trim(), + email: z.string().trim(), firstName: z.string().trim(), lastName: z.string().trim().optional(), protectedKey: z.string().trim(), @@ -81,7 +108,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { 200: z.object({ message: z.string(), user: UsersSchema, - token: z.string() + token: z.string(), + organizationId: z.string().nullish() }) } }, @@ -90,20 +118,31 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { if (!userAgent) throw new Error("user agent header is required"); const appCfg = getConfig(); - const { user, accessToken, refreshToken } = await server.services.signup.completeEmailAccountSignup({ - ...req.body, - ip: req.realIp, - userAgent, - authorization: req.headers.authorization as string - }); + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } - void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + const { user, accessToken, refreshToken, organizationId } = + await server.services.signup.completeEmailAccountSignup({ + ...req.body, + ip: req.realIp, + userAgent, + authorization: req.headers.authorization as string + }); + + if (user.email) { + void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + } void server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.UserSignedUp, - distinctId: user.email, + distinctId: user.username ?? "", properties: { - email: user.email, + username: user.username, + email: user.email ?? "", attributionSource: req.body.attributionSource } }); @@ -115,7 +154,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { secure: appCfg.HTTPS_ENABLED }); - return { message: "Successfully set up account", user, token: accessToken }; + return { message: "Successfully set up account", user, token: accessToken, organizationId }; } }); @@ -156,7 +195,22 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { const { user, accessToken, refreshToken } = await server.services.signup.completeAccountInvite({ ...req.body, ip: req.realIp, - userAgent + userAgent, + authorization: req.headers.authorization as string + }); + + if (user.email) { + void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || ""); + } + + void server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.UserSignedUp, + distinctId: user.username ?? "", + properties: { + username: user.username, + email: user.email ?? "", + attributionSource: "Team Invite" + } }); void res.setCookie("jid", refreshToken, { diff --git a/backend/src/server/routes/v3/user-router.ts b/backend/src/server/routes/v3/user-router.ts index 1672405b1..a9fdba358 100644 --- a/backend/src/server/routes/v3/user-router.ts +++ b/backend/src/server/routes/v3/user-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -8,6 +9,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/me/api-keys", + config: { + rateLimit: readLimit + }, schema: { response: { 200: z.object({ diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 7fae75c30..5d68a4e94 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -27,10 +27,17 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_VERIFICATION: { + // generate random 6-digit code + const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const triesLeft = 3; + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, triesLeft, expiresAt }; + } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); - const triesLeft = 5; + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 300000); return { token, triesLeft, expiresAt }; } @@ -141,7 +148,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFact const user = await userDAL.findById(session.userId); if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" }); - return { user, tokenVersionId: token.tokenVersionId }; + return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId }; }; return { diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 74787f4ac..630e36310 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,5 +1,6 @@ export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", + TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 3e65f7e05..80fb0b325 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -5,13 +5,20 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { AuthModeProviderJwtTokenPayload, AuthModeProviderSignUpTokenPayload, AuthTokenType } from "./auth-type"; -export const validateProviderAuthToken = (providerToken: string, email: string) => { +export const validateProviderAuthToken = (providerToken: string, username?: string) => { if (!providerToken) throw new UnauthorizedError(); const appCfg = getConfig(); const decodedToken = jwt.verify(providerToken, appCfg.AUTH_SECRET) as AuthModeProviderJwtTokenPayload; if (decodedToken.authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new UnauthorizedError(); - if (decodedToken.email !== email) throw new Error("Invalid auth credentials"); + + if (decodedToken.username !== username) throw new Error("Invalid auth credentials"); + + if (decodedToken.organizationId) { + return { orgId: decodedToken.organizationId, authMethod: decodedToken.authMethod }; + } + + return { authMethod: decodedToken.authMethod, orgId: null }; }; export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index d63baeaca..4d2a302c6 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -1,12 +1,16 @@ import jwt from "jsonwebtoken"; import { TUsers, UserDeviceSchema } from "@app/db/schemas"; +import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; +import { TTokenDALFactory } from "../auth-token/auth-token-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgDALFactory } from "../org/org-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { validateProviderAuthToken } from "./auth-fns"; @@ -16,16 +20,24 @@ import { TOauthLoginDTO, TVerifyMfaTokenDTO } from "./auth-login-type"; -import { AuthMethod, AuthTokenType } from "./auth-type"; +import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type"; type TAuthLoginServiceFactoryDep = { userDAL: TUserDALFactory; + orgDAL: TOrgDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; + tokenDAL: TTokenDALFactory; }; export type TAuthLoginFactory = ReturnType; -export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: TAuthLoginServiceFactoryDep) => { +export const authLoginServiceFactory = ({ + userDAL, + tokenService, + smtpService, + orgDAL, + tokenDAL +}: TAuthLoginServiceFactoryDep) => { /* * Private * Not exported. This is to update user device list @@ -38,17 +50,19 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: if (!isDeviceSeen) { const newDeviceList = devices.concat([{ ip, userAgent }]); await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) }); - await smtpService.sendMail({ - template: SmtpTemplates.NewDeviceJoin, - subjectLine: "Successful login from new device", - recipients: [user.email], - substitutions: { - email: user.email, - timestamp: new Date().toString(), - ip, - userAgent - } - }); + if (user.email) { + await smtpService.sendMail({ + template: SmtpTemplates.NewDeviceJoin, + subjectLine: "Successful login from new device", + recipients: [user.email], + substitutions: { + email: user.email, + timestamp: new Date().toString(), + ip, + userAgent + } + }); + } } }; @@ -56,7 +70,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: * Private * Send mfa code via email * */ - const sendUserMfaCode = async (userId: string, email: string) => { + const sendUserMfaCode = async ({ userId, email }: { userId: string; email: string }) => { const code = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_MFA, userId @@ -76,7 +90,19 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: * Check user device and send mail if new device * generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled */ - const generateUserTokens = async (user: TUsers, ip: string, userAgent: string) => { + const generateUserTokens = async ({ + user, + ip, + userAgent, + organizationId, + authMethod + }: { + user: TUsers; + ip: string; + userAgent: string; + organizationId: string | undefined; + authMethod: AuthMethod; + }) => { const cfg = getConfig(); await updateUserDeviceSession(user, ip, userAgent); const tokenSession = await tokenService.getUserTokenSession({ @@ -85,12 +111,15 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: userId: user.id }); if (!tokenSession) throw new Error("Failed to create token"); + const accessToken = jwt.sign( { + authMethod, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: user.id, tokenVersionId: tokenSession.id, - accessVersion: tokenSession.accessVersion + accessVersion: tokenSession.accessVersion, + organizationId }, cfg.AUTH_SECRET, { expiresIn: cfg.JWT_AUTH_LIFETIME } @@ -98,10 +127,12 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: const refreshToken = jwt.sign( { + authMethod, authTokenType: AuthTokenType.REFRESH_TOKEN, userId: user.id, tokenVersionId: tokenSession.id, - refreshVersion: tokenSession.refreshVersion + refreshVersion: tokenSession.refreshVersion, + organizationId }, cfg.AUTH_SECRET, { expiresIn: cfg.JWT_REFRESH_LIFETIME } @@ -118,9 +149,11 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: providerAuthToken, clientPublicKey }: TLoginGenServerPublicKeyDTO) => { - const userEnc = await userDAL.findUserEncKeyByEmail(email); + const userEnc = await userDAL.findUserEncKeyByUsername({ + username: email + }); if (!userEnc || (userEnc && !userEnc.isAccepted)) { - throw new Error("Failed to find user"); + throw new Error("Failed to find user"); } if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) { validateProviderAuthToken(providerAuthToken as string, email); @@ -141,16 +174,26 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: const loginExchangeClientProof = async ({ email, clientProof, - providerAuthToken, ip, - userAgent + userAgent, + providerAuthToken }: TLoginClientProofDTO) => { - const userEnc = await userDAL.findUserEncKeyByEmail(email); + const userEnc = await userDAL.findUserEncKeyByUsername({ + username: email + }); if (!userEnc) throw new Error("Failed to find user"); const cfg = getConfig(); - if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) { - validateProviderAuthToken(providerAuthToken as string, email); + let authMethod = AuthMethod.EMAIL; + let organizationId: string | undefined; + + if (providerAuthToken) { + const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); + + authMethod = decodedProviderToken.authMethod; + if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && decodedProviderToken.orgId) { + organizationId = decodedProviderToken.orgId; + } } if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); @@ -168,63 +211,162 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: clientPublicKey: null }); // send multi factor auth token if they it enabled - if (userEnc.isMfaEnabled) { - const mfaToken = jwt.sign({ authTokenType: AuthTokenType.MFA_TOKEN, userId: userEnc.userId }, cfg.AUTH_SECRET, { - expiresIn: cfg.JWT_MFA_LIFETIME + if (userEnc.isMfaEnabled && userEnc.email) { + const mfaToken = jwt.sign( + { + authMethod, + authTokenType: AuthTokenType.MFA_TOKEN, + userId: userEnc.userId + }, + cfg.AUTH_SECRET, + { + expiresIn: cfg.JWT_MFA_LIFETIME + } + ); + + await sendUserMfaCode({ + userId: userEnc.userId, + email: userEnc.email }); - await sendUserMfaCode(userEnc.userId, userEnc.email); return { isMfaEnabled: true, token: mfaToken } as const; } - const token = await generateUserTokens({ ...userEnc, id: userEnc.userId }, ip, userAgent); + const token = await generateUserTokens({ + user: { + ...userEnc, + id: userEnc.userId + }, + ip, + userAgent, + authMethod, + organizationId + }); + return { token, isMfaEnabled: false, user: userEnc } as const; }; + const selectOrganization = async ({ + userAgent, + authJwtToken, + ipAddress, + organizationId + }: { + userAgent: string | undefined; + authJwtToken: string | undefined; + ipAddress: string; + organizationId: string; + }) => { + const cfg = getConfig(); + + if (!authJwtToken) throw new UnauthorizedError({ name: "Authorization header is required" }); + if (!userAgent) throw new UnauthorizedError({ name: "user agent header is required" }); + + // eslint-disable-next-line no-param-reassign + authJwtToken = authJwtToken.replace("Bearer ", ""); // remove bearer from token + + // The decoded JWT token, which contains the auth method. + const decodedToken = jwt.verify(authJwtToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload; + if (!decodedToken.authMethod) throw new UnauthorizedError({ name: "Auth method not found on existing token" }); + + const user = await userDAL.findUserEncKeyByUserId(decodedToken.userId); + if (!user) throw new BadRequestError({ message: "User not found", name: "Find user from token" }); + + // Check if the user actually has access to the specified organization. + const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId); + + if (!hasOrganizationMembership) { + throw new UnauthorizedError({ message: "User does not have access to the organization" }); + } + + await tokenDAL.incrementTokenSessionVersion(user.id, decodedToken.tokenVersionId); + + const tokens = await generateUserTokens({ + authMethod: decodedToken.authMethod, + user, + userAgent, + ip: ipAddress, + organizationId + }); + + return tokens; + }; + /* * Multi factor authentication re-send code, Get user id from token * saved in frontend */ const resendMfaToken = async (userId: string) => { const user = await userDAL.findById(userId); - if (!user) return; - await sendUserMfaCode(user.id, user.email); + if (!user || !user.email) return; + await sendUserMfaCode({ + userId: user.id, + email: user.email + }); }; /* * Multi factor authentication verification of code * Third step of login in which user completes with mfa * */ - const verifyMfaToken = async ({ userId, mfaToken, ip, userAgent }: TVerifyMfaTokenDTO) => { + const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => { await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_MFA, userId, code: mfaToken }); + + const decodedToken = jwt.verify(mfaJwtToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload; + const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to authenticate user"); - const token = await generateUserTokens({ ...userEnc, id: userEnc.userId }, ip, userAgent); + const token = await generateUserTokens({ + user: { + ...userEnc, + id: userEnc.userId + }, + ip, + userAgent, + organizationId: orgId, + authMethod: decodedToken.authMethod + }); + return { token, user: userEnc }; }; /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ - email, - firstName, - lastName, - authMethod, - callbackPort, - isSignupAllowed - }: TOauthLoginDTO) => { - let user = await userDAL.findUserByEmail(email); + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { + let user = await userDAL.findUserByUsername(email); + const serverCfg = await getServerCfg(); + const appCfg = getConfig(); - const isOauthSignUpDisabled = !isSignupAllowed && !user; - if (isOauthSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Oauth 2 login" }); if (!user) { - user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod] }); + // Create a new user based on oAuth + if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); + + if (serverCfg?.allowedSignUpDomain) { + const domain = email.split("@")[1]; + const allowedDomains = serverCfg.allowedSignUpDomain.split(",").map((e) => e.trim()); + if (!allowedDomains.includes(domain)) + throw new BadRequestError({ + message: `Email with a domain (@${domain}) is not supported`, + name: "Oauth 2 login" + }); + } + + user = await userDAL.create({ + username: email, + email, + isEmailVerified: true, + firstName, + lastName, + authMethods: [authMethod], + isGhost: false + }); } const isLinkingRequired = !user?.authMethods?.includes(authMethod); const isUserCompleted = user.isAccepted; @@ -232,7 +374,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, + username: user.username, email: user.email, + isEmailVerified: user.isEmailVerified, firstName: user.firstName, lastName: user.lastName, authMethod, @@ -268,6 +412,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: oauth2Login, resendMfaToken, verifyMfaToken, + selectOrganization, generateUserTokens }; }; diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 3d67fef87..37b90f548 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -17,8 +17,10 @@ export type TLoginClientProofDTO = { export type TVerifyMfaTokenDTO = { userId: string; mfaToken: string; + mfaJwtToken: string; ip: string; userAgent: string; + orgId?: string; }; export type TOauthLoginDTO = { @@ -27,5 +29,4 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; - isSignupAllowed?: boolean; }; diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index ff07d422f..4025e4903 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -99,7 +99,7 @@ export const authPaswordServiceFactory = ({ * Email password reset flow via email. Step 1 send email */ const sendPasswordResetEmail = async (email: string) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) return; @@ -126,7 +126,7 @@ export const authPaswordServiceFactory = ({ * */ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) { throw new Error("Failed email verification for pass reset"); @@ -192,7 +192,7 @@ export const authPaswordServiceFactory = ({ }: TCreateBackupPrivateKeyDTO) => { const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc || (userEnc && !userEnc.isAccepted)) { - throw new Error("Failed to find user"); + throw new Error("Failed to find user"); } if (!userEnc.clientPublicKey || !userEnc.serverPrivateKey) throw new Error("failed to create backup key"); @@ -239,7 +239,7 @@ export const authPaswordServiceFactory = ({ const getBackupPrivateKeyOfUser = async (userId: string) => { const user = await userDAL.findUserEncKeyByUserId(userId); if (!user || (user && !user.isAccepted)) { - throw new Error("Failed to find user"); + throw new Error("Failed to find user"); } const backupKey = await authDAL.getBackupPrivateKeyByUserId(userId); if (!backupKey) throw new Error("Failed to find user backup key"); diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 6090a2129..be7f5777d 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,10 +1,17 @@ import jwt from "jsonwebtoken"; -import { OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; +import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; @@ -20,6 +27,14 @@ import { AuthMethod, AuthTokenType } from "./auth-type"; type TAuthSignupDep = { authDAL: TAuthDALFactory; userDAL: TUserDALFactory; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds" + >; + projectKeyDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + groupProjectDAL: Pick; orgService: Pick; orgDAL: TOrgDALFactory; tokenService: TAuthTokenServiceFactory; @@ -31,6 +46,11 @@ export type TAuthSignupFactory = ReturnType; export const authSignupServiceFactory = ({ authDAL, userDAL, + userGroupMembershipDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + groupProjectDAL, tokenService, smtpService, orgService, @@ -44,13 +64,13 @@ export const authSignupServiceFactory = ({ throw new Error("Provided a disposable email"); } - let user = await userDAL.findUserByEmail(email); + let user = await userDAL.findUserByUsername(email); if (user && user.isAccepted) { // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); } if (!user) { - user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email }); + user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false }); } if (!user) throw new Error("Failed to create user"); @@ -60,9 +80,9 @@ export const authSignupServiceFactory = ({ }); await smtpService.sendMail({ - template: SmtpTemplates.EmailVerification, + template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", - recipients: [email], + recipients: [user.email as string], substitutions: { code: token } @@ -70,7 +90,7 @@ export const authSignupServiceFactory = ({ }; const verifyEmailSignup = async (email: string, code: string) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); @@ -82,6 +102,8 @@ export const authSignupServiceFactory = ({ code }); + await userDAL.updateById(user.id, { isEmailVerified: true }); + // generate jwt token this is a temporary token const jwtToken = jwt.sign( { @@ -115,13 +137,17 @@ export const authSignupServiceFactory = ({ userAgent, authorization }: TCompleteAccountSignupDTO) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findOne({ username: email }); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } + let organizationId: string | null = null; + let authMethod: AuthMethod | null = null; if (providerAuthToken) { - validateProviderAuthToken(providerAuthToken, user.email); + const { orgId, authMethod: userAuthMethod } = validateProviderAuthToken(providerAuthToken, user.username); + authMethod = userAuthMethod; + organizationId = orgId; } else { validateSignUpAuthorization(authorization, user.id); } @@ -144,15 +170,38 @@ export const authSignupServiceFactory = ({ }, tx ); + // If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it + if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && organizationId) { + const [pendingOrgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + status: OrgMembershipStatus.Invited, + [`${TableName.OrgMembership}.orgId` as "orgId"]: organizationId + }); + + if (pendingOrgMembership) { + await orgDAL.updateMembershipById( + pendingOrgMembership.id, + { + status: OrgMembershipStatus.Accepted + }, + tx + ); + } + } + return { info: us, key: userEncKey }; }); - const hasSamlEnabled = user?.authMethods?.some((authMethod) => - [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes(authMethod as AuthMethod) - ); + if (!organizationId) { + const newOrganization = await orgService.createOrganization({ + userId: user.id, + userEmail: user.email ?? user.username, + orgName: organizationName + }); - if (!hasSamlEnabled) { - await orgService.createOrganization(user.id, user.email, organizationName); + if (!newOrganization) throw new Error("Failed to create organization"); + + organizationId = newOrganization.id; } const updatedMembersips = await orgDAL.updateMembership( @@ -162,6 +211,16 @@ export const authSignupServiceFactory = ({ const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId))); + await convertPendingGroupAdditionsToGroupMemberships({ + userIds: [user.id], + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL + }); + const tokenSession = await tokenService.getUserTokenSession({ userAgent, ip, @@ -172,10 +231,12 @@ export const authSignupServiceFactory = ({ const accessToken = jwt.sign( { + authMethod: AuthMethod.EMAIL, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, - accessVersion: tokenSession.accessVersion + accessVersion: tokenSession.accessVersion, + organizationId }, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_AUTH_LIFETIME } @@ -183,16 +244,18 @@ export const authSignupServiceFactory = ({ const refreshToken = jwt.sign( { + authMethod: AuthMethod.EMAIL, authTokenType: AuthTokenType.REFRESH_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, - refreshVersion: tokenSession.refreshVersion + refreshVersion: tokenSession.refreshVersion, + organizationId }, appCfg.AUTH_SECRET, { expiresIn: appCfg.JWT_REFRESH_LIFETIME } ); - return { user: updateduser.info, accessToken, refreshToken }; + return { user: updateduser.info, accessToken, refreshToken, organizationId }; }; /* @@ -212,13 +275,16 @@ export const authSignupServiceFactory = ({ protectedKeyTag, encryptedPrivateKey, encryptedPrivateKeyIV, - encryptedPrivateKeyTag + encryptedPrivateKeyTag, + authorization }: TCompleteAccountInviteDTO) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } + validateSignUpAuthorization(authorization, user.id); + const [orgMembership] = await orgDAL.findMembership({ inviteEmail: email, status: OrgMembershipStatus.Invited @@ -257,6 +323,17 @@ export const authSignupServiceFactory = ({ const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId))); + await convertPendingGroupAdditionsToGroupMemberships({ + userIds: [user.id], + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx + }); + return { info: us, key: userEncKey }; }); @@ -270,6 +347,7 @@ export const authSignupServiceFactory = ({ const accessToken = jwt.sign( { + authMethod: AuthMethod.EMAIL, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, @@ -281,6 +359,7 @@ export const authSignupServiceFactory = ({ const refreshToken = jwt.sign( { + authMethod: AuthMethod.EMAIL, authTokenType: AuthTokenType.REFRESH_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index 69b779e8b..a37a1cd96 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -34,4 +34,5 @@ export type TCompleteAccountInviteDTO = { verifier: string; ip: string; userAgent: string; + authorization: string; }; diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 26417b0e8..8e7b92253 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -5,7 +5,10 @@ export enum AuthMethod { GITLAB = "gitlab", OKTA_SAML = "okta-saml", AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + JUMPCLOUD_SAML = "jumpcloud-saml", + GOOGLE_SAML = "google-saml", + KEYCLOAK_SAML = "keycloak-saml", + LDAP = "ldap" } export enum AuthTokenType { @@ -17,45 +20,61 @@ export enum AuthTokenType { API_KEY = "apiKey", SERVICE_ACCESS_TOKEN = "serviceAccessToken", SERVICE_REFRESH_TOKEN = "serviceRefreshToken", - IDENTITY_ACCESS_TOKEN = "identityAccessToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", + SCIM_TOKEN = "scimToken" } export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", API_KEY = "apiKey", - IDENTITY_ACCESS_TOKEN = "identityAccessToken" + IDENTITY_ACCESS_TOKEN = "identityAccessToken", + SCIM_TOKEN = "scimToken" } export enum ActorType { // would extend to AWS, Azure, ... USER = "user", // userIdentity SERVICE = "service", IDENTITY = "identity", - Machine = "machine" + Machine = "machine", + SCIM_CLIENT = "scimClient" } +// This will be null unless the token-type is JWT +export type ActorAuthMethod = AuthMethod | null; + export type AuthModeJwtTokenPayload = { authTokenType: AuthTokenType.ACCESS_TOKEN; + authMethod: AuthMethod; userId: string; tokenVersionId: string; accessVersion: number; + organizationId?: string; }; export type AuthModeMfaJwtTokenPayload = { authTokenType: AuthTokenType.MFA_TOKEN; + authMethod: AuthMethod; userId: string; + organizationId?: string; }; export type AuthModeRefreshJwtTokenPayload = { + // authMode authTokenType: AuthTokenType.REFRESH_TOKEN; + authMethod: AuthMethod; userId: string; tokenVersionId: string; refreshVersion: number; + organizationId?: string; }; export type AuthModeProviderJwtTokenPayload = { authTokenType: AuthTokenType.PROVIDER_TOKEN; + username: string; + authMethod: AuthMethod; email: string; + organizationId?: string; }; export type AuthModeProviderSignUpTokenPayload = { diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts new file mode 100644 index 000000000..3b0523dde --- /dev/null +++ b/backend/src/services/group-project/group-project-dal.ts @@ -0,0 +1,99 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; + +export type TGroupProjectDALFactory = ReturnType; + +export const groupProjectDALFactory = (db: TDbClient) => { + const groupProjectOrm = ormify(db, TableName.GroupProjectMembership); + + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership), + db.ref("id").as("groupId").withSchema(TableName.Groups), + db.ref("name").as("groupName").withSchema(TableName.Groups), + db.ref("slug").as("groupSlug").withSchema(TableName.Groups), + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + ); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ groupId, groupName, groupSlug, id, createdAt, updatedAt }) => ({ + id, + groupId, + createdAt, + updatedAt, + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + } catch (error) { + throw new DatabaseError({ error, name: "FindByProjectId" }); + } + }; + + return { ...groupProjectOrm, findByProjectId }; +}; diff --git a/backend/src/services/group-project/group-project-membership-role-dal.ts b/backend/src/services/group-project/group-project-membership-role-dal.ts new file mode 100644 index 000000000..5572ac6f5 --- /dev/null +++ b/backend/src/services/group-project/group-project-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TGroupProjectMembershipRoleDALFactory = ReturnType; + +export const groupProjectMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GroupProjectMembershipRole); + return orm; +}; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts new file mode 100644 index 000000000..17862dd6f --- /dev/null +++ b/backend/src/services/group-project/group-project-service.ts @@ -0,0 +1,345 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectMembershipRole, SecretKeyEncoding } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; + +import { TGroupDALFactory } from "../../ee/services/group/group-dal"; +import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TGroupProjectDALFactory } from "./group-project-dal"; +import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membership-role-dal"; +import { + TCreateProjectGroupDTO, + TDeleteProjectGroupDTO, + TListProjectGroupDTO, + TUpdateProjectGroupDTO +} from "./group-project-types"; + +type TGroupProjectServiceFactoryDep = { + groupProjectDAL: Pick; + groupProjectMembershipRoleDAL: Pick< + TGroupProjectMembershipRoleDALFactory, + "create" | "transaction" | "insertMany" | "delete" + >; + userGroupMembershipDAL: Pick; + projectDAL: Pick; + projectKeyDAL: Pick; + projectRoleDAL: Pick; + projectBotDAL: TProjectBotDALFactory; + groupDAL: Pick; + permissionService: Pick; +}; + +export type TGroupProjectServiceFactory = ReturnType; + +export const groupProjectServiceFactory = ({ + groupDAL, + groupProjectDAL, + groupProjectMembershipRoleDAL, + userGroupMembershipDAL, + projectDAL, + projectKeyDAL, + projectBotDAL, + projectRoleDAL, + permissionService +}: TGroupProjectServiceFactoryDep) => { + const addGroupToProject = async ({ + groupSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectSlug, + role + }: TCreateProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + if (project.version < 2) throw new BadRequestError({ message: `Failed to add group to E2EE project` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const existingGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); + if (existingGroup) + throw new BadRequestError({ + message: `Group with slug ${groupSlug} already exists in project with id ${project.id}` + }); + + const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( + role, + project.id + ); + const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPrivilege) + throw new ForbiddenRequestError({ + message: "Failed to add group to project with more privileged role" + }); + const isCustomRole = Boolean(customRole); + + const projectGroup = await groupProjectDAL.transaction(async (tx) => { + const groupProjectMembership = await groupProjectDAL.create( + { + groupId: group.id, + projectId: project.id + }, + tx + ); + + await groupProjectMembershipRoleDAL.create( + { + projectMembershipId: groupProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + customRoleId: customRole?.id + }, + tx + ); + + // share project key with users in group that have not + // individually been added to the project and that are not part of + // other groups that are in the project + const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx); + + if (groupMembers.length) { + const ghostUser = await projectDAL.findProjectGhostUser(project.id, tx); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, project.id, tx); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId: project.id }, tx); + + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const plaintextProjectKey = decryptAsymmetric({ + ciphertext: ghostUserLatestKey.encryptedKey, + nonce: ghostUserLatestKey.nonce, + publicKey: ghostUserLatestKey.sender.publicKey, + privateKey: botPrivateKey + }); + + const projectKeyData = groupMembers.map(({ user: { publicKey, id } }) => { + const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, publicKey, botPrivateKey); + + return { + encryptedKey, + nonce, + senderId: ghostUser.id, + receiverId: id, + projectId: project.id + }; + }); + + await projectKeyDAL.insertMany(projectKeyData, tx); + } + + return groupProjectMembership; + }); + + return projectGroup; + }; + + const updateGroupInProject = async ({ + projectSlug, + groupSlug, + roles, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const projectGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); + if (!projectGroup) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId: project.id, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: projectGroup.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: projectGroup.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await groupProjectMembershipRoleDAL.transaction(async (tx) => { + await groupProjectMembershipRoleDAL.delete({ projectMembershipId: projectGroup.id }, tx); + return groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + }); + + return updatedRoles; + }; + + const removeGroupFromProject = async ({ + projectSlug, + groupSlug, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TDeleteProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const groupProjectMembership = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); + if (!groupProjectMembership) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + + const deletedProjectGroup = await groupProjectDAL.transaction(async (tx) => { + const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx); + + if (groupMembers.length) { + await projectKeyDAL.delete( + { + projectId: project.id, + $in: { + receiverId: groupMembers.map(({ user: { id } }) => id) + } + }, + tx + ); + } + + const [projectGroup] = await groupProjectDAL.delete({ groupId: group.id, projectId: project.id }, tx); + return projectGroup; + }); + + return deletedProjectGroup; + }; + + const listGroupsInProject = async ({ + projectSlug, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + + const groupMemberships = await groupProjectDAL.findByProjectId(project.id); + return groupMemberships; + }; + + return { + addGroupToProject, + updateGroupInProject, + removeGroupFromProject, + listGroupsInProject + }; +}; diff --git a/backend/src/services/group-project/group-project-types.ts b/backend/src/services/group-project/group-project-types.ts new file mode 100644 index 000000000..c867b75c0 --- /dev/null +++ b/backend/src/services/group-project/group-project-types.ts @@ -0,0 +1,31 @@ +import { TProjectSlugPermission } from "@app/lib/types"; + +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; + +export type TCreateProjectGroupDTO = { + groupSlug: string; + role: string; +} & TProjectSlugPermission; + +export type TUpdateProjectGroupDTO = { + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; + groupSlug: string; +} & TProjectSlugPermission; + +export type TDeleteProjectGroupDTO = { + groupSlug: string; +} & TProjectSlugPermission; + +export type TListProjectGroupDTO = TProjectSlugPermission; diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 42fb5bba5..92bae670c 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TIdentityAccessTokens } from "@app/db/schemas"; +import { IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -15,23 +15,56 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { const doc = await (tx || db)(TableName.IdentityAccessToken) .where(filter) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) - .leftJoin( - TableName.IdentityUaClientSecret, - `${TableName.IdentityAccessToken}.identityUAClientSecretId`, - `${TableName.IdentityUaClientSecret}.id` - ) - .leftJoin( - TableName.IdentityUniversalAuth, - `${TableName.IdentityUaClientSecret}.identityUAId`, - `${TableName.IdentityUniversalAuth}.id` - ) + .leftJoin(TableName.IdentityUaClientSecret, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.Univeral])).andOn( + `${TableName.IdentityAccessToken}.identityUAClientSecretId`, + `${TableName.IdentityUaClientSecret}.id` + ); + }) + .leftJoin(TableName.IdentityUniversalAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.Univeral])).andOn( + `${TableName.IdentityUaClientSecret}.identityUAId`, + `${TableName.IdentityUniversalAuth}.id` + ); + }) + .leftJoin(TableName.IdentityGcpAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.GCP_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityGcpAuth}.identityId` + ); + }) + .leftJoin(TableName.IdentityAwsAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.AWS_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityAwsAuth}.identityId` + ); + }) + .leftJoin(TableName.IdentityKubernetesAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.KUBERNETES_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityKubernetesAuth}.identityId` + ); + }) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select( - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), db.ref("name").withSchema(TableName.Identity) ) .first(); - return doc; + + if (!doc) return; + + return { + ...doc, + accessTokenTrustedIps: + doc.accessTokenTrustedIpsUa || + doc.accessTokenTrustedIpsGcp || + doc.accessTokenTrustedIpsAws || + doc.accessTokenTrustedIpsK8s + }; } catch (error) { throw new DatabaseError({ error, name: "IdAccessTokenFindOne" }); } diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index cdc8effe2..898d0bc62 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -6,17 +6,20 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip"; import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types"; type TIdentityAccessTokenServiceFactoryDep = { identityAccessTokenDAL: TIdentityAccessTokenDALFactory; + identityOrgMembershipDAL: TIdentityOrgDALFactory; }; export type TIdentityAccessTokenServiceFactory = ReturnType; export const identityAccessTokenServiceFactory = ({ - identityAccessTokenDAL + identityAccessTokenDAL, + identityOrgMembershipDAL }: TIdentityAccessTokenServiceFactoryDep) => { const validateAccessTokenExp = (identityAccessToken: TIdentityAccessTokens) => { const { @@ -35,12 +38,12 @@ export const identityAccessTokenServiceFactory = ({ } // ttl check - if (accessTokenTTL > 0) { + if (Number(accessTokenTTL) > 0) { const currentDate = new Date(); if (accessTokenLastRenewedAt) { // access token has been renewed const accessTokenRenewed = new Date(accessTokenLastRenewedAt); - const ttlInMilliseconds = accessTokenTTL * 1000; + const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); if (currentDate > expirationDate) @@ -50,7 +53,7 @@ export const identityAccessTokenServiceFactory = ({ } else { // access token has never been renewed const accessTokenCreated = new Date(accessTokenCreatedAt); - const ttlInMilliseconds = accessTokenTTL * 1000; + const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); if (currentDate > expirationDate) @@ -61,9 +64,9 @@ export const identityAccessTokenServiceFactory = ({ } // max ttl checks - if (accessTokenMaxTTL > 0) { + if (Number(accessTokenMaxTTL) > 0) { const accessTokenCreated = new Date(accessTokenCreatedAt); - const ttlInMilliseconds = accessTokenMaxTTL * 1000; + const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; const currentDate = new Date(); const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); @@ -72,7 +75,7 @@ export const identityAccessTokenServiceFactory = ({ message: "Failed to renew MI access token due to Max TTL expiration" }); - const extendToDate = new Date(currentDate.getTime() + accessTokenTTL); + const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL)); if (extendToDate > expirationDate) throw new UnauthorizedError({ message: "Failed to renew MI access token past its Max TTL expiration" @@ -103,6 +106,24 @@ export const identityAccessTokenServiceFactory = ({ return { accessToken, identityAccessToken: updatedIdentityAccessToken }; }; + const revokeAccessToken = async (accessToken: string) => { + const appCfg = getConfig(); + + const decodedToken = jwt.verify(accessToken, appCfg.AUTH_SECRET) as JwtPayload & { + identityAccessTokenId: string; + }; + if (decodedToken.authTokenType !== AuthTokenType.IDENTITY_ACCESS_TOKEN) throw new UnauthorizedError(); + + const identityAccessToken = await identityAccessTokenDAL.findOne({ + [`${TableName.IdentityAccessToken}.id` as "id"]: decodedToken.identityAccessTokenId, + isAccessTokenRevoked: false + }); + if (!identityAccessToken) throw new UnauthorizedError(); + + const revokedToken = await identityAccessTokenDAL.deleteById(identityAccessToken.id); + return { revokedToken }; + }; + const fnValidateIdentityAccessToken = async (token: TIdentityAccessTokenJwtPayload, ipAddress?: string) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, @@ -117,9 +138,17 @@ export const identityAccessTokenServiceFactory = ({ }); } + const identityOrgMembership = await identityOrgMembershipDAL.findOne({ + identityId: identityAccessToken.identityId + }); + + if (!identityOrgMembership) { + throw new UnauthorizedError({ message: "Identity does not belong to any organization" }); + } + validateAccessTokenExp(identityAccessToken); - return identityAccessToken; + return { ...identityAccessToken, orgId: identityOrgMembership.orgId }; }; - return { renewAccessToken, fnValidateIdentityAccessToken }; + return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts new file mode 100644 index 000000000..6ce215c58 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAwsAuthDALFactory = ReturnType; + +export const identityAwsAuthDALFactory = (db: TDbClient) => { + const awsAuthOrm = ormify(db, TableName.IdentityAwsAuth); + + return awsAuthOrm; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts new file mode 100644 index 000000000..517e9f613 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts @@ -0,0 +1,67 @@ +/** + * Extracts the identity ARN from the GetCallerIdentity response to one of the following formats: + * - arn:aws:iam::123456789012:user/MyUserName + * - arn:aws:iam::123456789012:role/MyRoleName + */ +export const extractPrincipalArn = (arn: string) => { + // split the ARN into parts using ":" as the delimiter + const fullParts = arn.split(":"); + if (fullParts.length !== 6) { + throw new Error(`Unrecognized ARN: contains ${fullParts.length} colon-separated parts, expected 6`); + } + const [prefix, partition, service, , accountNumber, resource] = fullParts; + if (prefix !== "arn") { + throw new Error('Unrecognized ARN: does not begin with "arn:"'); + } + + // structure to hold the parsed data + const entity = { + Partition: partition, + Service: service, + AccountNumber: accountNumber, + Type: "", + Path: "", + FriendlyName: "", + SessionInfo: "" + }; + + // validate the service is either 'iam' or 'sts' + if (entity.Service !== "iam" && entity.Service !== "sts") { + throw new Error(`Unrecognized service: ${entity.Service}, not one of iam or sts`); + } + + // parse the last part of the ARN which describes the resource + const parts = resource.split("/"); + if (parts.length < 2) { + throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 2 slash-separated parts`); + } + + const [type, ...rest] = parts; + entity.Type = type; + entity.FriendlyName = parts[parts.length - 1]; + + // handle different types of resources + switch (entity.Type) { + case "assumed-role": { + if (rest.length < 2) { + throw new Error(`Unrecognized ARN: "${resource}" contains fewer than 3 slash-separated parts`); + } + // assumed roles use a special format where the friendly name is the role name + const [roleName, sessionId] = rest; + entity.Type = "role"; // treat assumed role case as role + entity.FriendlyName = roleName; + entity.SessionInfo = sessionId; + break; + } + case "user": + case "role": + case "instance-profile": + // standard cases: just join back the path if there's any + entity.Path = rest.slice(0, -1).join("/"); + break; + default: + throw new Error(`Unrecognized principal type: "${entity.Type}"`); + } + + return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts new file mode 100644 index 000000000..a58944909 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -0,0 +1,310 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal"; +import { extractPrincipalArn } from "./identity-aws-auth-fns"; +import { + TAttachAwsAuthDTO, + TAwsGetCallerIdentityHeaders, + TGetAwsAuthDTO, + TGetCallerIdentityResponse, + TLoginAwsAuthDTO, + TUpdateAwsAuthDTO +} from "./identity-aws-auth-types"; + +type TIdentityAwsAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityAwsAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityDAL: Pick; + licenseService: Pick; + permissionService: Pick; +}; + +export type TIdentityAwsAuthServiceFactory = ReturnType; + +export const identityAwsAuthServiceFactory = ({ + identityAccessTokenDAL, + identityAwsAuthDAL, + identityOrgMembershipDAL, + identityDAL, + licenseService, + permissionService +}: TIdentityAwsAuthServiceFactoryDep) => { + const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { + const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); + if (!identityAwsAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsAuth.identityId }); + + const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); + const body: string = Buffer.from(iamRequestBody, "base64").toString(); + + const { + data: { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { Account, Arn } + } + } + }: { data: TGetCallerIdentityResponse } = await axios({ + method: iamHttpRequestMethod, + url: identityAwsAuth.stsEndpoint, + headers, + data: body + }); + + if (identityAwsAuth.allowedAccountIds) { + // validate if Account is in the list of allowed Account IDs + + const isAccountAllowed = identityAwsAuth.allowedAccountIds + .split(",") + .map((accountId) => accountId.trim()) + .some((accountId) => accountId === Account); + + if (!isAccountAllowed) throw new UnauthorizedError(); + } + + if (identityAwsAuth.allowedPrincipalArns) { + // validate if Arn is in the list of allowed Principal ARNs + + const isArnAllowed = identityAwsAuth.allowedPrincipalArns + .split(",") + .map((principalArn) => principalArn.trim()) + .some((principalArn) => { + // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" + // considers exact matches + wildcard matches + const regex = new RegExp(`^${principalArn.replace(/\*/g, ".*")}$`); + return regex.test(extractPrincipalArn(Arn)); + }); + + if (!isArnAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAwsAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAwsAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAwsAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAwsAuth = async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add AWS Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { + const doc = await identityAwsAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type: "iam", + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AWS_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAwsAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAwsAuth = async ({ + identityId, + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) + throw new BadRequestError({ + message: "Failed to update AWS Auth" + }); + + const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityAwsAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityAwsAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAwsAuth = await identityAwsAuthDAL.updateById(identityAwsAuth.id, { + stsEndpoint, + allowedPrincipalArns, + allowedAccountIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedAwsAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getAwsAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAwsAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AWS_AUTH) + throw new BadRequestError({ + message: "The identity does not have AWS Auth attached" + }); + + const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAwsAuth, + updateAwsAuth, + getAwsAuth + }; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts new file mode 100644 index 000000000..e45783ae1 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -0,0 +1,54 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAwsAuthDTO = { + identityId: string; + iamHttpRequestMethod: string; + iamRequestBody: string; + iamRequestHeaders: string; +}; + +export type TAttachAwsAuthDTO = { + identityId: string; + stsEndpoint: string; + allowedPrincipalArns: string; + allowedAccountIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAwsAuthDTO = { + identityId: string; + stsEndpoint?: string; + allowedPrincipalArns?: string; + allowedAccountIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAwsAuthDTO = { + identityId: string; +} & Omit; + +export type TAwsGetCallerIdentityHeaders = { + "Content-Type": string; + Host: string; + "X-Amz-Date": string; + "Content-Length": number; + "x-amz-security-token": string; + Authorization: string; +}; + +export type TGetCallerIdentityResponse = { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { + Account: string; + Arn: string; + UserId: string; + }; + ResponseMetadata: { RequestId: string }; + }; +}; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts new file mode 100644 index 000000000..2cb7b4ea4 --- /dev/null +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +const twelveDigitRegex = /^\d{12}$/; +const arnRegex = /^arn:aws:iam::\d{12}:(user\/[\w-]+|role\/[\w-]+|\*)$/; + +export const validateAccountIds = z + .string() + .trim() + .default("") + // Custom validation to ensure each part is a 12-digit number + .refine( + (data) => { + if (data === "") return true; + // Split the string by commas to check each supposed number + const accountIds = data.split(",").map((id) => id.trim()); + // Return true only if every item matches the 12-digit requirement + return accountIds.every((id) => twelveDigitRegex.test(id)); + }, + { + message: "Each account ID must be a 12-digit number." + } + ) + // Transform the string to normalize space after commas + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + +export const validatePrincipalArns = z + .string() + .trim() + .default("") + // Custom validation for ARN format + .refine( + (data) => { + // Skip validation if the string is empty + if (data === "") return true; + // Split the string by commas to check each supposed ARN + const arns = data.split(","); + // Return true only if every item matches one of the allowed ARN formats + return arns.every((arn) => arnRegex.test(arn.trim())); + }, + { + message: + "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'." + } + ) + // Transform to normalize the spaces around commas + .transform((data) => + data + .split(",") + .map((arn) => arn.trim()) + .join(", ") + ); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts new file mode 100644 index 000000000..e10250445 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityGcpAuthDALFactory = ReturnType; + +export const identityGcpAuthDALFactory = (db: TDbClient) => { + const gcpAuthOrm = ormify(db, TableName.IdentityGcpAuth); + return gcpAuthOrm; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts new file mode 100644 index 000000000..e1afceada --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-fns.ts @@ -0,0 +1,70 @@ +import axios from "axios"; +import { OAuth2Client } from "google-auth-library"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TDecodedGcpIamAuthJwt, TGcpIdTokenPayload } from "./identity-gcp-auth-types"; + +/** + * Validates that the identity token [jwt] sent in from a client GCE instance as part of GCP ID Token authentication + * is valid. + * @param {string} identityId - The ID of the identity in Infisical that is being authenticated against (used as audience). + * @param {string} jwt - The identity token to validate. + * @param {string} credentials - The credentials in the GCP Auth configuration for Infisical. + */ +export const validateIdTokenIdentity = async ({ + identityId, + jwt: identityToken +}: { + identityId: string; + jwt: string; +}) => { + const oAuth2Client = new OAuth2Client(); + const response = await oAuth2Client.getFederatedSignonCerts(); + const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + identityToken, + response.certs, + identityId, // audience + ["https://accounts.google.com"] + ); + const payload = ticket.getPayload() as TGcpIdTokenPayload; + if (!payload || !payload.email) throw new UnauthorizedError(); + + return { email: payload.email, computeEngineDetails: payload.google?.compute_engine }; +}; + +/** + * Validates that the signed JWT token for a GCP service account is valid as part of GCP IAM authentication. + * @param {string} identityId - The ID of the identity in Infisical that is being authenticated against (used as audience). + * @param {string} jwt - The signed JWT token to validate. + * @param {string} credentials - The credentials in the GCP Auth configuration for Infisical. + * @returns + */ +export const validateIamIdentity = async ({ + identityId, + jwt: serviceAccountJwt +}: { + identityId: string; + jwt: string; +}) => { + const decodedJwt = jwt.decode(serviceAccountJwt, { complete: true }) as TDecodedGcpIamAuthJwt; + const { sub, aud } = decodedJwt.payload; + + const { + data + }: { + data: { + [key: string]: string; + }; + } = await axios.get(`https://www.googleapis.com/service_accounts/v1/metadata/x509/${sub}`); + + const publicKey = data[decodedJwt.header.kid]; + + jwt.verify(serviceAccountJwt, publicKey, { + algorithms: ["RS256"] + }); + + if (aud !== identityId) throw new UnauthorizedError(); + return { email: sub }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts new file mode 100644 index 000000000..5f829cb33 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -0,0 +1,324 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityGcpAuthDALFactory } from "./identity-gcp-auth-dal"; +import { validateIamIdentity, validateIdTokenIdentity } from "./identity-gcp-auth-fns"; +import { + TAttachGcpAuthDTO, + TGcpIdentityDetails, + TGetGcpAuthDTO, + TLoginGcpAuthDTO, + TUpdateGcpAuthDTO +} from "./identity-gcp-auth-types"; + +type TIdentityGcpAuthServiceFactoryDep = { + identityGcpAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityGcpAuthServiceFactory = ReturnType; + +export const identityGcpAuthServiceFactory = ({ + identityGcpAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityGcpAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + if (!identityGcpAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityGcpAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + let gcpIdentityDetails: TGcpIdentityDetails; + switch (identityGcpAuth.type) { + case "gce": { + gcpIdentityDetails = await validateIdTokenIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + case "iam": { + gcpIdentityDetails = await validateIamIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + default: { + throw new BadRequestError({ message: "Invalid GCP Auth type" }); + } + } + + if (identityGcpAuth.allowedServiceAccounts) { + // validate if the service account is in the list of allowed service accounts + + const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts + .split(",") + .map((serviceAccount) => serviceAccount.trim()) + .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + + if (!isServiceAccountAllowed) throw new UnauthorizedError(); + } + + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { + // validate if the project that the service account belongs to is in the list of allowed projects + + const isProjectAllowed = identityGcpAuth.allowedProjects + .split(",") + .map((project) => project.trim()) + .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); + + if (!isProjectAllowed) throw new UnauthorizedError(); + } + + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { + const isZoneAllowed = identityGcpAuth.allowedZones + .split(",") + .map((zone) => zone.trim()) + .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); + + if (!isZoneAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityGcpAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityGcpAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityGcpAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachGcpAuth = async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add GCP Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { + const doc = await identityGcpAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.GCP_AUTH + }, + tx + ); + return doc; + }); + return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateGcpAuth = async ({ + identityId, + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "Failed to update GCP Auth" + }); + + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedGcpAuth = await identityGcpAuthDAL.updateById(identityGcpAuth.id, { + type, + allowedServiceAccounts, + allowedProjects, + allowedZones, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedGcpAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getGcpAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetGcpAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.GCP_AUTH) + throw new BadRequestError({ + message: "The identity does not have GCP Auth attached" + }); + + const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachGcpAuth, + updateGcpAuth, + getGcpAuth + }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts new file mode 100644 index 000000000..60ab36b58 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts @@ -0,0 +1,78 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginGcpAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachGcpAuthDTO = { + identityId: string; + type: "iam" | "gce"; + allowedServiceAccounts: string; + allowedProjects: string; + allowedZones: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateGcpAuthDTO = { + identityId: string; + type?: "iam" | "gce"; + allowedServiceAccounts?: string; + allowedProjects?: string; + allowedZones?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetGcpAuthDTO = { + identityId: string; +} & Omit; + +type TComputeEngineDetails = { + instance_creation_timestamp: number; + instance_id: string; + instance_name: string; + project_id: string; + project_number: number; + zone: string; +}; + +export type TGcpIdentityDetails = { + email: string; + computeEngineDetails?: TComputeEngineDetails; +}; + +export type TGcpIdTokenPayload = { + aud: string; + azp: string; + email: string; + email_verified: boolean; + exp: number; + google?: { + compute_engine: TComputeEngineDetails; + }; + iat: number; + iss: string; + sub: string; +}; + +export type TDecodedGcpIamAuthJwt = { + header: { + alg: string; + kid: string; + typ: string; + }; + payload: { + sub: string; + aud: string; + }; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts new file mode 100644 index 000000000..a49cee417 --- /dev/null +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateGcpAuthField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts new file mode 100644 index 000000000..df5919101 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityKubernetesAuthDALFactory = ReturnType; + +export const identityKubernetesAuthDALFactory = (db: TDbClient) => { + const kubernetesAuthOrm = ormify(db, TableName.IdentityKubernetesAuth); + return kubernetesAuthOrm; +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts new file mode 100644 index 000000000..194e69b3c --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-fns.ts @@ -0,0 +1,15 @@ +/** + * Extracts the K8s service account name and namespace + * from the username in this format: system:serviceaccount:default:infisical-auth + */ +export const extractK8sUsername = (username: string) => { + const parts = username.split(":"); + // Ensure that the username format is correct + if (parts.length === 4 && parts[0] === "system" && parts[1] === "serviceaccount") { + return { + namespace: parts[2], + name: parts[3] + }; + } + throw new Error("Invalid username format"); +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts new file mode 100644 index 000000000..8ee8c36bd --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -0,0 +1,515 @@ +import { ForbiddenError } from "@casl/ability"; +import axios from "axios"; +import https from "https"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod, SecretKeyEncoding, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { + decryptSymmetric, + encryptSymmetric, + generateAsymmetricKeyPair, + generateSymmetricKey, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; +import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; +import { + TAttachKubernetesAuthDTO, + TCreateTokenReviewResponse, + TGetKubernetesAuthDTO, + TLoginKubernetesAuthDTO, + TUpdateKubernetesAuthDTO +} from "./identity-kubernetes-auth-types"; + +type TIdentityKubernetesAuthServiceFactoryDep = { + identityKubernetesAuthDAL: Pick< + TIdentityKubernetesAuthDALFactory, + "create" | "findOne" | "transaction" | "updateById" + >; + identityAccessTokenDAL: Pick; + identityOrgMembershipDAL: Pick; + identityDAL: Pick; + orgBotDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityKubernetesAuthServiceFactory = ReturnType; + +export const identityKubernetesAuthServiceFactory = ({ + identityKubernetesAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + orgBotDAL, + permissionService, + licenseService +}: TIdentityKubernetesAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + if (!identityKubernetesAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityKubernetesAuth.identityId + }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedCaCert, caCertIV, caCertTag, encryptedTokenReviewerJwt, tokenReviewerJwtIV, tokenReviewerJwtTag } = + identityKubernetesAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + let tokenReviewerJwt = ""; + if (encryptedTokenReviewerJwt && tokenReviewerJwtIV && tokenReviewerJwtTag) { + tokenReviewerJwt = decryptSymmetric({ + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag, + key + }); + } + + const { data }: { data: TCreateTokenReviewResponse } = await axios.post( + `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: !!caCert + }) + } + ); + + if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error }); + + // check the response to determine if the token is valid + if (!(data.status && data.status.authenticated)) throw new UnauthorizedError(); + + const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); + + if (identityKubernetesAuth.allowedNamespaces) { + // validate if [targetNamespace] is in the list of allowed namespaces + + const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces + .split(",") + .map((namespace) => namespace.trim()) + .some((namespace) => namespace === targetNamespace); + + if (!isNamespaceAllowed) throw new UnauthorizedError(); + } + + if (identityKubernetesAuth.allowedNames) { + // validate if [targetName] is in the list of allowed names + + const isNameAllowed = identityKubernetesAuth.allowedNames + .split(",") + .map((name) => name.trim()) + .some((name) => name === targetName); + + if (!isNameAllowed) throw new UnauthorizedError(); + } + + if (identityKubernetesAuth.allowedAudience) { + // validate if [audience] is in the list of allowed audiences + const isAudienceAllowed = data.status.audiences.some( + (audience) => audience === identityKubernetesAuth.allowedAudience + ); + + if (!isAudienceAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityKubernetesAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityKubernetesAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityKubernetesAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachKubernetesAuth = async ({ + identityId, + kubernetesHost, + caCert, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add Kubernetes Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const orgBot = await orgBotDAL.transaction(async (tx) => { + const doc = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }, tx); + if (doc) return doc; + + const { privateKey, publicKey } = generateAsymmetricKeyPair(); + const key = generateSymmetricKey(); + const { + ciphertext: encryptedPrivateKey, + iv: privateKeyIV, + tag: privateKeyTag, + encoding: privateKeyKeyEncoding, + algorithm: privateKeyAlgorithm + } = infisicalSymmetricEncypt(privateKey); + const { + ciphertext: encryptedSymmetricKey, + iv: symmetricKeyIV, + tag: symmetricKeyTag, + encoding: symmetricKeyKeyEncoding, + algorithm: symmetricKeyAlgorithm + } = infisicalSymmetricEncypt(key); + + return orgBotDAL.create( + { + name: "Infisical org bot", + publicKey, + privateKeyIV, + encryptedPrivateKey, + symmetricKeyIV, + symmetricKeyTag, + encryptedSymmetricKey, + symmetricKeyAlgorithm, + orgId: identityMembershipOrg.orgId, + privateKeyTag, + privateKeyAlgorithm, + privateKeyKeyEncoding, + symmetricKeyKeyEncoding + }, + tx + ); + }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { ciphertext: encryptedCaCert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + const { + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag + } = encryptSymmetric(tokenReviewerJwt, key); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { + const doc = await identityKubernetesAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + kubernetesHost, + encryptedCaCert, + caCertIV, + caCertTag, + encryptedTokenReviewerJwt, + tokenReviewerJwtIV, + tokenReviewerJwtTag, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.KUBERNETES_AUTH + }, + tx + ); + return doc; + }); + + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + }; + + const updateKubernetesAuth = async ({ + identityId, + kubernetesHost, + caCert, + tokenReviewerJwt, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.KUBERNETES_AUTH) + throw new BadRequestError({ + message: "Failed to update Kubernetes Auth" + }); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityKubernetesAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityKubernetesAuth.accessTokenMaxTTL) > + (accessTokenMaxTTL || identityKubernetesAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updateQuery: TIdentityKubernetesAuthsUpdate = { + kubernetesHost, + allowedNamespaces, + allowedNames, + allowedAudience, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + if (caCert !== undefined) { + const { ciphertext: encryptedCACert, iv: caCertIV, tag: caCertTag } = encryptSymmetric(caCert, key); + updateQuery.encryptedCaCert = encryptedCACert; + updateQuery.caCertIV = caCertIV; + updateQuery.caCertTag = caCertTag; + } + + if (tokenReviewerJwt !== undefined) { + const { + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag + } = encryptSymmetric(tokenReviewerJwt, key); + updateQuery.encryptedTokenReviewerJwt = encryptedTokenReviewerJwt; + updateQuery.tokenReviewerJwtIV = tokenReviewerJwtIV; + updateQuery.tokenReviewerJwtTag = tokenReviewerJwtTag; + } + + const updatedKubernetesAuth = await identityKubernetesAuthDAL.updateById(identityKubernetesAuth.id, updateQuery); + + return { ...updatedKubernetesAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getKubernetesAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TGetKubernetesAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.KUBERNETES_AUTH) + throw new BadRequestError({ + message: "The identity does not have Kubernetes Auth attached" + }); + + const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const orgBot = await orgBotDAL.findOne({ orgId: identityMembershipOrg.orgId }); + if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); + + const key = infisicalSymmetricDecrypt({ + ciphertext: orgBot.encryptedSymmetricKey, + iv: orgBot.symmetricKeyIV, + tag: orgBot.symmetricKeyTag, + keyEncoding: orgBot.symmetricKeyKeyEncoding as SecretKeyEncoding + }); + + const { encryptedCaCert, caCertIV, caCertTag, encryptedTokenReviewerJwt, tokenReviewerJwtIV, tokenReviewerJwtTag } = + identityKubernetesAuth; + + let caCert = ""; + if (encryptedCaCert && caCertIV && caCertTag) { + caCert = decryptSymmetric({ + ciphertext: encryptedCaCert, + iv: caCertIV, + tag: caCertTag, + key + }); + } + + let tokenReviewerJwt = ""; + if (encryptedTokenReviewerJwt && tokenReviewerJwtIV && tokenReviewerJwtTag) { + tokenReviewerJwt = decryptSymmetric({ + ciphertext: encryptedTokenReviewerJwt, + iv: tokenReviewerJwtIV, + tag: tokenReviewerJwtTag, + key + }); + } + + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachKubernetesAuth, + updateKubernetesAuth, + getKubernetesAuth + }; +}; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts new file mode 100644 index 000000000..dbb42dce8 --- /dev/null +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -0,0 +1,61 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginKubernetesAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachKubernetesAuthDTO = { + identityId: string; + kubernetesHost: string; + caCert: string; + tokenReviewerJwt: string; + allowedNamespaces: string; + allowedNames: string; + allowedAudience: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateKubernetesAuthDTO = { + identityId: string; + kubernetesHost?: string; + caCert?: string; + tokenReviewerJwt?: string; + allowedNamespaces?: string; + allowedNames?: string; + allowedAudience?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetKubernetesAuthDTO = { + identityId: string; +} & Omit; + +type TCreateTokenReviewSuccessResponse = { + authenticated: true; + user: { + username: string; + uid: string; + groups: string[]; + }; + audiences: string[]; +}; + +type TCreateTokenReviewErrorResponse = { + error: string; +}; + +export type TCreateTokenReviewResponse = { + apiVersion: "authentication.k8s.io/v1"; + kind: "TokenReview"; + spec: { + token: string; + }; + status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; +}; diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index dbb864387..c1cfe79cc 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -3,64 +3,103 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; export type TIdentityProjectDALFactory = ReturnType; export const identityProjectDALFactory = (db: TDbClient) => { const identityProjectOrm = ormify(db, TableName.IdentityProjectMembership); - const findByProjectId = async (projectId: string, tx?: Knex) => { + const findByProjectId = async (projectId: string, filter: { identityId?: string } = {}, tx?: Knex) => { try { const docs = await (tx || db)(TableName.IdentityProjectMembership) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) + .where((qb) => { + if (filter.identityId) { + void qb.where("identityId", filter.identityId); + } + }) + .join( + TableName.IdentityProjectMembershipRole, + `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .leftJoin( TableName.ProjectRoles, - `${TableName.IdentityProjectMembership}.roleId`, + `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) - .select(selectAllTableCols(TableName.IdentityProjectMembership)) - // cr stands for custom role - .select(db.ref("id").as("crId").withSchema(TableName.ProjectRoles)) - .select(db.ref("name").as("crName").withSchema(TableName.ProjectRoles)) - .select(db.ref("slug").as("crSlug").withSchema(TableName.ProjectRoles)) - .select(db.ref("description").as("crDescription").withSchema(TableName.ProjectRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) - .select(db.ref("id").as("identityId").withSchema(TableName.Identity)) - .select(db.ref("name").as("identityName").withSchema(TableName.Identity)) - .select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); - return docs.map( - ({ - crId, - crDescription, - crSlug, - crPermission, - crName, - identityId, - identityName, - identityAuthMethod, - ...el - }) => ({ - ...el, + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectMembership}.id`, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId` + ) + .select( + db.ref("id").withSchema(TableName.IdentityProjectMembership), + db.ref("createdAt").withSchema(TableName.IdentityProjectMembership), + db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership), + db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity), + db.ref("id").as("identityId").withSchema(TableName.Identity), + db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("id").withSchema(TableName.IdentityProjectMembership), + db.ref("role").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole) + ); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt }) => ({ + id, identityId, + createdAt, + updatedAt, identity: { id: identityId, name: identityName, authMethod: identityAuthMethod - }, - customRole: el.roleId - ? { - id: crId, - name: crName, - slug: crSlug, - permissions: crPermission, - description: crDescription - } - : undefined - }) - ); + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; } catch (error) { throw new DatabaseError({ error, name: "FindByProjectId" }); } diff --git a/backend/src/services/identity-project/identity-project-membership-role-dal.ts b/backend/src/services/identity-project/identity-project-membership-role-dal.ts new file mode 100644 index 000000000..3f6c6b589 --- /dev/null +++ b/backend/src/services/identity-project/identity-project-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityProjectMembershipRoleDALFactory = ReturnType; + +export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityProjectMembershipRole); + return orm; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 05e0bd68b..fb5dc6fb0 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -1,25 +1,36 @@ import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; -import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas"; +import { ProjectMembershipRole } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TProjectDALFactory } from "../project/project-dal"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { TIdentityProjectDALFactory } from "./identity-project-dal"; +import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-membership-role-dal"; import { TCreateProjectIdentityDTO, TDeleteProjectIdentityDTO, + TGetProjectIdentityByIdentityIdDTO, TListProjectIdentityDTO, TUpdateProjectIdentityDTO } from "./identity-project-types"; type TIdentityProjectServiceFactoryDep = { identityProjectDAL: TIdentityProjectDALFactory; + identityProjectMembershipRoleDAL: Pick< + TIdentityProjectMembershipRoleDALFactory, + "create" | "transaction" | "insertMany" | "delete" + >; projectDAL: Pick; + projectRoleDAL: Pick; identityOrgMembershipDAL: Pick; permissionService: Pick; }; @@ -30,10 +41,26 @@ export const identityProjectServiceFactory = ({ identityProjectDAL, permissionService, identityOrgMembershipDAL, - projectDAL + identityProjectMembershipRoleDAL, + projectDAL, + projectRoleDAL }: TIdentityProjectServiceFactoryDep) => { - const createProjectIdentity = async ({ identityId, actor, actorId, projectId, role }: TCreateProjectIdentityDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const createProjectIdentity = async ({ + identityId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + roles + }: TCreateProjectIdentityDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Identity); const existingIdentity = await identityProjectDAL.findOne({ identityId, projectId }); @@ -52,28 +79,87 @@ export const identityProjectServiceFactory = ({ message: `Failed to find identity with id ${identityId}` }); - const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( - role, - project.id - ); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) - throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" - }); - const isCustomRole = Boolean(customRole); + for await (const { role: requestedRoleChange } of roles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + requestedRoleChange, + projectId + ); - const projectIdentity = await identityProjectDAL.create({ - identityId, - projectId: project.id, - role: isCustomRole ? ProjectMembershipRole.Custom : role, - roleId: customRole?.id + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + + if (!hasRequiredPriviledges) { + throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); + } + } + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + const projectIdentity = await identityProjectDAL.transaction(async (tx) => { + const identityProjectMembership = await identityProjectDAL.create( + { + identityId, + projectId: project.id + }, + tx + ); + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const identityRoles = await identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + return { ...identityProjectMembership, roles: identityRoles }; }); return projectIdentity; }; - const updateProjectIdentity = async ({ projectId, identityId, role, actor, actorId }: TUpdateProjectIdentityDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const updateProjectIdentity = async ({ + projectId, + identityId, + roles, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateProjectIdentityDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); const projectIdentity = await identityProjectDAL.findOne({ identityId, projectId }); @@ -82,40 +168,74 @@ export const identityProjectServiceFactory = ({ message: `Identity with id ${identityId} doesn't exists in project with id ${projectId}` }); - const { permission: identityRolePermission } = await permissionService.getProjectPermission( - ActorType.IDENTITY, - projectIdentity.identityId, - projectIdentity.projectId - ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - - let customRole: TProjectRoles | undefined; - if (role) { - const { permission: rolePermission, role: customOrgRole } = await permissionService.getProjectPermissionByRole( - role, - projectIdentity.projectId + for await (const { role: requestedRoleChange } of roles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + requestedRoleChange, + projectId ); - const isCustomRole = Boolean(customOrgRole); - const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasRequiredNewRolePermission) - throw new BadRequestError({ message: "Failed to create a more privileged identity" }); - if (isCustomRole) customRole = customOrgRole; + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + + if (!hasRequiredPriviledges) { + throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); + } } - const [updatedProjectIdentity] = await identityProjectDAL.update( - { projectId, identityId: projectIdentity.identityId }, - { - role: customRole ? ProjectMembershipRole.Custom : role, - roleId: customRole ? customRole.id : null - } + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); - return updatedProjectIdentity; + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: projectIdentity.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: projectIdentity.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => { + await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx); + return identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + }); + + return updatedRoles; }; - const deleteProjectIdentity = async ({ identityId, actorId, actor, projectId }: TDeleteProjectIdentityDTO) => { + const deleteProjectIdentity = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TDeleteProjectIdentityDTO) => { const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); @@ -123,13 +243,17 @@ export const identityProjectServiceFactory = ({ const { permission } = await permissionService.getProjectPermission( actor, actorId, - identityProjectMembership.projectId + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity); const { permission: identityRolePermission } = await permissionService.getProjectPermission( ActorType.IDENTITY, identityId, - identityProjectMembership.projectId + identityProjectMembership.projectId, + actorAuthMethod, + actorOrgId ); const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); if (!hasRequiredPriviledges) @@ -139,18 +263,53 @@ export const identityProjectServiceFactory = ({ return deletedIdentity; }; - const listProjectIdentities = async ({ projectId, actor, actorId }: TListProjectIdentityDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const listProjectIdentities = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListProjectIdentityDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); - const identityMemberhips = await identityProjectDAL.findByProjectId(projectId); - return identityMemberhips; + const identityMemberships = await identityProjectDAL.findByProjectId(projectId); + return identityMemberships; + }; + + const getProjectIdentityByIdentityId = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + identityId + }: TGetProjectIdentityByIdentityIdDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); + + const [identityMembership] = await identityProjectDAL.findByProjectId(projectId, { identityId }); + if (!identityMembership) throw new BadRequestError({ message: `Membership not found for identity ${identityId}` }); + return identityMembership; }; return { createProjectIdentity, updateProjectIdentity, deleteProjectIdentity, - listProjectIdentities + listProjectIdentities, + getProjectIdentityByIdentityId }; }; diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 71e048c19..43c671e50 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -1,12 +1,38 @@ import { TProjectPermission } from "@app/lib/types"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; + export type TCreateProjectIdentityDTO = { identityId: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectPermission; export type TUpdateProjectIdentityDTO = { - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; identityId: string; } & TProjectPermission; @@ -15,3 +41,7 @@ export type TDeleteProjectIdentityDTO = { } & TProjectPermission; export type TListProjectIdentityDTO = TProjectPermission; + +export type TGetProjectIdentityByIdentityIdDTO = { + identityId: string; +} & TProjectPermission; diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index aa5c3c895..5e940871b 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -52,7 +52,9 @@ export const identityUaServiceFactory = ({ }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); - if (!identityUa) throw new UnauthorizedError(); + if (!identityUa) throw new UnauthorizedError({ message: "Invalid credentials" }); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); checkIPAgainstBlocklist({ ipAddress: ip, @@ -66,12 +68,12 @@ export const identityUaServiceFactory = ({ const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) => bcrypt.compareSync(clientSecret, clientSecretHash) ); - if (!validClientSecretInfo) throw new UnauthorizedError(); + if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (clientSecretTTL > 0) { + if (Number(clientSecretTTL) > 0) { const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = clientSecretTTL * 1000; + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; const currentDate = new Date(); const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); @@ -124,10 +126,14 @@ export const identityUaServiceFactory = ({ } as TIdentityAccessTokenJwtPayload, appCfg.AUTH_SECRET, { - expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) } ); - return { accessToken, identityUa, validClientSecretInfo, identityAccessToken }; + + return { accessToken, identityUa, validClientSecretInfo, identityAccessToken, identityMembershipOrg }; }; const attachUa = async ({ @@ -138,7 +144,9 @@ export const identityUaServiceFactory = ({ accessTokenTrustedIps, clientSecretTrustedIps, actorId, - actor + actorAuthMethod, + actor, + actorOrgId }: TAttachUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); @@ -151,7 +159,13 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); @@ -221,7 +235,9 @@ export const identityUaServiceFactory = ({ accessTokenTrustedIps, clientSecretTrustedIps, actorId, - actor + actorAuthMethod, + actor, + actorOrgId }: TUpdateUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); @@ -239,7 +255,13 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const plan = await licenseService.getPlan(identityMembershipOrg.orgId); @@ -290,7 +312,7 @@ export const identityUaServiceFactory = ({ return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; }; - const getIdentityUa = async ({ identityId, actorId, actor }: TGetUaDTO) => { + const getIdentityUa = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) @@ -300,7 +322,13 @@ export const identityUaServiceFactory = ({ const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId }; }; @@ -308,8 +336,10 @@ export const identityUaServiceFactory = ({ const createUaClientSecret = async ({ actor, actorId, + actorOrgId, identityId, ttl, + actorAuthMethod, description, numUsesLimit }: TCreateUaClientSecretDTO) => { @@ -319,13 +349,21 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "The identity does not have universal auth" }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, - identityMembershipOrg.orgId + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId ); const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); if (!hasPriviledge) @@ -358,20 +396,34 @@ export const identityUaServiceFactory = ({ }; }; - const getUaClientSecrets = async ({ actor, actorId, identityId }: TGetUaClientSecretsDTO) => { + const getUaClientSecrets = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + identityId + }: TGetUaClientSecretsDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) throw new BadRequestError({ message: "The identity does not have universal auth" }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, - identityMembershipOrg.orgId + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId ); const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); if (!hasPriviledge) @@ -390,20 +442,35 @@ export const identityUaServiceFactory = ({ return { clientSecrets, orgId: identityMembershipOrg.orgId }; }; - const revokeUaClientSecret = async ({ identityId, actorId, actor, clientSecretId }: TRevokeUaClientSecretDTO) => { + const revokeUaClientSecret = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + clientSecretId + }: TRevokeUaClientSecretDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) throw new BadRequestError({ message: "The identity does not have universal auth" }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); const { permission: rolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, identityMembershipOrg.identityId, - identityMembershipOrg.orgId + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId ); const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); if (!hasPriviledge) diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 3dd494034..2863bf23e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -25,8 +25,16 @@ export const identityServiceFactory = ({ identityOrgMembershipDAL, permissionService }: TIdentityServiceFactoryDep) => { - const createIdentity = async ({ name, role, actor, orgId, actorId }: TCreateIdentityDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const createIdentity = async ({ + name, + role, + actor, + orgId, + actorId, + actorAuthMethod, + actorOrgId + }: TCreateIdentityDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( @@ -54,17 +62,33 @@ export const identityServiceFactory = ({ return identity; }; - const updateIdentity = async ({ id, role, name, actor, actorId }: TUpdateIdentityDTO) => { + const updateIdentity = async ({ + id, + role, + name, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateIdentityDTO) => { const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new BadRequestError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityOrgMembership.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityOrgMembership.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); const { permission: identityRolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, id, - identityOrgMembership.orgId + identityOrgMembership.orgId, + actorAuthMethod, + actorOrgId ); const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); if (!hasRequiredPriviledges) @@ -102,16 +126,24 @@ export const identityServiceFactory = ({ return { ...identity, orgId: identityOrgMembership.orgId }; }; - const deleteIdentity = async ({ actorId, actor, id }: TDeleteIdentityDTO) => { + const deleteIdentity = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteIdentityDTO) => { const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); if (!identityOrgMembership) throw new BadRequestError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission(actor, actorId, identityOrgMembership.orgId); + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityOrgMembership.orgId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); const { permission: identityRolePermission } = await permissionService.getOrgPermission( ActorType.IDENTITY, id, - identityOrgMembership.orgId + identityOrgMembership.orgId, + actorAuthMethod, + actorOrgId ); const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); if (!hasRequiredPriviledges) @@ -121,12 +153,12 @@ export const identityServiceFactory = ({ return { ...deletedIdentity, orgId: identityOrgMembership.orgId }; }; - const listOrgIdentities = async ({ orgId, actor, actorId }: TOrgPermission) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const listOrgIdentities = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPermission) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); - const identityMemberhips = await identityOrgMembershipDAL.findByOrgId(orgId); - return identityMemberhips; + const identityMemberships = await identityOrgMembershipDAL.findByOrgId(orgId); + return identityMemberships; }; return { diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 17b1b63ad..9cb0d822c 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -109,7 +109,7 @@ const getAppsGCPSecretManager = async ({ accessToken }: { accessToken: string }) */ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get<{ name: string }[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, { + await request.get<{ name: string; id: string }[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, { headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}` @@ -118,7 +118,8 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { ).data; const apps = res.map((a) => ({ - name: a.name + name: a.name, + appId: a.id })); return apps; @@ -128,26 +129,55 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { * Return list of names of apps for Vercel integration */ const getAppsVercel = async ({ accessToken, teamId }: { teamId?: string | null; accessToken: string }) => { - const res = ( - await request.get<{ projects: { name: string; id: string }[] }>(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + const apps: Array<{ name: string; appId: string }> = []; + + const limit = "20"; + let hasMorePages = true; + let next: number | null = null; + + interface Response { + projects: { name: string; id: string }[]; + pagination: { + count: number; + next: number | null; + prev: number; + }; + } + + while (hasMorePages) { + const params: { [key: string]: string } = { + limit + }; + + if (teamId) { + params.teamId = teamId; + } + + if (next) { + params.until = String(next); + } + + const { data } = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + params: new URLSearchParams(params), headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" - }, - ...(teamId - ? { - params: { - teamId - } - } - : {}) - }) - ).data; + } + }); - const apps = res.projects.map((a) => ({ - name: a.name, - appId: a.id - })); + data.projects.forEach((a) => { + apps.push({ + name: a.name, + appId: a.id + }); + }); + + next = data.pagination.next; + + if (data.pagination.next === null) { + hasMorePages = false; + } + } return apps; }; @@ -259,20 +289,44 @@ const getAppsGithub = async ({ accessToken }: { accessToken: string }) => { * Return list of services for Render integration */ const getAppsRender = async ({ accessToken }: { accessToken: string }) => { - const res = ( - await request.get<{ service: { name: string; id: string } }[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - "Accept-Encoding": "application/json" - } - }) - ).data; + const apps: Array<{ name: string; appId: string }> = []; + let hasMorePages = true; + const perPage = 100; + let cursor; - const apps = res.map((a) => ({ - name: a.service.name, - appId: a.service.id - })); + interface RenderService { + cursor: string; + service: { name: string; id: string }; + } + + while (hasMorePages) { + const res: RenderService[] = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { + params: new URLSearchParams({ + ...(cursor ? { cursor: String(cursor) } : {}), + limit: String(perPage) + }), + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + res.forEach((a) => { + apps.push({ + name: a.service.name, + appId: a.service.id + }); + }); + + if (res.length < perPage) { + hasMorePages = false; + } else { + cursor = res[res.length - 1].cursor; + } + } return apps; }; diff --git a/backend/src/services/integration-auth/integration-auth-dal.ts b/backend/src/services/integration-auth/integration-auth-dal.ts index f2d9c9ea2..d32cd1579 100644 --- a/backend/src/services/integration-auth/integration-auth-dal.ts +++ b/backend/src/services/integration-auth/integration-auth-dal.ts @@ -1,10 +1,35 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TIntegrationAuthDALFactory = ReturnType; export const integrationAuthDALFactory = (db: TDbClient) => { const integrationAuthOrm = ormify(db, TableName.IntegrationAuth); - return integrationAuthOrm; + + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TIntegrationAuthsUpdate }>, + tx?: Knex + ) => { + try { + const integrationAuths = await Promise.all( + data.map(async ({ filter, data: updateData }) => { + const [doc] = await (tx || db)(TableName.IntegrationAuth).where(filter).update(updateData).returning("*"); + if (!doc) throw new BadRequestError({ message: "Failed to update document" }); + return doc; + }) + ); + return integrationAuths; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + return { + ...integrationAuthOrm, + bulkUpdate + }; }; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 844e01f3c..74d881d26 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -1,4 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { Octokit } from "@octokit/rest"; +import AWS from "aws-sdk"; import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -20,9 +22,14 @@ import { TDeleteIntegrationAuthsDTO, TGetIntegrationAuthDTO, TGetIntegrationAuthTeamCityBuildConfigDTO, + THerokuPipelineCoupling, TIntegrationAuthAppsDTO, + TIntegrationAuthAwsKmsKeyDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthGithubEnvsDTO, + TIntegrationAuthGithubOrgsDTO, + TIntegrationAuthHerokuPipelinesDTO, TIntegrationAuthNorthflankSecretGroupDTO, TIntegrationAuthQoveryEnvironmentsDTO, TIntegrationAuthQoveryOrgsDTO, @@ -59,27 +66,60 @@ export const integrationAuthServiceFactory = ({ projectBotDAL, projectBotService }: TIntegrationAuthServiceFactoryDep) => { - const listIntegrationAuthByProjectId = async ({ actorId, actor, projectId }: TProjectPermission) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const listIntegrationAuthByProjectId = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const authorizations = await integrationAuthDAL.find({ projectId }); return authorizations; }; - const getIntegrationAuth = async ({ actor, id, actorId }: TGetIntegrationAuthDTO) => { + const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); return integrationAuth; }; - const oauthExchange = async ({ projectId, actorId, actor, integration, url, code }: TOauthExchangeDTO) => { + const oauthExchange = async ({ + projectId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + integration, + url, + code + }: TOauthExchangeDTO) => { if (!Object.values(Integrations).includes(integration as Integrations)) throw new BadRequestError({ message: "Invalid integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); const bot = await projectBotDAL.findOne({ isActive: true, projectId }); @@ -134,6 +174,8 @@ export const integrationAuthServiceFactory = ({ integration, url, actor, + actorOrgId, + actorAuthMethod, accessId, namespace, accessToken @@ -141,7 +183,13 @@ export const integrationAuthServiceFactory = ({ if (!Object.values(Integrations).includes(integration as Integrations)) throw new BadRequestError({ message: "Invalid integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); const bot = await projectBotDAL.findOne({ isActive: true, projectId }); @@ -254,11 +302,25 @@ export const integrationAuthServiceFactory = ({ return { accessId, accessToken }; }; - const getIntegrationApps = async ({ actor, actorId, teamId, id, workspaceSlug }: TIntegrationAuthAppsDTO) => { + const getIntegrationApps = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + teamId, + id, + workspaceSlug + }: TIntegrationAuthAppsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); @@ -274,11 +336,23 @@ export const integrationAuthServiceFactory = ({ return apps; }; - const getIntegrationAuthTeams = async ({ actor, actorId, id }: TIntegrationAuthTeamsDTO) => { + const getIntegrationAuthTeams = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + id + }: TIntegrationAuthTeamsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); @@ -291,11 +365,24 @@ export const integrationAuthServiceFactory = ({ return teams; }; - const getVercelBranches = async ({ appId, id, actor, actorId }: TIntegrationAuthVercelBranchesDTO) => { + const getVercelBranches = async ({ + appId, + id, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIntegrationAuthVercelBranchesDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -319,11 +406,24 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getChecklyGroups = async ({ actorId, actor, id, accountId }: TIntegrationAuthChecklyGroupsDTO) => { + const getChecklyGroups = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + accountId + }: TIntegrationAuthChecklyGroupsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -340,11 +440,86 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getQoveryOrgs = async ({ actorId, actor, id }: TIntegrationAuthQoveryOrgsDTO) => { + const getGithubOrgs = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TIntegrationAuthGithubOrgsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const octokit = new Octokit({ + auth: accessToken + }); + + const { data } = await octokit.request("GET /user/orgs", { + headers: { + "X-GitHub-Api-Version": "2022-11-28" + } + }); + if (!data) return []; + + return data.map(({ login: name, id: orgId }) => ({ name, orgId: String(orgId) })); + }; + + const getGithubEnvs = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + repoOwner, + repoName + }: TIntegrationAuthGithubEnvsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const octokit = new Octokit({ + auth: accessToken + }); + + const { + data: { environments } + } = await octokit.request("GET /repos/{owner}/{repo}/environments", { + headers: { + "X-GitHub-Api-Version": "2022-11-28" + }, + owner: repoOwner, + repo: repoName + }); + if (!environments) return []; + return environments.map(({ id: envId, name }) => ({ name, envId: String(envId) })); + }; + + const getQoveryOrgs = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TIntegrationAuthQoveryOrgsDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -361,11 +536,82 @@ export const integrationAuthServiceFactory = ({ return data.results.map(({ name, id: orgId }) => ({ name, orgId })); }; - const getQoveryProjects = async ({ actorId, actor, id, orgId }: TIntegrationAuthQoveryProjectDTO) => { + const getAwsKmsKeys = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + region + }: TIntegrationAuthAwsKmsKeyDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessId, accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + AWS.config.update({ + region, + credentials: { + accessKeyId: String(accessId), + secretAccessKey: accessToken + } + }); + const kms = new AWS.KMS(); + const aliases = await kms.listAliases({}).promise(); + + const keyAliases = aliases.Aliases!.filter((alias) => { + if (!alias.TargetKeyId) return false; + + if (integrationAuth.integration === Integrations.AWS_PARAMETER_STORE && alias.AliasName === "alias/aws/ssm") + return true; + + if ( + integrationAuth.integration === Integrations.AWS_SECRET_MANAGER && + alias.AliasName === "alias/aws/secretsmanager" + ) + return true; + + if (alias.AliasName?.includes("alias/aws/")) return false; + return alias.TargetKeyId; + }); + + const keysWithAliases = keyAliases.map((alias) => { + return { + id: alias.TargetKeyId!, + alias: alias.AliasName! + }; + }); + + return keysWithAliases; + }; + + const getQoveryProjects = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + orgId + }: TIntegrationAuthQoveryProjectDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -384,11 +630,24 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getQoveryEnvs = async ({ projectId, id, actor, actorId }: TIntegrationAuthQoveryEnvironmentsDTO) => { + const getQoveryEnvs = async ({ + projectId, + id, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIntegrationAuthQoveryEnvironmentsDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -412,11 +671,24 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getQoveryApps = async ({ id, actor, actorId, environmentId }: TIntegrationAuthQoveryScopesDTO) => { + const getQoveryApps = async ({ + id, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environmentId + }: TIntegrationAuthQoveryScopesDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -439,11 +711,24 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getQoveryContainers = async ({ id, actor, actorId, environmentId }: TIntegrationAuthQoveryScopesDTO) => { + const getQoveryContainers = async ({ + id, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environmentId + }: TIntegrationAuthQoveryScopesDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -466,11 +751,24 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getQoveryJobs = async ({ id, actor, actorId, environmentId }: TIntegrationAuthQoveryScopesDTO) => { + const getQoveryJobs = async ({ + id, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environmentId + }: TIntegrationAuthQoveryScopesDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -493,11 +791,63 @@ export const integrationAuthServiceFactory = ({ return []; }; - const getRailwayEnvironments = async ({ id, actor, actorId, appId }: TIntegrationAuthRailwayEnvDTO) => { + const getHerokuPipelines = async ({ + id, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIntegrationAuthHerokuPipelinesDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const { data } = await request.get( + `${IntegrationUrls.HEROKU_API_URL}/pipeline-couplings`, + { + headers: { + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return data.map(({ app: { id: appId }, stage, pipeline: { id: pipelineId, name } }) => ({ + app: { appId }, + stage, + pipeline: { pipelineId, name } + })); + }; + + const getRailwayEnvironments = async ({ + id, + actor, + actorId, + actorOrgId, + actorAuthMethod, + appId + }: TIntegrationAuthRailwayEnvDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -547,41 +897,43 @@ export const integrationAuthServiceFactory = ({ } return []; }; - const getRailwayServices = async ({ id, actor, actorId, appId }: TIntegrationAuthRailwayServicesDTO) => { + + const getRailwayServices = async ({ + id, + actor, + actorId, + actorOrgId, + actorAuthMethod, + appId + }: TIntegrationAuthRailwayServicesDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); - if (appId) { + + if (appId && appId !== "") { const query = ` - query project($id: String!) { - project(id: $id) { - createdAt - deletedAt - id - description - expiredAt - isPublic - isTempProject - isUpdatable - name - prDeploys - teamId - updatedAt - upstreamUrl - services { - edges { - node { - id - name - } - } - } + query project($id: String!) { + project(id: $id) { + services { + edges { + node { + id + name + } + } + } + } } - } `; const variables = { @@ -617,14 +969,27 @@ export const integrationAuthServiceFactory = ({ ); return edges.map(({ node: { name, id: serviceId } }) => ({ name, serviceId })); } + return []; }; - const getBitbucketWorkspaces = async ({ actorId, actor, id }: TIntegrationAuthBitbucketWorkspaceDTO) => { + const getBitbucketWorkspaces = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TIntegrationAuthBitbucketWorkspaceDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -634,9 +999,7 @@ export const integrationAuthServiceFactory = ({ while (hasNextPage) { // eslint-disable-next-line - const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get( - workspaceUrl, - { + const { data }: { data: { values: TBitbucketWorkspace[]; next: string } } = await request.get(workspaceUrl, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json" @@ -658,11 +1021,24 @@ export const integrationAuthServiceFactory = ({ return workspaces; }; - const getNorthFlankSecretGroups = async ({ id, actor, actorId, appId }: TIntegrationAuthNorthflankSecretGroupDTO) => { + const getNorthFlankSecretGroups = async ({ + id, + actor, + actorId, + actorOrgId, + actorAuthMethod, + appId + }: TIntegrationAuthNorthflankSecretGroupDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -713,11 +1089,24 @@ export const integrationAuthServiceFactory = ({ return secretGroups; }; - const getTeamcityBuildConfigs = async ({ appId, id, actorId, actor }: TGetIntegrationAuthTeamCityBuildConfigDTO) => { + const getTeamcityBuildConfigs = async ({ + appId, + id, + actorId, + actorOrgId, + actorAuthMethod, + actor + }: TGetIntegrationAuthTeamCityBuildConfigDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); @@ -742,19 +1131,44 @@ export const integrationAuthServiceFactory = ({ return []; }; - const deleteIntegrationAuths = async ({ projectId, integration, actor, actorId }: TDeleteIntegrationAuthsDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteIntegrationAuths = async ({ + projectId, + integration, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDeleteIntegrationAuthsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); const integrations = await integrationAuthDAL.delete({ integration, projectId }); return integrations; }; - const deleteIntegrationAuthById = async ({ id, actorId, actor }: TDeleteIntegrationAuthByIdDTO) => { + const deleteIntegrationAuthById = async ({ + id, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TDeleteIntegrationAuthByIdDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); const delIntegrationAuth = await integrationAuthDAL.transaction(async (tx) => { @@ -779,10 +1193,14 @@ export const integrationAuthServiceFactory = ({ getIntegrationApps, getVercelBranches, getApps, + getAwsKmsKeys, + getGithubOrgs, + getGithubEnvs, getChecklyGroups, getQoveryApps, getQoveryEnvs, getQoveryJobs, + getHerokuPipelines, getQoveryOrgs, getQoveryProjects, getQoveryContainers, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 34c5d995a..0a816035c 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -44,6 +44,16 @@ export type TIntegrationAuthChecklyGroupsDTO = { accountId: string; } & Omit; +export type TIntegrationAuthGithubOrgsDTO = { + id: string; +} & Omit; + +export type TIntegrationAuthGithubEnvsDTO = { + id: string; + repoName: string; + repoOwner: string; +} & Omit; + export type TIntegrationAuthQoveryOrgsDTO = { id: string; } & Omit; @@ -53,6 +63,11 @@ export type TIntegrationAuthQoveryProjectDTO = { orgId: string; } & Omit; +export type TIntegrationAuthAwsKmsKeyDTO = { + id: string; + region: string; +} & Omit; + export type TIntegrationAuthQoveryEnvironmentsDTO = { id: string; } & TProjectPermission; @@ -62,6 +77,10 @@ export type TIntegrationAuthQoveryScopesDTO = { environmentId: string; } & Omit; +export type TIntegrationAuthHerokuPipelinesDTO = { + id: string; +} & Omit; + export type TIntegrationAuthRailwayEnvDTO = { id: string; appId: string; @@ -129,6 +148,12 @@ export type TNorthflankSecretGroup = { projectId: string; }; +export type THerokuPipelineCoupling = { + app: { id: string }; + stage: string; + pipeline: { id: string; name: string }; +}; + export type TTeamCityBuildConfig = { id: string; name: string; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index d3cabbcb5..2aaf5d5f4 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -37,6 +37,17 @@ export enum IntegrationType { OAUTH2 = "oauth2" } +export enum IntegrationInitialSyncBehavior { + OVERWRITE_TARGET = "overwrite-target", + PREFER_TARGET = "prefer-target", + PREFER_SOURCE = "prefer-source" +} + +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} + export enum IntegrationUrls { // integration oauth endpoints GCP_TOKEN_URL = "https://oauth2.googleapis.com/token", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index abb6a7b1e..40d51c81a 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ @@ -8,9 +9,12 @@ import { CreateSecretCommand, + DescribeSecretCommand, GetSecretValueCommand, ResourceNotFoundException, SecretsManagerClient, + TagResourceCommand, + UntagResourceCommand, UpdateSecretCommand } from "@aws-sdk/client-secrets-manager"; import { Octokit } from "@octokit/rest"; @@ -20,11 +24,18 @@ import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; import { z } from "zod"; -import { TIntegrationAuths, TIntegrations } from "@app/db/schemas"; +import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; +import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; -import { Integrations, IntegrationUrls } from "./integration-list"; +import { TIntegrationDALFactory } from "../integration/integration-dal"; +import { + IntegrationInitialSyncBehavior, + IntegrationMappingBehavior, + Integrations, + IntegrationUrls +} from "./integration-list"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -441,49 +452,74 @@ const syncSecretsAWSParameterStore = async ({ }) => { if (!accessId) return; - AWS.config.update({ + const config = new AWS.Config({ region: integration.region as string, - accessKeyId: accessId, - secretAccessKey: accessToken + credentials: { + accessKeyId: accessId, + secretAccessKey: accessToken + } }); const ssm = new AWS.SSM({ apiVersion: "2014-11-06", region: integration.region as string }); + ssm.config.update(config); - const params = { - Path: integration.path as string, - Recursive: true, - WithDecryption: true - }; + const metadata = z.record(z.any()).parse(integration.metadata || {}); + const awsParameterStoreSecretsObj: Record = {}; - const parameterList = (await ssm.getParametersByPath(params).promise()).Parameters; + // now fetch all aws parameter store secrets + let hasNext = true; + let nextToken: string | undefined; + while (hasNext) { + const parameters = await ssm + .getParametersByPath({ + Path: integration.path as string, + Recursive: false, + WithDecryption: true, + MaxResults: 10, + NextToken: nextToken + }) + .promise(); - const awsParameterStoreSecretsObj = (parameterList || []) - .filter(({ Name }) => Boolean(Name)) - .reduce( - (obj, secret) => ({ - ...obj, - [(secret.Name as string).substring((integration.path as string).length)]: secret - }), - {} as Record - ); + if (parameters.Parameters) { + parameters.Parameters.forEach((parameter) => { + if (parameter.Name) { + const secKey = parameter.Name.substring((integration.path as string).length); + awsParameterStoreSecretsObj[secKey] = parameter; + } + }); + } + hasNext = Boolean(parameters.NextToken); + nextToken = parameters.NextToken; + } // Identify secrets to create - await Promise.all( - Object.keys(secrets).map(async (key) => { + // don't use Promise.all() and promise map here + // it will cause rate limit + for (const key in secrets) { + if (Object.hasOwn(secrets, key)) { if (!(key in awsParameterStoreSecretsObj)) { // case: secret does not exist in AWS parameter store // -> create secret - await ssm - .putParameter({ - Name: `${integration.path}${key}`, - Type: "SecureString", - Value: secrets[key].value, - Overwrite: true - }) - .promise(); + if (secrets[key].value) { + await ssm + .putParameter({ + Name: `${integration.path}${key}`, + Type: "SecureString", + Value: secrets[key].value, + ...(metadata.kmsKeyId && { KeyId: metadata.kmsKeyId }), + // Overwrite: true, + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ + Key: tag.key, + Value: tag.value + })) + : [] + }) + .promise(); + } // case: secret exists in AWS parameter store } else if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { // case: secret value doesn't match one in AWS parameter store @@ -494,32 +530,35 @@ const syncSecretsAWSParameterStore = async ({ Type: "SecureString", Value: secrets[key].value, Overwrite: true + // Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] }) .promise(); } - }) - ); - // Identify secrets to delete - await Promise.all( - Object.keys(awsParameterStoreSecretsObj).map(async (key) => { - if (!(key in secrets)) { - // case: - // -> delete secret - await ssm - .deleteParameter({ - Name: awsParameterStoreSecretsObj[key].Name as string - }) - .promise(); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + } + + if (!metadata.shouldDisableDelete) { + for (const key in awsParameterStoreSecretsObj) { + if (Object.hasOwn(awsParameterStoreSecretsObj, key)) { + if (!(key in secrets)) { + // case: + // -> delete secret + await ssm + .deleteParameter({ + Name: awsParameterStoreSecretsObj[key].Name as string + }) + .promise(); + } + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); } - }) - ); - - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); + } + } }; /** @@ -536,65 +575,149 @@ const syncSecretsAWSSecretManager = async ({ accessId: string | null; accessToken: string; }) => { - let secretsManager; - const secKeyVal = getSecretKeyValuePair(secrets); - try { - if (!accessId) return; + const metadata = z.record(z.any()).parse(integration.metadata || {}); - AWS.config.update({ - region: integration.region as string, + if (!accessId) return; + + const secretsManager = new SecretsManagerClient({ + region: integration.region as string, + credentials: { accessKeyId: accessId, secretAccessKey: accessToken - }); + } + }); - secretsManager = new SecretsManagerClient({ - region: integration.region as string, - credentials: { - accessKeyId: accessId, - secretAccessKey: accessToken + const processAwsSecret = async ( + secretId: string, + secretValue: Record | string + ) => { + try { + const awsSecretManagerSecret = await secretsManager.send( + new GetSecretValueCommand({ + SecretId: secretId + }) + ); + + let secretToCompare; + if (awsSecretManagerSecret?.SecretString) { + if (typeof secretValue === "string") { + secretToCompare = awsSecretManagerSecret.SecretString; + } else { + secretToCompare = JSON.parse(awsSecretManagerSecret.SecretString); + } } - }); - const awsSecretManagerSecret = await secretsManager.send( - new GetSecretValueCommand({ - SecretId: integration.app as string - }) - ); + if (!isEqual(secretToCompare, secretValue)) { + await secretsManager.send( + new UpdateSecretCommand({ + SecretId: secretId, + SecretString: typeof secretValue === "string" ? secretValue : JSON.stringify(secretValue) + }) + ); + } - let awsSecretManagerSecretObj: { [key: string]: AWS.SecretsManager } = {}; + const secretAWSTag = metadata.secretAWSTag as { key: string; value: string }[] | undefined; - if (awsSecretManagerSecret?.SecretString) { - awsSecretManagerSecretObj = JSON.parse(awsSecretManagerSecret.SecretString); + if (secretAWSTag && secretAWSTag.length) { + const describedSecret = await secretsManager.send( + // requires secretsmanager:DescribeSecret policy + new DescribeSecretCommand({ + SecretId: secretId + }) + ); + + if (!describedSecret.Tags) return; + + const integrationTagObj = secretAWSTag.reduce( + (acc, item) => { + acc[item.key] = item.value; + return acc; + }, + {} as Record + ); + + const awsTagObj = (describedSecret.Tags || []).reduce( + (acc, item) => { + if (item.Key && item.Value) { + acc[item.Key] = item.Value; + } + return acc; + }, + {} as Record + ); + + const tagsToUpdate: { Key: string; Value: string }[] = []; + const tagsToDelete: { Key: string; Value: string }[] = []; + + describedSecret.Tags?.forEach((tag) => { + if (tag.Key && tag.Value) { + if (!(tag.Key in integrationTagObj)) { + // delete tag from AWS secret manager + tagsToDelete.push({ + Key: tag.Key, + Value: tag.Value + }); + } else if (tag.Value !== integrationTagObj[tag.Key]) { + // update tag in AWS secret manager + tagsToUpdate.push({ + Key: tag.Key, + Value: integrationTagObj[tag.Key] + }); + } + } + }); + + secretAWSTag?.forEach((tag) => { + if (!(tag.key in awsTagObj)) { + // create tag in AWS secret manager + tagsToUpdate.push({ + Key: tag.key, + Value: tag.value + }); + } + }); + + if (tagsToUpdate.length) { + await secretsManager.send( + new TagResourceCommand({ + SecretId: secretId, + Tags: tagsToUpdate + }) + ); + } + + if (tagsToDelete.length) { + await secretsManager.send( + new UntagResourceCommand({ + SecretId: secretId, + TagKeys: tagsToDelete.map((tag) => tag.Key) + }) + ); + } + } + } catch (err) { + // case when AWS manager can't find the specified secret + if (err instanceof ResourceNotFoundException && secretsManager) { + await secretsManager.send( + new CreateSecretCommand({ + Name: secretId, + SecretString: typeof secretValue === "string" ? secretValue : JSON.stringify(secretValue), + ...(metadata.kmsKeyId && { KmsKeyId: metadata.kmsKeyId }), + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) + : [] + }) + ); + } } + }; - if (!isEqual(awsSecretManagerSecretObj, secKeyVal)) { - await secretsManager.send( - new UpdateSecretCommand({ - SecretId: integration.app as string, - SecretString: JSON.stringify(secKeyVal) - }) - ); + if (metadata.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE) { + for await (const [key, value] of Object.entries(secrets)) { + await processAwsSecret(key, value.value); } - - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); - } catch (err) { - if (err instanceof ResourceNotFoundException && secretsManager) { - await secretsManager.send( - new CreateSecretCommand({ - Name: integration.app as string, - SecretString: JSON.stringify(secKeyVal) - }) - ); - } - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); + } else { + await processAwsSecret(integration.app as string, getSecretKeyValuePair(secrets)); } }; @@ -602,11 +725,25 @@ const syncSecretsAWSSecretManager = async ({ * Sync/push [secrets] to Heroku app named [integration.app] */ const syncSecretsHeroku = async ({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, secrets, accessToken }: { - integration: TIntegrations; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integrationDAL: Pick; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; secrets: Record; accessToken: string; }) => { @@ -620,12 +757,74 @@ const syncSecretsHeroku = async ({ }) ).data; + const secretsToAdd: { [key: string]: string } = {}; + const secretsToUpdate: { [key: string]: string } = {}; + + const metadata = z.record(z.any()).parse(integration.metadata); + Object.keys(herokuSecrets).forEach((key) => { - if (!(key in secrets)) { - secrets[key] = null; - } + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + case IntegrationInitialSyncBehavior.OVERWRITE_TARGET: { + if (!(key in secrets)) secrets[key] = null; + break; + } + case IntegrationInitialSyncBehavior.PREFER_TARGET: { + if (!(key in secrets)) { + secretsToAdd[key] = herokuSecrets[key]; + } else if (secrets[key]?.value !== herokuSecrets[key]) { + secretsToUpdate[key] = herokuSecrets[key]; + } + secrets[key] = { + value: herokuSecrets[key] + }; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + if (!(key in secrets)) { + secrets[key] = herokuSecrets[key]; + secretsToAdd[key] = herokuSecrets[key]; + } + break; + } + default: { + if (!(key in secrets)) secrets[key] = null; + break; + } + } + } else if (!(key in secrets)) secrets[key] = null; }); + if (Object.keys(secretsToAdd).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + if (Object.keys(secretsToUpdate).length) { + await updateManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToUpdate).map((key) => ({ + secretName: key, + secretValue: secretsToUpdate[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + await request.patch( `${IntegrationUrls.HEROKU_API_URL}/apps/${integration.app}/config-vars`, getSecretKeyValuePair(secrets), @@ -637,6 +836,10 @@ const syncSecretsHeroku = async ({ } } ); + + await integrationDAL.updateById(integration.id, { + lastUsed: new Date() + }); }; /** @@ -1048,98 +1251,176 @@ const syncSecretsGitHub = async ({ interface GitHubRepoKey { key_id: string; key: string; + id?: number | undefined; + url?: string | undefined; + title?: string | undefined; + created_at?: string | undefined; } interface GitHubSecret { name: string; created_at: string; updated_at: string; - } - - interface GitHubSecretRes { - [index: string]: GitHubSecret; + visibility?: "all" | "private" | "selected"; + selected_repositories_url?: string | undefined; } const octokit = new Octokit({ auth: accessToken }); - // const user = (await octokit.request('GET /user', {})).data; - const repoPublicKey: GitHubRepoKey = ( - await octokit.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", { - owner: integration.owner as string, - repo: integration.app as string - }) - ).data; + enum GithubScope { + Repo = "github-repo", + Org = "github-org", + Env = "github-env" + } + + let repoPublicKey: GitHubRepoKey; + + switch (integration.scope) { + case GithubScope.Org: { + const { data } = await octokit.request("GET /orgs/{org}/actions/secrets/public-key", { + org: integration.owner as string + }); + repoPublicKey = data; + break; + } + case GithubScope.Env: { + const { data } = await octokit.request( + "GET /repositories/{repository_id}/environments/{environment_name}/secrets/public-key", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string + } + ); + repoPublicKey = data; + break; + } + default: { + const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", { + owner: integration.owner as string, + repo: integration.app as string + }); + repoPublicKey = data; + break; + } + } // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key - let encryptedSecrets: GitHubSecretRes = ( - await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { - owner: integration.owner as string, - repo: integration.app as string - }) - ).data.secrets.reduce( - (obj, secret) => ({ - ...obj, - [secret.name]: secret - }), - {} - ); + let encryptedSecrets: GitHubSecret[]; - 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; - }, - {} - ); - - await Promise.all( - Object.keys(encryptedSecrets).map(async (key) => { - if (!(key in secrets)) { - return octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + switch (integration.scope) { + case GithubScope.Org: { + encryptedSecrets = ( + await octokit.request("GET /orgs/{org}/actions/secrets", { + org: integration.owner as string + }) + ).data.secrets; + break; + } + case GithubScope.Env: { + encryptedSecrets = ( + await octokit.request("GET /repositories/{repository_id}/environments/{environment_name}/secrets", { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string + }) + ).data.secrets; + break; + } + default: { + encryptedSecrets = ( + await octokit.request("GET /repos/{owner}/{repo}/actions/secrets", { owner: integration.owner as string, - repo: integration.app as string, - secret_name: key - }); + repo: integration.app as string + }) + ).data.secrets; + break; + } + } + + for await (const encryptedSecret of encryptedSecrets) { + if ( + !(encryptedSecret.name in secrets) && + !(appendices?.prefix !== undefined && !encryptedSecret.name.startsWith(appendices?.prefix)) && + !(appendices?.suffix !== undefined && !encryptedSecret.name.endsWith(appendices?.suffix)) + ) { + switch (integration.scope) { + case GithubScope.Org: { + await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, + secret_name: encryptedSecret.name + }); + break; + } + case GithubScope.Env: { + await octokit.request( + "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: encryptedSecret.name + } + ); + break; + } + default: { + await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: encryptedSecret.name + }); + break; + } } - }) - ); + } + } - await Promise.all( - Object.keys(secrets).map((key) => { - // let encryptedSecret; - return sodium.ready.then(async () => { - // convert secret & base64 key to Uint8Array. - const binkey = sodium.from_base64(repoPublicKey.key, sodium.base64_variants.ORIGINAL); - const binsec = sodium.from_string(secrets[key].value); + await sodium.ready.then(async () => { + for await (const key of Object.keys(secrets)) { + // convert secret & base64 key to Uint8Array. + const binkey = sodium.from_base64(repoPublicKey.key, sodium.base64_variants.ORIGINAL); + const binsec = sodium.from_string(secrets[key].value); - // encrypt secret using libsodium - const encBytes = sodium.crypto_box_seal(binsec, binkey); + // encrypt secret using libsodium + const encBytes = sodium.crypto_box_seal(binsec, binkey); - // convert encrypted Uint8Array to base64 - const encryptedSecret = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL); + // convert encrypted Uint8Array to base64 + const encryptedSecret = sodium.to_base64(encBytes, sodium.base64_variants.ORIGINAL); - await octokit.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: integration.owner as string, - repo: integration.app as string, - secret_name: key, - encrypted_value: encryptedSecret, - key_id: repoPublicKey.key_id - }); - }); - }) - ); + switch (integration.scope) { + case GithubScope.Org: + await octokit.request("PUT /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, + secret_name: key, + visibility: "all", + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + }); + break; + case GithubScope.Env: + await octokit.request( + "PUT /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: key, + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + } + ); + break; + default: + await octokit.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: key, + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + }); + break; + } + } + }); }; /** @@ -1167,6 +1448,22 @@ const syncSecretsRender = async ({ } } ); + + if (integration.metadata) { + const metadata = z.record(z.any()).parse(integration.metadata); + if (metadata.shouldAutoRedeploy === true) { + await request.post( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${integration.appId}/deploys`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } + } }; /** @@ -1224,21 +1521,21 @@ const syncSecretsRailway = async ({ } `; - const input = { - projectId: integration.appId, - environmentId: integration.targetEnvironmentId, - ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), - replace: true, - variables: getSecretKeyValuePair(secrets) + const variables = { + input: { + projectId: integration.appId, + environmentId: integration.targetEnvironmentId, + ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), + replace: true, + variables: getSecretKeyValuePair(secrets) + } }; await request.post( IntegrationUrls.RAILWAY_API_URL, { query, - variables: { - input - } + variables }, { headers: { @@ -1989,16 +2286,29 @@ const syncSecretsQovery = async ({ * @param {String} obj.accessToken - access token for Terraform Cloud API */ const syncSecretsTerraformCloud = async ({ + createManySecretsRawFn, + updateManySecretsRawFn, integration, secrets, - accessToken + accessToken, + integrationDAL }: { - integration: TIntegrations; - secrets: Record; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + }; + secrets: Record; accessToken: string; + integrationDAL: Pick; }) => { // get secrets from Terraform Cloud - const getSecretsRes = ( + const terraformSecrets = ( await request.get<{ data: { attributes: { key: string; value: string }; id: string }[] }>( `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`, { @@ -2016,9 +2326,74 @@ const syncSecretsTerraformCloud = async ({ {} as Record ); + const secretsToAdd: { [key: string]: string } = {}; + const secretsToUpdate: { [key: string]: string } = {}; + + const metadata = z.record(z.any()).parse(integration.metadata); + + Object.keys(terraformSecrets).forEach((key) => { + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + case IntegrationInitialSyncBehavior.PREFER_TARGET: { + if (!(key in secrets)) { + secretsToAdd[key] = terraformSecrets[key].attributes.value; + } else if (secrets[key]?.value !== terraformSecrets[key].attributes.value) { + secretsToUpdate[key] = terraformSecrets[key].attributes.value; + } + secrets[key] = { + value: terraformSecrets[key].attributes.value + }; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + if (!(key in secrets)) { + secrets[key] = { + value: terraformSecrets[key].attributes.value + }; + secretsToAdd[key] = terraformSecrets[key].attributes.value; + } + break; + } + default: { + break; + } + } + } else if (!(key in secrets)) secrets[key] = null; + }); + + if (Object.keys(secretsToAdd).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + if (Object.keys(secretsToUpdate).length) { + await updateManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToUpdate).map((key) => ({ + secretName: key, + secretValue: secretsToUpdate[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + // create or update secrets on Terraform Cloud for await (const key of Object.keys(secrets)) { - if (!(key in getSecretsRes)) { + if (!(key in terraformSecrets)) { // case: secret does not exist in Terraform Cloud // -> add secret await request.post( @@ -2028,7 +2403,7 @@ const syncSecretsTerraformCloud = async ({ type: "vars", attributes: { key, - value: secrets[key].value, + value: secrets[key]?.value, category: integration.targetService } } @@ -2042,17 +2417,17 @@ const syncSecretsTerraformCloud = async ({ } ); // case: secret exists in Terraform Cloud - } else if (secrets[key].value !== getSecretsRes[key].attributes.value) { + } else if (secrets[key]?.value !== terraformSecrets[key].attributes.value) { // -> update secret await request.patch( - `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${terraformSecrets[key].id}`, { data: { type: "vars", - id: getSecretsRes[key].id, + id: terraformSecrets[key].id, attributes: { - ...getSecretsRes[key], - value: secrets[key].value + ...terraformSecrets[key], + value: secrets[key]?.value } } }, @@ -2067,11 +2442,11 @@ const syncSecretsTerraformCloud = async ({ } } - for await (const key of Object.keys(getSecretsRes)) { + for await (const key of Object.keys(terraformSecrets)) { if (!(key in secrets)) { // case: delete secret await request.delete( - `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${terraformSecrets[key].id}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -2082,6 +2457,10 @@ const syncSecretsTerraformCloud = async ({ ); } } + + await integrationDAL.updateById(integration.id, { + lastUsed: new Date() + }); }; /** @@ -2606,7 +2985,7 @@ const syncSecretsDigitalOceanAppPlatform = async ({ spec: { name: integration.app, ...appSettings, - envs: Object.entries(secrets).map(([key, data]) => ({ key, value: data.value })) + envs: Object.entries(secrets).map(([key, data]) => ({ key, value: data.value, type: "SECRET" })) } }, { @@ -2950,8 +3329,14 @@ const syncSecretsHasuraCloud = async ({ /** * Sync/push [secrets] to [app] in integration named [integration] + * + * Do this in terms of DAL + * */ export const syncIntegrationSecrets = async ({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, integrationAuth, secrets, @@ -2959,7 +3344,18 @@ export const syncIntegrationSecrets = async ({ accessToken, appendices }: { - integration: TIntegrations; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integrationDAL: Pick; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; integrationAuth: TIntegrationAuths; secrets: Record; accessId: string | null; @@ -2999,6 +3395,9 @@ export const syncIntegrationSecrets = async ({ break; case Integrations.HEROKU: await syncSecretsHeroku({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, secrets, accessToken @@ -3103,9 +3502,12 @@ export const syncIntegrationSecrets = async ({ break; case Integrations.TERRAFORM_CLOUD: await syncSecretsTerraformCloud({ + createManySecretsRawFn, + updateManySecretsRawFn, integration, secrets, - accessToken + accessToken, + integrationDAL }); break; case Integrations.HASHICORP_VAULT: diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index b7f74966e..eff73c1b6 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -9,7 +9,12 @@ import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TIntegrationDALFactory } from "./integration-dal"; -import { TCreateIntegrationDTO, TDeleteIntegrationDTO, TUpdateIntegrationDTO } from "./integration-types"; +import { + TCreateIntegrationDTO, + TDeleteIntegrationDTO, + TSyncIntegrationDTO, + TUpdateIntegrationDTO +} from "./integration-types"; type TIntegrationServiceFactoryDep = { integrationDAL: TIntegrationDALFactory; @@ -31,6 +36,7 @@ export const integrationServiceFactory = ({ const createIntegration = async ({ app, actor, + actorOrgId, path, appId, owner, @@ -41,6 +47,7 @@ export const integrationServiceFactory = ({ metadata, secretPath, targetService, + actorAuthMethod, targetServiceId, integrationAuthId, sourceEnvironment, @@ -50,7 +57,13 @@ export const integrationServiceFactory = ({ const integrationAuth = await integrationAuthDAL.findById(integrationAuthId); if (!integrationAuth) throw new BadRequestError({ message: "Integration auth not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integrationAuth.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath); @@ -86,6 +99,8 @@ export const integrationServiceFactory = ({ const updateIntegration = async ({ actorId, actor, + actorOrgId, + actorAuthMethod, targetEnvironment, app, id, @@ -93,12 +108,19 @@ export const integrationServiceFactory = ({ owner, isActive, environment, - secretPath + secretPath, + metadata }: TUpdateIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new BadRequestError({ message: "Integration auth not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integration.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); const folder = await folderDAL.findBySecretPath(integration.projectId, environment, secretPath); @@ -111,35 +133,108 @@ export const integrationServiceFactory = ({ appId, targetEnvironment, owner, - secretPath + secretPath, + metadata: { + ...(integration.metadata as object), + ...metadata + } + }); + + await secretQueueService.syncIntegrations({ + environment: folder.environment.slug, + secretPath, + projectId: folder.projectId }); return updatedIntegration; }; - const deleteIntegration = async ({ actorId, id, actor }: TDeleteIntegrationDTO) => { + const deleteIntegration = async ({ actorId, id, actor, actorAuthMethod, actorOrgId }: TDeleteIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new BadRequestError({ message: "Integration auth not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, integration.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Integrations); - const deletedIntegration = await integrationDAL.deleteById(id); + const deletedIntegration = await integrationDAL.transaction(async (tx) => { + // delete integration + const deletedIntegrationResult = await integrationDAL.deleteById(id, tx); + + // check if there are other integrations that share the same integration auth + const integrations = await integrationDAL.find( + { + integrationAuthId: integration.integrationAuthId + }, + tx + ); + + if (integrations.length === 0) { + // no other integration shares the same integration auth + // -> delete the integration auth + await integrationAuthDAL.deleteById(integration.integrationAuthId, tx); + } + + return deletedIntegrationResult; + }); + return { ...integration, ...deletedIntegration }; }; - const listIntegrationByProject = async ({ actor, actorId, projectId }: TProjectPermission) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const listIntegrationByProject = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const integrations = await integrationDAL.findByProjectId(projectId); return integrations; }; + const syncIntegration = async ({ id, actorId, actor, actorOrgId, actorAuthMethod }: TSyncIntegrationDTO) => { + const integration = await integrationDAL.findById(id); + if (!integration) { + throw new BadRequestError({ message: "Integration not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integration.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + + await secretQueueService.syncIntegrations({ + environment: integration.environment.slug, + secretPath: integration.secretPath, + projectId: integration.projectId + }); + + return { ...integration, envId: integration.environment.id }; + }; + return { createIntegration, updateIntegration, deleteIntegration, - listIntegrationByProject + listIntegrationByProject, + syncIntegration }; }; diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 8f54c4fdb..1c8772478 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -22,20 +22,44 @@ export type TCreateIntegrationDTO = { labelName: string; labelValue: string; }; + secretAWSTag?: { + key: string; + value: string; + }[]; + kmsKeyId?: string; + shouldDisableDelete?: boolean; }; } & Omit; export type TUpdateIntegrationDTO = { id: string; - app: string; - appId: string; + app?: string; + appId?: string; isActive?: boolean; secretPath: string; targetEnvironment: string; owner: string; environment: string; + metadata?: { + secretPrefix?: string; + secretSuffix?: string; + secretGCPLabel?: { + labelName: string; + labelValue: string; + }; + secretAWSTag?: { + key: string; + value: string; + }[]; + kmsKeyId?: string; + shouldDisableDelete?: boolean; + }; } & Omit; export type TDeleteIntegrationDTO = { id: string; } & Omit; + +export type TSyncIntegrationDTO = { + id: string; +} & Omit; diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts new file mode 100644 index 000000000..9990d9c3d --- /dev/null +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgMembershipDALFactory = ReturnType; + +export const orgMembershipDALFactory = (db: TDbClient) => { + const orgMembershipOrm = ormify(db, TableName.OrgMembership); + + return { + ...orgMembershipOrm + }; +}; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 6629030d2..1e52053b2 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -11,11 +11,13 @@ import { TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { buildFindFilter, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; export type TOrgDALFactory = ReturnType; export const orgDALFactory = (db: TDbClient) => { + const orgOrm = ormify(db, TableName.Organization); + const findOrgById = async (orgId: string) => { try { const org = await db(TableName.Organization).where({ id: orgId }).first(); @@ -55,7 +57,7 @@ export const orgDALFactory = (db: TDbClient) => { const findAllOrgMembers = async (orgId: string) => { try { const members = await db(TableName.OrgMembership) - .where({ orgId }) + .where(`${TableName.OrgMembership}.orgId`, orgId) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin( TableName.UserEncryptionKey, @@ -70,11 +72,68 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("roleId").withSchema(TableName.OrgMembership), db.ref("status").withSchema(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), db.ref("publicKey").withSchema(TableName.UserEncryptionKey) - ); + ) + .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER + + return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, username, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + + const countAllOrgMembers = async (orgId: string) => { + try { + interface CountResult { + count: string; + } + + const count = await db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .count("*") + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .where({ isGhost: false }) + .first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all org members" }); + } + }; + + const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => { + try { + const members = await db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: false }) + .whereIn("username", usernames); return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, user: { email, firstName, lastName, id: userId, publicKey } @@ -84,6 +143,45 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findOrgGhostUser = async (orgId: string) => { + try { + const member = await db(TableName.OrgMembership) + .where({ orgId }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: true }) + .first(); + return member; + } catch (error) { + return null; + } + }; + + const ghostUserExists = async (orgId: string) => { + try { + const member = await db(TableName.OrgMembership) + .where({ orgId }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`) + .select(db.ref("id").withSchema(TableName.Users).as("userId")) + .where({ isGhost: true }) + .first(); + return Boolean(member); + } catch (error) { + return false; + } + }; + const create = async (dto: TOrganizationsInsert, tx?: Knex) => { try { const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*"); @@ -163,7 +261,23 @@ export const orgDALFactory = (db: TDbClient) => { // eslint-disable-next-line .where(buildFindFilter(filter)) .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) - .select(selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users)); + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.UserAliases, function joinUserAlias() { + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); + }) + .select( + selectAllTableCols(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("externalId").withSchema(TableName.UserAliases) + ) + .where({ isGhost: false }); + if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -177,10 +291,15 @@ export const orgDALFactory = (db: TDbClient) => { }; return withTransaction(db, { + ...orgOrm, findOrgByProjectId, findAllOrgMembers, + countAllOrgMembers, findOrgById, findAllOrgsByUserId, + ghostUserExists, + findOrgMembersByUsername, + findOrgGhostUser, create, updateById, deleteById, diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts new file mode 100644 index 000000000..a63ffabee --- /dev/null +++ b/backend/src/services/org/org-fns.ts @@ -0,0 +1,78 @@ +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; + +type TDeleteOrgMembership = { + orgMembershipId: string; + orgId: string; + orgDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + userAliasDAL: Pick; + licenseService: Pick; +}; + +export const deleteOrgMembershipFn = async ({ + orgMembershipId, + orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService +}: TDeleteOrgMembership) => { + const deletedMembership = await orgDAL.transaction(async (tx) => { + const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); + + if (!orgMembership.userId) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + } + + await userAliasDAL.delete( + { + userId: orgMembership.userId, + orgId + }, + tx + ); + + // Get all the project memberships of the user in the organization + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); + + // Delete all the project memberships of the user in the organization + await projectMembershipDAL.delete( + { + $in: { + id: projectMemberships.map((membership) => membership.id) + } + }, + tx + ); + + // Get all the project keys of the user in the organization + const projectKeys = await projectKeyDAL.find({ + $in: { + projectId: projectMemberships.map((membership) => membership.projectId) + }, + receiverId: orgMembership.userId + }); + + // Delete all the project keys of the user in the organization + await projectKeyDAL.delete( + { + $in: { + id: projectKeys.map((key) => key.id) + } + }, + tx + ); + + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + }); + + return deletedMembership; +}; diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts index c48002d89..70c54ff18 100644 --- a/backend/src/services/org/org-role-service.ts +++ b/backend/src/services/org/org-role-service.ts @@ -12,6 +12,7 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError } from "@app/lib/errors"; +import { ActorAuthMethod } from "../auth/auth-type"; import { TOrgRoleDALFactory } from "./org-role-dal"; type TOrgRoleServiceFactoryDep = { @@ -22,8 +23,14 @@ type TOrgRoleServiceFactoryDep = { export type TOrgRoleServiceFactory = ReturnType; export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRoleServiceFactoryDep) => { - const createRole = async (userId: string, orgId: string, data: Omit) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const createRole = async ( + userId: string, + orgId: string, + data: Omit, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Role); const existingRole = await orgRoleDAL.findOne({ slug: data.slug, orgId }); if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); @@ -35,8 +42,15 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol return role; }; - const updateRole = async (userId: string, orgId: string, roleId: string, data: Omit) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const updateRole = async ( + userId: string, + orgId: string, + roleId: string, + data: Omit, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); if (data?.slug) { const existingRole = await orgRoleDAL.findOne({ slug: data.slug, orgId }); @@ -47,21 +61,32 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol { id: roleId, orgId }, { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } ); - if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return updatedRole; }; - const deleteRole = async (userId: string, orgId: string, roleId: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const deleteRole = async ( + userId: string, + orgId: string, + roleId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); - if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); return deletedRole; }; - const listRoles = async (userId: string, orgId: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const listRoles = async ( + userId: string, + orgId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); const customRoles = await orgRoleDAL.find({ orgId }); const roles = [ @@ -104,8 +129,18 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol return roles; }; - const getUserPermission = async (userId: string, orgId: string) => { - const { permission, membership } = await permissionService.getUserOrgPermission(userId, orgId); + const getUserPermission = async ( + userId: string, + orgId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission, membership } = await permissionService.getUserOrgPermission( + userId, + orgId, + actorAuthMethod, + actorOrgId + ); return { permissions: packRules(permission.rules), membership }; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index a393edcb5..60ddc5230 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -1,9 +1,12 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; +import crypto from "crypto"; import jwt from "jsonwebtoken"; +import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -11,36 +14,48 @@ import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config- import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; -import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; +import { deleteOrgMembershipFn } from "./org-fns"; import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, TFindAllWorkspacesDTO, + TFindOrgMembersByEmailDTO, + TGetOrgGroupsDTO, TInviteUserToOrgDTO, + TUpdateOrgDTO, TUpdateOrgMembershipDTO, TVerifyUserToOrgDTO } from "./org-types"; type TOrgServiceFactoryDep = { + userAliasDAL: Pick; orgDAL: TOrgDALFactory; orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; userDAL: TUserDALFactory; + groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; - samlConfigDAL: Pick; + samlConfigDAL: Pick; smtpService: TSmtpService; tokenService: TAuthTokenServiceFactory; permissionService: TPermissionServiceFactory; @@ -53,13 +68,17 @@ type TOrgServiceFactoryDep = { export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ + userAliasDAL, orgDAL, userDAL, + groupDAL, orgRoleDAL, incidentContactDAL, permissionService, smtpService, projectDAL, + projectMembershipDAL, + projectKeyDAL, tokenService, orgBotDAL, licenseService, @@ -68,8 +87,13 @@ export const orgServiceFactory = ({ /* * Get organization details by the organization id * */ - const findOrganizationById = async (userId: string, orgId: string) => { - await permissionService.getUserOrgPermission(userId, orgId); + const findOrganizationById = async ( + userId: string, + orgId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); const org = await orgDAL.findOrgById(orgId); if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" }); return org; @@ -84,16 +108,44 @@ export const orgServiceFactory = ({ /* * Get all workspace members * */ - const findAllOrgMembers = async (userId: string, orgId: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const findAllOrgMembers = async ( + userId: string, + orgId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const members = await orgDAL.findAllOrgMembers(orgId); return members; }; - const findAllWorkspaces = async ({ actor, actorId, orgId }: TFindAllWorkspacesDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const getOrgGroups = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TGetOrgGroupsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + const groups = await groupDAL.findByOrgId(orgId); + return groups; + }; + + const findOrgMembersByUsername = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + orgId, + emails + }: TFindOrgMembersByEmailDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const members = await orgDAL.findOrgMembersByUsername(orgId, emails); + + return members; + }; + + const findAllWorkspaces = async ({ actor, actorId, actorOrgId, actorAuthMethod, orgId }: TFindAllWorkspacesDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id)); @@ -117,20 +169,119 @@ export const orgServiceFactory = ({ return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id)); }; + const addGhostUser = async (orgId: string, tx?: Knex) => { + const email = `sudo-${alphaNumericNanoId(16)}-${orgId}@infisical.com`; // We add a nanoid because the email is unique. And we have to create a new ghost user each time, so we can have access to the private key. + const password = crypto.randomBytes(128).toString("hex"); + + const user = await userDAL.create( + { + isGhost: true, + authMethods: [AuthMethod.EMAIL], + username: email, + email, + isAccepted: true + }, + tx + ); + + const encKeys = await generateUserSrpKeys(email, password); + + await userDAL.upsertUserEncryptionKey( + user.id, + { + encryptionVersion: 2, + protectedKey: encKeys.protectedKey, + protectedKeyIV: encKeys.protectedKeyIV, + protectedKeyTag: encKeys.protectedKeyTag, + publicKey: encKeys.publicKey, + encryptedPrivateKey: encKeys.encryptedPrivateKey, + iv: encKeys.encryptedPrivateKeyIV, + tag: encKeys.encryptedPrivateKeyTag, + salt: encKeys.salt, + verifier: encKeys.verifier + }, + tx + ); + + const createMembershipData = { + orgId, + userId: user.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted + }; + + await orgDAL.createMembership(createMembershipData, tx); + + return { + user, + keys: encKeys + }; + }; + /* - * Update organization settings + * Update organization details * */ - const updateOrgName = async (userId: string, orgId: string, name: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const updateOrg = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + orgId, + data: { name, slug, authEnforced, scimEnabled } + }: TUpdateOrgDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); - const org = await orgDAL.updateById(orgId, { name }); + + const plan = await licenseService.getPlan(orgId); + + if (authEnforced !== undefined) { + if (!plan?.samlSSO) + throw new BadRequestError({ + message: + "Failed to enforce/un-enforce SAML SSO due to plan restriction. Upgrade plan to enforce/un-enforce SAML SSO." + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + } + + if (scimEnabled !== undefined) { + if (!plan?.scim) + throw new BadRequestError({ + message: + "Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning." + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); + } + + if (authEnforced) { + const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId); + if (!samlCfg) + throw new BadRequestError({ + name: "No enforceable SAML config found", + message: "No enforceable SAML config found" + }); + } + + const org = await orgDAL.updateById(orgId, { + name, + slug: slug ? slugify(slug) : undefined, + authEnforced, + scimEnabled + }); if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" }); return org; }; /* * Create organization * */ - const createOrganization = async (userId: string, userEmail: string, orgName: string) => { + const createOrganization = async ({ + userId, + userEmail, + orgName + }: { + userId: string; + orgName: string; + userEmail?: string | null; + }) => { const { privateKey, publicKey } = generateAsymmetricKeyPair(); const key = generateSymmetricKey(); const { @@ -191,8 +342,13 @@ export const orgServiceFactory = ({ /* * Delete organization by id * */ - const deleteOrganizationById = async (userId: string, orgId: string) => { - const { membership } = await permissionService.getUserOrgPermission(userId, orgId); + const deleteOrganizationById = async ( + userId: string, + orgId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { membership } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) throw new UnauthorizedError({ name: "Delete org by id", message: "Not an admin" }); @@ -206,8 +362,15 @@ export const orgServiceFactory = ({ * Org membership management * Not another service because it has close ties with how an org works doesn't make sense to seperate them * */ - const updateOrgMembership = async ({ role, orgId, userId, membershipId }: TUpdateOrgMembershipDTO) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const updateOrgMembership = async ({ + role, + orgId, + userId, + membershipId, + actorAuthMethod, + actorOrgId + }: TUpdateOrgMembershipDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); @@ -237,16 +400,24 @@ export const orgServiceFactory = ({ /* * Invite user to organization */ - const inviteUserToOrganization = async ({ orgId, userId, inviteeEmail }: TInviteUserToOrgDTO) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const inviteUserToOrganization = async ({ + orgId, + userId, + inviteeEmail, + actorAuthMethod, + actorOrgId + }: TInviteUserToOrgDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); - const samlCfg = await samlConfigDAL.findOne({ orgId }); - if (samlCfg && samlCfg.isActive) { + const org = await orgDAL.findOrgById(orgId); + + if (org?.authEnforced) { throw new BadRequestError({ - message: "Failed to invite member due to SAML SSO configured for organization" + message: "Failed to invite user due to org-level auth enforced for organization" }); } + const plan = await licenseService.getPlan(orgId); if (plan.memberLimit !== null && plan.membersUsed >= plan.memberLimit) { // case: limit imposed on number of members allowed @@ -256,11 +427,17 @@ export const orgServiceFactory = ({ }); } const invitee = await orgDAL.transaction(async (tx) => { - const inviteeUser = await userDAL.findUserByEmail(inviteeEmail, tx); + const inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx); if (inviteeUser) { // if user already exist means its already part of infisical // Thus the signup flow is not needed anymore - const [inviteeMembership] = await orgDAL.findMembership({ orgId, userId: inviteeUser.id }, { tx }); + const [inviteeMembership] = await orgDAL.findMembership( + { + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUser.id + }, + { tx } + ); if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { throw new BadRequestError({ message: "Failed to invite an existing member of org", @@ -292,9 +469,11 @@ export const orgServiceFactory = ({ // not invited before const user = await userDAL.create( { + username: inviteeEmail, email: inviteeEmail, isAccepted: false, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isGhost: false }, tx ); @@ -317,7 +496,6 @@ export const orgServiceFactory = ({ orgId }); - const org = await orgDAL.findOrgById(orgId); const user = await userDAL.findById(userId); const appCfg = getConfig(); await smtpService.sendMail({ @@ -326,7 +504,7 @@ export const orgServiceFactory = ({ recipients: [inviteeEmail], substitutions: { inviterFirstName: user.firstName, - inviterEmail: user.email, + inviterUsername: user.username, organizationName: org?.name, email: inviteeEmail, organizationId: org?.id.toString(), @@ -346,14 +524,14 @@ export const orgServiceFactory = ({ * magic link and issue a temporary signup token for user to complete setting up their account */ const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { - const user = await userDAL.findUserByEmail(email); + const user = await userDAL.findUserByUsername(email); if (!user) { throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" }); } const [orgMembership] = await orgDAL.findMembership({ - userId: user.id, + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, status: OrgMembershipStatus.Invited, - orgId + [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId }); if (!orgMembership) throw new BadRequestError({ @@ -368,6 +546,10 @@ export const orgServiceFactory = ({ code }); + await userDAL.updateById(user.id, { + isEmailVerified: true + }); + if (user.isAccepted) { // this means user has already completed signup process // isAccepted is set true when keys are exchanged @@ -394,28 +576,52 @@ export const orgServiceFactory = ({ return { token, user }; }; - const deleteOrgMembership = async ({ orgId, userId, membershipId }: TDeleteOrgMembershipDTO) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const deleteOrgMembership = async ({ + orgId, + userId, + membershipId, + actorAuthMethod, + actorOrgId + }: TDeleteOrgMembershipDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const membership = await orgDAL.deleteMembershipById(membershipId, orgId); + const deletedMembership = await deleteOrgMembershipFn({ + orgMembershipId: membershipId, + orgId, + orgDAL, + projectMembershipDAL, + projectKeyDAL, + userAliasDAL, + licenseService + }); - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return membership; + return deletedMembership; }; /* * CRUD operations of incident contacts * */ - const findIncidentContacts = async (userId: string, orgId: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const findIncidentContacts = async ( + userId: string, + orgId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); const incidentContacts = await incidentContactDAL.findByOrgId(orgId); return incidentContacts; }; - const createIncidentContact = async (userId: string, orgId: string, email: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const createIncidentContact = async ( + userId: string, + orgId: string, + email: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email }); if (doesIncidentContactExist) { @@ -429,8 +635,14 @@ export const orgServiceFactory = ({ return incidentContact; }; - const deleteIncidentContact = async (userId: string, orgId: string, id: string) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + const deleteIncidentContact = async ( + userId: string, + orgId: string, + id: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); const incidentContact = await incidentContactDAL.deleteById(id, orgId); @@ -443,15 +655,18 @@ export const orgServiceFactory = ({ findAllOrganizationOfUser, inviteUserToOrganization, verifyUserToOrg, - updateOrgName, + updateOrg, + findOrgMembersByUsername, createOrganization, deleteOrganizationById, deleteOrgMembership, findAllWorkspaces, + addGhostUser, updateOrgMembership, // incident contacts findIncidentContacts, createIncidentContact, - deleteIncidentContact + deleteIncidentContact, + getOrgGroups }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index b9dbd74ad..0efc7ffe1 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -1,21 +1,29 @@ -import { ActorType } from "../auth/auth-type"; +import { TOrgPermission } from "@app/lib/types"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; export type TUpdateOrgMembershipDTO = { userId: string; orgId: string; membershipId: string; role: string; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; }; export type TDeleteOrgMembershipDTO = { userId: string; orgId: string; membershipId: string; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; }; export type TInviteUserToOrgDTO = { userId: string; orgId: string; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; inviteeEmail: string; }; @@ -25,8 +33,25 @@ export type TVerifyUserToOrgDTO = { code: string; }; +export type TFindOrgMembersByEmailDTO = { + actor: ActorType; + actorOrgId: string | undefined; + actorId: string; + actorAuthMethod: ActorAuthMethod; + orgId: string; + emails: string[]; +}; + export type TFindAllWorkspacesDTO = { actor: ActorType; actorId: string; + actorOrgId: string | undefined; + actorAuthMethod: ActorAuthMethod; orgId: string; }; + +export type TUpdateOrgDTO = { + data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>; +} & TOrgPermission; + +export type TGetOrgGroupsDTO = TOrgPermission; diff --git a/backend/src/services/project-bot/project-bot-dal.ts b/backend/src/services/project-bot/project-bot-dal.ts index 7f342f0ae..74abf8f21 100644 --- a/backend/src/services/project-bot/project-bot-dal.ts +++ b/backend/src/services/project-bot/project-bot-dal.ts @@ -27,5 +27,19 @@ export const projectBotDALFactory = (db: TDbClient) => { } }; - return { ...projectBotOrm, findOne }; + const findProjectByBotId = async (botId: string) => { + try { + const project = await db(TableName.ProjectBot) + .where({ [`${TableName.ProjectBot}.id` as "id"]: botId }) + .join(TableName.Project, `${TableName.ProjectBot}.projectId`, `${TableName.Project}.id`) + .select(selectAllTableCols(TableName.Project)) + .first(); + + return project || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find project by bot id" }); + } + }; + + return { ...projectBotOrm, findOne, findProjectByBotId }; }; diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts new file mode 100644 index 000000000..00604b37f --- /dev/null +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -0,0 +1,43 @@ +import { SecretKeyEncoding } from "@app/db/schemas"; +import { decryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; + +import { TProjectDALFactory } from "../project/project-dal"; +import { TGetPrivateKeyDTO } from "./project-bot-types"; + +export const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => + infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + +export const getBotKeyFnFactory = ( + projectBotDAL: TProjectBotDALFactory, + projectDAL: Pick +) => { + const getBotKeyFn = async (projectId: string) => { + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found during bot lookup." }); + + const bot = await projectBotDAL.findOne({ projectId: project.id }); + + if (!bot) throw new BadRequestError({ message: "Failed to find bot key" }); + if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); + if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) + throw new BadRequestError({ message: "Encryption key missing" }); + + const botPrivateKey = getBotPrivateKey({ bot }); + + return decryptAsymmetric({ + ciphertext: bot.encryptedProjectKey, + privateKey: botPrivateKey, + nonce: bot.encryptedProjectKeyNonce, + publicKey: bot.sender.publicKey + }); + }; + + return getBotKeyFn; +}; diff --git a/backend/src/services/project-bot/project-bot-service.ts b/backend/src/services/project-bot/project-bot-service.ts index 5478aadfa..ce7782a80 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -1,132 +1,131 @@ import { ForbiddenError } from "@casl/ability"; -import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectVersion } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { getConfig } from "@app/lib/config/env"; -import { - decryptAsymmetric, - decryptSymmetric, - decryptSymmetric128BitHexKeyUTF8, - encryptSymmetric, - encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair -} from "@app/lib/crypto"; +import { generateAsymmetricKeyPair } from "@app/lib/crypto"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { TProjectPermission } from "@app/lib/types"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "./project-bot-dal"; -import { TSetActiveStateDTO } from "./project-bot-types"; +import { getBotKeyFnFactory, getBotPrivateKey } from "./project-bot-fns"; +import { TFindBotByProjectIdDTO, TSetActiveStateDTO } from "./project-bot-types"; type TProjectBotServiceFactoryDep = { permissionService: Pick; + projectDAL: Pick; projectBotDAL: TProjectBotDALFactory; }; export type TProjectBotServiceFactory = ReturnType; -export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => { +export const projectBotServiceFactory = ({ + projectBotDAL, + projectDAL, + permissionService +}: TProjectBotServiceFactoryDep) => { + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); + const getBotKey = async (projectId: string) => { - const appCfg = getConfig(); - const encryptionKey = appCfg.ENCRYPTION_KEY; - const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; - - const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); - if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); - if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) - throw new BadRequestError({ message: "Encryption key missing" }); - - if (rootEncryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.BASE64) { - const privateKeyBot = decryptSymmetric({ - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey, - key: rootEncryptionKey - }); - return decryptAsymmetric({ - ciphertext: bot.encryptedProjectKey, - privateKey: privateKeyBot, - nonce: bot.encryptedProjectKeyNonce, - publicKey: bot.sender.publicKey - }); - } - if (encryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.UTF8) { - const privateKeyBot = decryptSymmetric128BitHexKeyUTF8({ - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey, - key: encryptionKey - }); - return decryptAsymmetric({ - ciphertext: bot.encryptedProjectKey, - privateKey: privateKeyBot, - nonce: bot.encryptedProjectKeyNonce, - publicKey: bot.sender.publicKey - }); - } - - throw new BadRequestError({ - message: "Failed to obtain bot copy of workspace key needed for operation" - }); + return getBotKeyFn(projectId); }; - const findBotByProjectId = async ({ actorId, actor, projectId }: TProjectPermission) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const findBotByProjectId = async ({ + actorId, + actor, + projectId, + actorOrgId, + privateKey, + actorAuthMethod, + botKey, + publicKey + }: TFindBotByProjectIdDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); - const appCfg = getConfig(); const bot = await projectBotDAL.transaction(async (tx) => { const doc = await projectBotDAL.findOne({ projectId }, tx); if (doc) return doc; - const { publicKey, privateKey } = generateAsymmetricKeyPair(); - if (appCfg.ROOT_ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY); - return projectBotDAL.create( - { - name: "Infisical Bot", - projectId, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: false, - publicKey, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.BASE64 - }, - tx - ); + const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair(); + + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(keys.privateKey); + + const project = await projectDAL.findById(projectId, tx); + + if (project.version === ProjectVersion.V2) { + throw new BadRequestError({ message: "Failed to create bot, project is upgraded." }); } - if (appCfg.ENCRYPTION_KEY) { - const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(privateKey, appCfg.ENCRYPTION_KEY); - return projectBotDAL.create( - { - name: "Infisical Bot", - projectId, - tag, - iv, - encryptedPrivateKey: ciphertext, - isActive: false, - publicKey, - algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 - }, - tx - ); - } - throw new BadRequestError({ message: "Failed to create bot due to missing encryption key" }); + + return projectBotDAL.create( + { + name: "Infisical Bot", + projectId, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: false, + publicKey: keys.publicKey, + algorithm, + keyEncoding: encoding, + ...(botKey && { + encryptedProjectKey: botKey.encryptedKey, + encryptedProjectKeyNonce: botKey.nonce + }) + }, + tx + ); }); return bot; }; - const setBotActiveState = async ({ actor, botId, botKey, actorId, isActive }: TSetActiveStateDTO) => { + const findProjectByBotId = async (botId: string) => { + try { + const bot = await projectBotDAL.findProjectByBotId(botId); + return bot; + } catch (e) { + throw new BadRequestError({ message: "Failed to find bot by ID" }); + } + }; + + const setBotActiveState = async ({ + actor, + botId, + botKey, + actorId, + actorOrgId, + actorAuthMethod, + isActive + }: TSetActiveStateDTO) => { const bot = await projectBotDAL.findById(botId); if (!bot) throw new BadRequestError({ message: "Bot not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, bot.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + bot.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + const project = await projectBotDAL.findProjectByBotId(botId); + + if (!project) { + throw new BadRequestError({ message: "Failed to find project by bot ID" }); + } + + if (project.version === ProjectVersion.V2) { + throw new BadRequestError({ message: "Failed to set bot active for upgraded project. Bot is already active" }); + } + if (isActive) { if (!botKey?.nonce || !botKey?.encryptedKey) { throw new BadRequestError({ message: "Failed to set bot active - missing bot key" }); @@ -153,6 +152,8 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T return { findBotByProjectId, setBotActiveState, + getBotPrivateKey, + findProjectByBotId, getBotKey }; }; diff --git a/backend/src/services/project-bot/project-bot-types.ts b/backend/src/services/project-bot/project-bot-types.ts index 94a2943eb..50fec2200 100644 --- a/backend/src/services/project-bot/project-bot-types.ts +++ b/backend/src/services/project-bot/project-bot-types.ts @@ -1,3 +1,4 @@ +import { TProjectBots } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TSetActiveStateDTO = { @@ -8,3 +9,16 @@ export type TSetActiveStateDTO = { }; botId: string; } & Omit; + +export type TFindBotByProjectIdDTO = { + privateKey?: string; + publicKey?: string; + botKey?: { + nonce: string; + encryptedKey: string; + }; +} & TProjectPermission; + +export type TGetPrivateKeyDTO = { + bot: TProjectBots; +}; diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index 49657204e..2acda33c0 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -27,8 +27,22 @@ export const projectEnvServiceFactory = ({ projectDAL, folderDAL }: TProjectEnvServiceFactoryDep) => { - const createEnvironment = async ({ projectId, actorId, actor, name, slug }: TCreateEnvDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const createEnvironment = async ({ + projectId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + name, + slug + }: TCreateEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); const envs = await projectEnvDAL.find({ projectId }); @@ -59,8 +73,24 @@ export const projectEnvServiceFactory = ({ return env; }; - const updateEnvironment = async ({ projectId, slug, actor, actorId, name, id, position }: TUpdateEnvDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const updateEnvironment = async ({ + projectId, + slug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + name, + id, + position + }: TUpdateEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); const oldEnv = await projectEnvDAL.findOne({ id, projectId }); @@ -85,8 +115,14 @@ export const projectEnvServiceFactory = ({ return { environment: env, old: oldEnv }; }; - const deleteEnvironment = async ({ projectId, actor, actorId, id }: TDeleteEnvDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteEnvironment = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod, id }: TDeleteEnvDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); const env = await projectEnvDAL.transaction(async (tx) => { diff --git a/backend/src/services/project-key/project-key-dal.ts b/backend/src/services/project-key/project-key-dal.ts index 7423a48de..d1b4053d0 100644 --- a/backend/src/services/project-key/project-key-dal.ts +++ b/backend/src/services/project-key/project-key-dal.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TProjectKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -10,10 +12,11 @@ export const projectKeyDALFactory = (db: TDbClient) => { const findLatestProjectKey = async ( userId: string, - projectId: string + projectId: string, + tx?: Knex ): Promise<(TProjectKeys & { sender: { publicKey: string } }) | undefined> => { try { - const projectKey = await db(TableName.ProjectKeys) + const projectKey = await (tx || db)(TableName.ProjectKeys) .join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`) .join(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`) .where({ projectId, receiverId: userId }) @@ -29,9 +32,9 @@ export const projectKeyDALFactory = (db: TDbClient) => { } }; - const findAllProjectUserPubKeys = async (projectId: string) => { + const findAllProjectUserPubKeys = async (projectId: string, tx?: Knex) => { try { - const pubKeys = await db(TableName.ProjectMembership) + const pubKeys = await (tx || db)(TableName.ProjectMembership) .where({ projectId }) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) diff --git a/backend/src/services/project-key/project-key-service.ts b/backend/src/services/project-key/project-key-service.ts index e7ebc23ee..70c8365ee 100644 --- a/backend/src/services/project-key/project-key-service.ts +++ b/backend/src/services/project-key/project-key-service.ts @@ -25,11 +25,19 @@ export const projectKeyServiceFactory = ({ receiverId, actor, actorId, + actorOrgId, + actorAuthMethod, projectId, nonce, encryptedKey }: TUploadProjectKeyDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); const receiverMembership = await projectMembershipDAL.findOne({ @@ -45,14 +53,32 @@ export const projectKeyServiceFactory = ({ await projectKeyDAL.create({ projectId, receiverId, encryptedKey, nonce, senderId: actorId }); }; - const getLatestProjectKey = async ({ actorId, projectId, actor }: TGetLatestProjectKeyDTO) => { - await permissionService.getProjectPermission(actor, actorId, projectId); + const getLatestProjectKey = async ({ + actorId, + projectId, + actor, + actorOrgId, + actorAuthMethod + }: TGetLatestProjectKeyDTO) => { + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); return latestKey; }; - const getProjectPublicKeys = async ({ actor, actorId, projectId }: TGetLatestProjectKeyDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getProjectPublicKeys = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }: TGetLatestProjectKeyDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); return projectKeyDAL.findAllProjectUserPubKeys(projectId); }; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 22b9937a9..590c26ecc 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,7 +1,9 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TProjectMembershipDALFactory = ReturnType; @@ -9,7 +11,117 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const projectMemberOrm = ormify(db, TableName.ProjectMembership); // special query - const findAllProjectMembers = async (projectId: string) => { + const findAllProjectMembers = async (projectId: string, filter: { usernames?: string[]; username?: string } = {}) => { + try { + const docs = await db(TableName.ProjectMembership) + .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .where((qb) => { + if (filter.usernames) { + void qb.whereIn("username", filter.usernames); + } + if (filter.username) { + void qb.where("username", filter.username); + } + }) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole) + ) + .where({ isGhost: false }); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + } catch (error) { + throw new DatabaseError({ error, name: "Find all project members" }); + } + }; + + const findProjectGhostUser = async (projectId: string, tx?: Knex) => { + try { + const ghostUser = await (tx || db)(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select(selectAllTableCols(TableName.Users)) + .where({ isGhost: true }) + .first(); + + return ghostUser; + } catch (error) { + throw new DatabaseError({ error, name: "Find project top-level user" }); + } + }; + + const findMembershipsByUsername = async (projectId: string, usernames: string[]) => { try { const members = await db(TableName.ProjectMembership) .where({ projectId }) @@ -20,24 +132,40 @@ export const projectMembershipDALFactory = (db: TDbClient) => { `${TableName.Users}.id` ) .select( - db.ref("id").withSchema(TableName.ProjectMembership), - db.ref("projectId").withSchema(TableName.ProjectMembership), - db.ref("role").withSchema(TableName.ProjectMembership), - db.ref("roleId").withSchema(TableName.ProjectMembership), - db.ref("email").withSchema(TableName.Users), - db.ref("publicKey").withSchema(TableName.UserEncryptionKey), - db.ref("firstName").withSchema(TableName.Users), - db.ref("lastName").withSchema(TableName.Users), - db.ref("id").withSchema(TableName.Users).as("userId") - ); - return members.map(({ email, firstName, lastName, publicKey, ...data }) => ({ + selectAllTableCols(TableName.ProjectMembership), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("username").withSchema(TableName.Users) + ) + .whereIn("username", usernames) + .where({ isGhost: false }); + return members.map(({ userId, username, ...data }) => ({ ...data, - user: { email, firstName, lastName, id: data.userId, publicKey } + user: { id: userId, username } })); } catch (error) { - throw new DatabaseError({ error, name: "Find all project members" }); + throw new DatabaseError({ error, name: "Find members by email" }); } }; - return { ...projectMemberOrm, findAllProjectMembers }; + const findProjectMembershipsByUserId = async (orgId: string, userId: string) => { + try { + const memberships = await db(TableName.ProjectMembership) + .where({ userId }) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .where({ [`${TableName.Project}.orgId` as "orgId"]: orgId }) + .select(selectAllTableCols(TableName.ProjectMembership)); + + return memberships; + } catch (error) { + throw new DatabaseError({ error, name: "Find project memberships by user id" }); + } + }; + + return { + ...projectMemberOrm, + findAllProjectMembers, + findProjectGhostUser, + findMembershipsByUsername, + findProjectMembershipsByUserId + }; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 862c19384..a6682465f 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,36 +1,56 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; -import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; +import { + ProjectMembershipRole, + ProjectVersion, + SecretKeyEncoding, + TableName, + TProjectMemberships +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; +import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; +import { ActorType } from "../auth/auth-type"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { + ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, - TDeleteProjectMembershipDTO, + TAddUsersToWorkspaceNonE2EEDTO, + TDeleteProjectMembershipOldDTO, + TDeleteProjectMembershipsDTO, + TGetProjectMembershipByUsernameDTO, TGetProjectMembershipDTO, - TInviteUserToProjectDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; +import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; + projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; - userDAL: Pick; - projectRoleDAL: Pick; - orgDAL: Pick; - projectDAL: Pick; + projectUserMembershipRoleDAL: Pick; + userDAL: Pick; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; + projectRoleDAL: Pick; + orgDAL: Pick; + projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; }; @@ -40,86 +60,80 @@ export type TProjectMembershipServiceFactory = ReturnType { - const getProjectMemberships = async ({ actorId, actor, projectId }: TGetProjectMembershipDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getProjectMemberships = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetProjectMembershipDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); return projectMembershipDAL.findAllProjectMembers(projectId); }; - const inviteUserToProject = async ({ actorId, actor, projectId, email }: TInviteUserToProjectDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); - - const invitee = await userDAL.findOne({ email }); - if (!invitee || !invitee.isAccepted) - throw new BadRequestError({ - message: "Faield to validate invitee", - name: "Invite user to project" - }); - - const inviteeMembership = await projectMembershipDAL.findOne({ - userId: invitee.id, - projectId - }); - if (inviteeMembership) - throw new BadRequestError({ - message: "Existing member of project", - name: "Invite user to project" - }); - - const project = await projectDAL.findById(projectId); - const inviteeMembershipOrg = await orgDAL.findMembership({ - userId: invitee.id, - orgId: project.orgId, - status: OrgMembershipStatus.Accepted - }); - if (!inviteeMembershipOrg) - throw new BadRequestError({ - message: "Failed to validate invitee org membership", - name: "Invite user to project" - }); - - const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); - await projectMembershipDAL.create({ - userId: invitee.id, + const getProjectMembershipByUsername = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + username + }: TGetProjectMembershipByUsernameDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, projectId, - role: ProjectMembershipRole.Member - }); + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - const sender = await userDAL.findById(actorId); - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: [invitee.email], - substitutions: { - inviterFirstName: sender.firstName, - inviterEmail: sender.email, - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - - return { invitee, latestKey }; + const [membership] = await projectMembershipDAL.findAllProjectMembers(projectId, { username }); + if (!membership) throw new BadRequestError({ message: `Project membership not found for user ${username}` }); + return membership; }; - const addUsersToProject = async ({ projectId, actorId, actor, members }: TAddUsersToWorkspaceDTO) => { + const addUsersToProject = async ({ + projectId, + actorId, + actor, + actorOrgId, + actorAuthMethod, + members, + sendEmails = true + }: TAddUsersToWorkspaceDTO) => { const project = await projectDAL.findById(projectId); if (!project) throw new BadRequestError({ message: "Project not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); const orgMembers = await orgDAL.findMembership({ - orgId: project.orgId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId, $in: { [`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) } @@ -128,86 +142,306 @@ export const projectMembershipServiceFactory = ({ const existingMembers = await projectMembershipDAL.find({ projectId, - $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) as string[] } + $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) } }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + const userIdsToExcludeForProjectKeyAddition = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject( + orgMembers.map(({ username }) => username), + projectId + ) + ); + await projectMembershipDAL.transaction(async (tx) => { - await projectMembershipDAL.insertMany( + const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ userId }) => ({ projectId, - userId: userId as string, - role: ProjectMembershipRole.Member + userId })), tx ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), + tx + ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( - orgMembers.map(({ userId, id }) => ({ - encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, - nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, - senderId: actorId, - receiverId: userId as string, - projectId - })), + orgMembers + .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId)) + .map(({ userId, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: actorId, + receiverId: userId, + projectId + })), tx ); }); - const sender = await userDAL.findById(actorId); - const appCfg = getConfig(); - await smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical workspace invitation", - recipients: orgMembers.map(({ email }) => email).filter(Boolean), - substitutions: { - inviterFirstName: sender.firstName, - inviterEmail: sender.email, - workspaceName: project.name, - callback_url: `${appCfg.SITE_URL}/login` - } - }); + + if (sendEmails) { + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: orgMembers.filter((i) => i.email).map((i) => i.email as string), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } return orgMembers; }; + const addUsersToProjectNonE2EE = async ({ + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + emails, + usernames, + sendEmails = true + }: TAddUsersToWorkspaceNonE2EEDTO) => { + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version === ProjectVersion.V1) { + throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + + const usernamesAndEmails = [...emails, ...usernames]; + + const orgMembers = await orgDAL.findOrgMembersByUsername(project.orgId, [ + ...new Set(usernamesAndEmails.map((element) => element.toLowerCase())) + ]); + + if (orgMembers.length !== usernamesAndEmails.length) + throw new BadRequestError({ message: "Some users are not part of org" }); + + if (!orgMembers.length) return []; + + const existingMembers = await projectMembershipDAL.find({ + projectId, + $in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) } + }); + if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const newWsMembers = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: orgMembers.map((membership) => ({ + orgMembershipId: membership.id, + projectMembershipRole: ProjectMembershipRole.Member, + userPublicKey: membership.user.publicKey + })) + }); + + const members: TProjectMemberships[] = []; + + const userIdsToExcludeForProjectKeyAddition = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) + ); + + await projectMembershipDAL.transaction(async (tx) => { + const projectMemberships = await projectMembershipDAL.insertMany( + orgMembers.map(({ user }) => ({ + projectId, + userId: user.id + })), + tx + ); + await projectUserMembershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), + tx + ); + + members.push(...projectMemberships); + + const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); + await projectKeyDAL.insertMany( + orgMembers + .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) + .map(({ user, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + })), + tx + ); + }); + + if (sendEmails) { + const recipients = orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string); + + const appCfg = getConfig(); + + if (recipients.length) { + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: orgMembers.filter((i) => i.user.email).map((i) => i.user.email as string), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } + } + return members; + }; + const updateProjectMembership = async ({ actorId, actor, + actorOrgId, + actorAuthMethod, projectId, membershipId, - role + roles }: TUpdateProjectMembershipDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); - const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); - if (isCustomRole) { - const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); - if (!customRole) throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); - const project = await projectDAL.findById(customRole.projectId); - const plan = await licenseService.getPlan(project.orgId); + const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); + if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) { + throw new BadRequestError({ + message: "Unauthorized member update", + name: "Update project membership" + }); + } + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + if (hasCustomRole) { + const plan = await licenseService.getPlan(actorOrgId); if (!plan?.rbac) throw new BadRequestError({ message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." }); - - const [membership] = await projectMembershipDAL.update( - { id: membershipId, projectId }, - { - role: ProjectMembershipRole.Custom, - roleId: customRole.id - } - ); - return membership; } - const [membership] = await projectMembershipDAL.update({ id: membershipId, projectId }, { role, roleId: null }); - return membership; + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: membershipId, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: membershipId, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await projectMembershipDAL.transaction(async (tx) => { + await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx); + return projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + }); + + return updatedRoles; }; - const deleteProjectMembership = async ({ actorId, actor, projectId, membershipId }: TDeleteProjectMembershipDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. + const deleteProjectMembership = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + membershipId + }: TDeleteProjectMembershipOldDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + const member = await userDAL.findUserByProjectMembershipId(membershipId); + + if (member?.isGhost) { + throw new BadRequestError({ + message: "Unauthorized member delete", + name: "Delete project membership" + }); + } + const membership = await projectMembershipDAL.transaction(async (tx) => { const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); @@ -216,11 +450,94 @@ export const projectMembershipServiceFactory = ({ return membership; }; + const deleteProjectMemberships = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + emails, + usernames + }: TDeleteProjectMembershipsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + + const project = await projectDAL.findById(projectId); + + if (!project) { + throw new BadRequestError({ + message: "Project not found", + name: "Delete project membership" + }); + } + + const usernamesAndEmails = [...emails, ...usernames]; + + const projectMembers = await projectMembershipDAL.findMembershipsByUsername(projectId, [ + ...new Set(usernamesAndEmails.map((element) => element.toLowerCase())) + ]); + + if (projectMembers.length !== usernamesAndEmails.length) { + throw new BadRequestError({ + message: "Some users are not part of project", + name: "Delete project membership" + }); + } + + if (actor === ActorType.USER && projectMembers.some(({ user }) => user.id === actorId)) { + throw new BadRequestError({ + message: "Cannot remove yourself from project", + name: "Delete project membership" + }); + } + + const userIdsToExcludeFromProjectKeyRemoval = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) + ); + + const memberships = await projectMembershipDAL.transaction(async (tx) => { + const deletedMemberships = await projectMembershipDAL.delete( + { + projectId, + $in: { + id: projectMembers.map(({ id }) => id) + } + }, + tx + ); + + // delete project keys belonging to users that are not part of any other groups in the project + await projectKeyDAL.delete( + { + projectId, + $in: { + receiverId: projectMembers + .filter(({ user }) => !userIdsToExcludeFromProjectKeyRemoval.has(user.id)) + .map(({ user }) => user.id) + .filter(Boolean) + } + }, + tx + ); + + return deletedMemberships; + }); + return memberships; + }; + return { getProjectMemberships, - inviteUserToProject, + getProjectMembershipByUsername, updateProjectMembership, - deleteProjectMembership, + addUsersToProjectNonE2EE, + deleteProjectMemberships, + deleteProjectMembership, // TODO: Remove this addUsersToProject }; }; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 6ee1d2965..1eab75265 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,24 +1,55 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; +export enum ProjectUserMembershipTemporaryMode { + Relative = "relative" +} export type TInviteUserToProjectDTO = { - email: string; + emails: string[]; +} & TProjectPermission; + +export type TGetProjectMembershipByUsernameDTO = { + username: string; } & TProjectPermission; export type TUpdateProjectMembershipDTO = { membershipId: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectPermission; -export type TDeleteProjectMembershipDTO = { +export type TDeleteProjectMembershipOldDTO = { membershipId: string; } & TProjectPermission; +export type TDeleteProjectMembershipsDTO = { + emails: string[]; + usernames: string[]; +} & TProjectPermission; + export type TAddUsersToWorkspaceDTO = { + sendEmails?: boolean; members: { orgMembershipId: string; workspaceEncryptedKey: string; workspaceEncryptedNonce: string; }[]; } & TProjectPermission; + +export type TAddUsersToWorkspaceNonE2EEDTO = { + sendEmails?: boolean; + emails: string[]; + usernames: string[]; +} & TProjectPermission; diff --git a/backend/src/services/project-membership/project-user-membership-role-dal.ts b/backend/src/services/project-membership/project-user-membership-role-dal.ts new file mode 100644 index 000000000..b1cb55b9b --- /dev/null +++ b/backend/src/services/project-membership/project-user-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectUserMembershipRoleDALFactory = ReturnType; + +export const projectUserMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProjectUserMembershipRole); + return orm; +}; diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 4c0633712..831af3200 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -13,24 +13,41 @@ import { } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; -import { ActorType } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; permissionService: Pick; + identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; + projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; }; export type TProjectRoleServiceFactory = ReturnType; -export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: TProjectRoleServiceFactoryDep) => { +export const projectRoleServiceFactory = ({ + projectRoleDAL, + permissionService, + identityProjectMembershipRoleDAL, + projectUserMembershipRoleDAL +}: TProjectRoleServiceFactoryDep) => { const createRole = async ( actor: ActorType, actorId: string, projectId: string, - data: Omit + data: Omit, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined ) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Role); const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId }); if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); @@ -47,9 +64,17 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: actorId: string, projectId: string, roleId: string, - data: Omit + data: Omit, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined ) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); if (data?.slug) { const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId }); @@ -64,17 +89,59 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: return updatedRole; }; - const deleteRole = async (actor: ActorType, actorId: string, projectId: string, roleId: string) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteRole = async ( + actor: ActorType, + actorId: string, + projectId: string, + roleId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); + + const identityRole = await identityProjectMembershipRoleDAL.findOne({ customRoleId: roleId }); + const projectUserRole = await projectUserMembershipRoleDAL.findOne({ customRoleId: roleId }); + + if (identityRole) { + throw new BadRequestError({ + message: "The role is assigned to one or more identities. Make sure to unassign them before deleting the role.", + name: "Delete role" + }); + } + if (projectUserRole) { + throw new BadRequestError({ + message: "The role is assigned to one or more users. Make sure to unassign them before deleting the role.", + name: "Delete role" + }); + } + const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); - if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Delete role" }); return deletedRole; }; - const listRoles = async (actor: ActorType, actorId: string, projectId: string) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const listRoles = async ( + actor: ActorType, + actorId: string, + projectId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); const customRoles = await projectRoleDAL.find({ projectId }); const roles = [ @@ -84,7 +151,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Admin", slug: ProjectMembershipRole.Admin, description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions.rules), + permissions: packRules(projectAdminPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -94,7 +161,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Developer", slug: ProjectMembershipRole.Member, description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions.rules), + permissions: packRules(projectMemberPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -104,7 +171,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "Viewer", slug: ProjectMembershipRole.Viewer, description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission.rules), + permissions: packRules(projectViewerPermission), createdAt: new Date(), updatedAt: new Date() }, @@ -114,7 +181,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: name: "No Access", slug: "no-access", description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions.rules), + permissions: packRules(projectNoAccessPermissions), createdAt: new Date(), updatedAt: new Date() }, @@ -127,8 +194,18 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }: return roles; }; - const getUserPermission = async (userId: string, projectId: string) => { - const { permission, membership } = await permissionService.getUserProjectPermission(userId, projectId); + const getUserPermission = async ( + userId: string, + projectId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string | undefined + ) => { + const { permission, membership } = await permissionService.getUserProjectPermission( + userId, + projectId, + actorAuthMethod, + actorOrgId + ); return { permissions: packRules(permission.rules), membership }; }; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 44ba57481..a4ec99157 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -1,8 +1,12 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { ProjectsSchema, TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { ProjectsSchema, ProjectUpgradeStatus, ProjectVersion, TableName, TProjectsUpdate } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { Filter, ProjectFilterType } from "./project-types"; + export type TProjectDALFactory = ReturnType; export const projectDALFactory = (db: TDbClient) => { @@ -26,8 +30,33 @@ export const projectDALFactory = (db: TDbClient) => { { column: `${TableName.Environment}.position`, order: "asc" } ]); + const groups: string[] = await db(TableName.UserGroupMembership) + .where({ userId }) + .select(selectAllTableCols(TableName.UserGroupMembership)) + .pluck("groupId"); + + const groupWorkspaces = await db(TableName.GroupProjectMembership) + .whereIn("groupId", groups) + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .whereNotIn( + `${TableName.Project}.id`, + workspaces.map(({ id }) => id) + ) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .select( + selectAllTableCols(TableName.Project), + db.ref("id").withSchema(TableName.Project).as("_id"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + .orderBy([ + { column: `${TableName.Project}.name`, order: "asc" }, + { column: `${TableName.Environment}.position`, order: "asc" } + ]); + const nestedWorkspaces = sqlNestRelationships({ - data: workspaces, + data: workspaces.concat(groupWorkspaces), key: "id", parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), childrenMapper: [ @@ -52,6 +81,32 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const findProjectGhostUser = async (projectId: string, tx?: Knex) => { + try { + const ghostUser = await (tx || db)(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select(selectAllTableCols(TableName.Users)) + .where({ isGhost: true }) + .first(); + return ghostUser; + } catch (error) { + throw new DatabaseError({ error, name: "Find project top-level user" }); + } + }; + + const setProjectUpgradeStatus = async (projectId: string, status: ProjectUpgradeStatus | null, tx?: Knex) => { + try { + const data: TProjectsUpdate = { + upgradeStatus: status + } as const; + + await (tx || db)(TableName.Project).where({ id: projectId }).update(data); + } catch (error) { + throw new DatabaseError({ error, name: "Set project upgrade status" }); + } + }; + const findAllProjectsByIdentity = async (identityId: string) => { try { const workspaces = await db(TableName.IdentityProjectMembership) @@ -96,13 +151,11 @@ export const projectDALFactory = (db: TDbClient) => { const findProjectById = async (id: string) => { try { - const workspaces = await db(TableName.ProjectMembership) + const workspaces = await db(TableName.Project) .where(`${TableName.Project}.id`, id) - .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) .select( selectAllTableCols(TableName.Project), - db.ref("id").withSchema(TableName.Project).as("_id"), db.ref("id").withSchema(TableName.Environment).as("envId"), db.ref("slug").withSchema(TableName.Environment).as("envSlug"), db.ref("name").withSchema(TableName.Environment).as("envName") @@ -111,10 +164,11 @@ export const projectDALFactory = (db: TDbClient) => { { column: `${TableName.Project}.name`, order: "asc" }, { column: `${TableName.Environment}.position`, order: "asc" } ]); - return sqlNestRelationships({ + + const project = sqlNestRelationships({ data: workspaces, key: "id", - parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), + parentMapper: ({ ...el }) => ({ _id: el.id, ...ProjectsSchema.parse(el) }), childrenMapper: [ { key: "envId", @@ -127,15 +181,109 @@ export const projectDALFactory = (db: TDbClient) => { } ] })?.[0]; + + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + return project; } catch (error) { throw new DatabaseError({ error, name: "Find all projects" }); } }; + const findProjectBySlug = async (slug: string, orgId: string | undefined) => { + try { + if (!orgId) { + throw new BadRequestError({ message: "Organization ID is required when querying with slugs" }); + } + + const projects = await db(TableName.Project) + .where(`${TableName.Project}.slug`, slug) + .where(`${TableName.Project}.orgId`, orgId) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .select( + selectAllTableCols(TableName.Project), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + .orderBy([ + { column: `${TableName.Project}.name`, order: "asc" }, + { column: `${TableName.Environment}.position`, order: "asc" } + ]); + + const project = sqlNestRelationships({ + data: projects, + key: "id", + parentMapper: ({ ...el }) => ({ _id: el.id, ...ProjectsSchema.parse(el) }), + childrenMapper: [ + { + key: "envId", + label: "environments" as const, + mapper: ({ envId, envSlug, envName: name }) => ({ + id: envId, + slug: envSlug, + name + }) + } + ] + })?.[0]; + + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + return project; + } catch (error) { + throw new DatabaseError({ error, name: "Find project by slug" }); + } + }; + + const findProjectByFilter = async (filter: Filter) => { + try { + if (filter.type === ProjectFilterType.ID) { + return await findProjectById(filter.projectId); + } + if (filter.type === ProjectFilterType.SLUG) { + if (!filter.orgId) { + throw new BadRequestError({ + message: "Organization ID is required when querying with slugs" + }); + } + + return await findProjectBySlug(filter.slug, filter.orgId); + } + throw new BadRequestError({ message: "Invalid filter type" }); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + throw new DatabaseError({ error, name: `Failed to find project by ${filter.type}` }); + } + }; + + const checkProjectUpgradeStatus = async (projectId: string) => { + const project = await projectOrm.findById(projectId); + const upgradeInProgress = + project.upgradeStatus === ProjectUpgradeStatus.InProgress && project.version === ProjectVersion.V1; + + if (upgradeInProgress) { + throw new BadRequestError({ + message: "Project is currently being upgraded, and secrets cannot be written. Please try again" + }); + } + }; + return { ...projectOrm, findAllProjects, + setProjectUpgradeStatus, findAllProjectsByIdentity, - findProjectById + findProjectGhostUser, + findProjectById, + findProjectByFilter, + findProjectBySlug, + checkProjectUpgradeStatus }; }; diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts new file mode 100644 index 000000000..3ac75248d --- /dev/null +++ b/backend/src/services/project/project-fns.ts @@ -0,0 +1,51 @@ +import crypto from "crypto"; + +import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; + +import { AddUserToWsDTO } from "./project-types"; + +export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => { + const plaintextProjectKey = decryptAsymmetric({ + ciphertext: decryptKey.encryptedKey, + nonce: decryptKey.nonce, + publicKey: decryptKey.sender.publicKey, + privateKey: userPrivateKey + }); + + const newWsMembers = members.map(({ orgMembershipId, userPublicKey, projectMembershipRole }) => { + const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric( + plaintextProjectKey, + userPublicKey, + userPrivateKey + ); + + return { + orgMembershipId, + projectRole: projectMembershipRole, + workspaceEncryptedKey: inviteeCipherText, + workspaceEncryptedNonce: inviteeNonce + }; + }); + + return newWsMembers; +}; + +type TCreateProjectKeyDTO = { + publicKey: string; + privateKey: string; + plainProjectKey?: string; +}; + +export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCreateProjectKeyDTO) => { + // 3. Create a random key that we'll use as the project key. + const randomBytes = plainProjectKey || crypto.randomBytes(16).toString("hex"); + + // 4. Encrypt the project key with the users key pair. + const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric( + randomBytes, + publicKey, + privateKey + ); + + return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; +}; diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts new file mode 100644 index 000000000..8f1e3fc3f --- /dev/null +++ b/backend/src/services/project/project-queue.ts @@ -0,0 +1,577 @@ +/* eslint-disable no-await-in-loop */ +import { + IntegrationAuthsSchema, + ProjectMembershipRole, + ProjectUpgradeStatus, + ProjectVersion, + SecretApprovalRequestsSecretsSchema, + SecretKeyEncoding, + SecretsSchema, + SecretVersionsSchema, + TableName, + TIntegrationAuths, + TSecretApprovalRequestsSecrets, + TSecrets, + TSecretVersions +} from "@app/db/schemas"; +import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; +import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; +import { RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; +import { + decryptIntegrationAuths, + decryptSecretApprovals, + decryptSecrets, + decryptSecretVersions +} from "@app/lib/crypto"; +import { + decryptAsymmetric, + encryptSymmetric128BitHexKeyUTF8, + infisicalSymmetricDecrypt, + infisicalSymmetricEncypt +} from "@app/lib/crypto/encryption"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueJobTypes, TQueueServiceFactory } from "@app/queue"; + +import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TOrgServiceFactory } from "../org/org-service"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TSecretDALFactory } from "../secret/secret-dal"; +import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TProjectDALFactory } from "./project-dal"; +import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; + +export type TProjectQueueFactory = ReturnType; + +type TProjectQueueFactoryDep = { + queueService: TQueueServiceFactory; + secretVersionDAL: Pick; + folderDAL: Pick; + secretDAL: Pick; + projectKeyDAL: Pick; + secretApprovalRequestDAL: Pick; + secretApprovalSecretDAL: Pick; + projectBotDAL: Pick; + orgService: Pick; + projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; + integrationAuthDAL: TIntegrationAuthDALFactory; + userDAL: Pick; + projectEnvDAL: Pick; + projectDAL: Pick; + orgDAL: Pick; +}; + +export const projectQueueFactory = ({ + queueService, + secretDAL, + folderDAL, + userDAL, + secretVersionDAL, + integrationAuthDAL, + secretApprovalRequestDAL, + secretApprovalSecretDAL, + projectKeyDAL, + projectBotDAL, + projectEnvDAL, + orgDAL, + projectDAL, + orgService, + projectMembershipDAL, + projectUserMembershipRoleDAL +}: TProjectQueueFactoryDep) => { + const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { + await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { + attempts: 1, + removeOnComplete: true, + removeOnFail: { + count: 5 // keep the most recent jobs + } + }); + }; + + queueService.start(QueueName.UpgradeProjectToGhost, async ({ data }) => { + try { + const [project] = await projectDAL.find({ + id: data.projectId, + version: ProjectVersion.V1 + }); + + const oldProjectKey = await projectKeyDAL.findLatestProjectKey(data.startedByUserId, data.projectId); + + if (!project) { + throw new Error("Project not found"); + } + if (!oldProjectKey) { + throw new Error("Old project key not found"); + } + + if (project.upgradeStatus !== ProjectUpgradeStatus.Failed && project.upgradeStatus !== null) { + throw new Error("Project upgrade status is not valid"); + } + + await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.InProgress); // Set the status to in progress. This is important to prevent multiple upgrades at the same time. + + // eslint-disable-next-line no-promise-executor-return + // await new Promise((resolve) => setTimeout(resolve, 50_000)); + + const userPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: data.encryptedPrivateKey.keyEncoding, + ciphertext: data.encryptedPrivateKey.encryptedKey, + iv: data.encryptedPrivateKey.encryptedKeyIv, + tag: data.encryptedPrivateKey.encryptedKeyTag + }); + + const decryptedPlainProjectKey = decryptAsymmetric({ + ciphertext: oldProjectKey.encryptedKey, + nonce: oldProjectKey.nonce, + publicKey: oldProjectKey.sender.publicKey, + privateKey: userPrivateKey + }); + + const projectEnvs = await projectEnvDAL.find({ + projectId: project.id + }); + + const projectFolders = await folderDAL.find({ + $in: { + envId: projectEnvs.map((env) => env.id) + } + }); + + // Get all the secrets within the project (as encrypted) + const projectIntegrationAuths = await integrationAuthDAL.find({ + projectId: project.id + }); + const secrets: TSecrets[] = []; + const secretVersions: TSecretVersions[] = []; + const approvalSecrets: TSecretApprovalRequestsSecrets[] = []; + const folderSecretVersionIdsToDelete: string[] = []; + + for (const folder of projectFolders) { + const folderSecrets = await secretDAL.find({ folderId: folder.id }); + + const folderSecretVersions = await secretVersionDAL.find( + { + folderId: folder.id + }, + // Only get the latest 700 secret versions for each folder. + { + limit: 1000, + sort: [["createdAt", "desc"]] + } + ); + + const deletedSecretVersions = await secretVersionDAL.find( + { + folderId: folder.id + }, + { + // Get all the secret versions that are not the latest 700 + offset: 1000 + } + ); + folderSecretVersionIdsToDelete.push(...deletedSecretVersions.map((el) => el.id)); + + const approvalRequests = await secretApprovalRequestDAL.find({ + status: RequestState.Open, + folderId: folder.id + }); + const secretApprovals = await secretApprovalSecretDAL.find({ + $in: { + requestId: approvalRequests.map((el) => el.id) + } + }); + + secrets.push(...folderSecrets); + secretVersions.push(...folderSecretVersions); + approvalSecrets.push(...secretApprovals); + } + + const decryptedSecrets = decryptSecrets(secrets, userPrivateKey, oldProjectKey); + const decryptedSecretVersions = decryptSecretVersions(secretVersions, userPrivateKey, oldProjectKey); + const decryptedApprovalSecrets = decryptSecretApprovals(approvalSecrets, userPrivateKey, oldProjectKey); + const decryptedIntegrationAuths = decryptIntegrationAuths(projectIntegrationAuths, userPrivateKey, oldProjectKey); + + // Get the existing bot and the existing project keys for the members of the project + const existingBot = await projectBotDAL.findOne({ projectId: project.id }).catch(() => null); + const existingProjectKeys = await projectKeyDAL.find({ projectId: project.id }); + + // TRANSACTION START + await projectDAL.transaction(async (tx) => { + await projectDAL.updateById(project.id, { version: ProjectVersion.V2 }, tx); + + // Create a ghost user + const ghostUser = await orgService.addGhostUser(project.orgId, tx); + + // Create a project key + const { key: newEncryptedProjectKey, iv: newEncryptedProjectKeyIv } = createProjectKey({ + plainProjectKey: decryptedPlainProjectKey, + publicKey: ghostUser.keys.publicKey, + privateKey: ghostUser.keys.plainPrivateKey + }); + + // Create a new project key for the GHOST + await projectKeyDAL.create( + { + projectId: project.id, + receiverId: ghostUser.user.id, + encryptedKey: newEncryptedProjectKey, + nonce: newEncryptedProjectKeyIv, + senderId: ghostUser.user.id + }, + tx + ); + + // Create a membership for the ghost user + const projectMembership = await projectMembershipDAL.create( + { + projectId: project.id, + userId: ghostUser.user.id + }, + tx + ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); + + // If a bot already exists, delete it + if (existingBot) { + await projectBotDAL.delete({ id: existingBot.id }, tx); + } + + // Delete all the existing project keys + await projectKeyDAL.delete( + { + projectId: project.id, + $in: { + id: existingProjectKeys.map((key) => key.id) + } + }, + tx + ); + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx); + + if (!ghostUserLatestKey) { + throw new Error("User latest key not found (V2 Upgrade)"); + } + + const newProjectMembers: { + encryptedKey: string; + nonce: string; + senderId: string; + receiverId: string; + projectId: string; + }[] = []; + + for (const key of existingProjectKeys) { + const user = await userDAL.findUserEncKeyByUserId(key.receiverId); + const [orgMembership] = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: key.receiverId, + [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId + }); + + if (!user) { + throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`); + } + + if (!orgMembership) { + // This can happen. Since we don't remove project memberships and project keys when a user is removed from an org, this is a valid case. + logger.info("User is not in organization", { + userId: key.receiverId, + orgId: project.orgId, + projectId: project.id + }); + // eslint-disable-next-line no-continue + continue; + } + + const [newMember] = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: ghostUser.keys.plainPrivateKey, + members: [ + { + userPublicKey: user.publicKey, + orgMembershipId: orgMembership.id, + projectMembershipRole: ProjectMembershipRole.Admin + } + ] + }); + + newProjectMembers.push({ + encryptedKey: newMember.workspaceEncryptedKey, + nonce: newMember.workspaceEncryptedNonce, + senderId: ghostUser.user.id, + receiverId: user.id, + projectId: project.id + }); + } + + // Create project keys for all the old members + await projectKeyDAL.insertMany(newProjectMembers, tx); + + // Encrypt the bot private key (which is the same as the ghost user) + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey); + + // 5. Create a bot for the project + const newBot = await projectBotDAL.create( + { + name: "Infisical Bot (Ghost)", + projectId: project.id, + tag, + iv, + encryptedPrivateKey: ciphertext, + isActive: true, + publicKey: ghostUser.keys.publicKey, + senderId: ghostUser.user.id, + encryptedProjectKey: newEncryptedProjectKey, + encryptedProjectKeyNonce: newEncryptedProjectKeyIv, + algorithm, + keyEncoding: encoding + }, + tx + ); + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: newBot.keyEncoding as SecretKeyEncoding, + iv: newBot.iv, + tag: newBot.tag, + ciphertext: newBot.encryptedPrivateKey + }); + + const botKey = decryptAsymmetric({ + ciphertext: newBot.encryptedProjectKey!, + privateKey: botPrivateKey, + nonce: newBot.encryptedProjectKeyNonce!, + publicKey: ghostUser.keys.publicKey + }); + + const updatedSecrets: TSecrets[] = []; + const updatedSecretVersions: TSecretVersions[] = []; + const updatedSecretApprovals: TSecretApprovalRequestsSecrets[] = []; + const updatedIntegrationAuths: TIntegrationAuths[] = []; + for (const rawSecret of decryptedSecrets) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecret.decrypted.secretComment || "", + botKey + ); + + const payload: TSecrets = { + ...rawSecret.original, + keyEncoding: SecretKeyEncoding.UTF8, + + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag + } as const; + + if (!SecretsSchema.safeParse(payload).success) { + throw new Error(`Invalid secret payload: ${JSON.stringify(payload)}`); + } + + updatedSecrets.push(payload); + } + + for (const rawSecretVersion of decryptedSecretVersions) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretVersion.decrypted.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretVersion.decrypted.secretValue || "", + botKey + ); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretVersion.decrypted.secretComment || "", + botKey + ); + + const payload: TSecretVersions = { + ...rawSecretVersion.original, + keyEncoding: SecretKeyEncoding.UTF8, + + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag + } as const; + + if (!SecretVersionsSchema.safeParse(payload).success) { + throw new Error(`Invalid secret version payload: ${JSON.stringify(payload)}`); + } + + updatedSecretVersions.push(payload); + } + + for (const rawSecretApproval of decryptedApprovalSecrets) { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretApproval.decrypted.secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretApproval.decrypted.secretValue || "", + botKey + ); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8( + rawSecretApproval.decrypted.secretComment || "", + botKey + ); + + const payload: TSecretApprovalRequestsSecrets = { + ...rawSecretApproval.original, + keyEncoding: SecretKeyEncoding.UTF8, + + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag + } as const; + + if (!SecretApprovalRequestsSecretsSchema.safeParse(payload).success) { + throw new Error(`Invalid secret approval payload: ${JSON.stringify(payload)}`); + } + + updatedSecretApprovals.push(payload); + } + + for (const integrationAuth of decryptedIntegrationAuths) { + const access = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.access, botKey); + const accessId = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.accessId, botKey); + const refresh = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.refresh, botKey); + + const payload: TIntegrationAuths = { + ...integrationAuth.original, + keyEncoding: SecretKeyEncoding.UTF8, + + accessCiphertext: access.ciphertext, + accessIV: access.iv, + accessTag: access.tag, + + accessIdCiphertext: accessId.ciphertext, + accessIdIV: accessId.iv, + accessIdTag: accessId.tag, + + refreshCiphertext: refresh.ciphertext, + refreshIV: refresh.iv, + refreshTag: refresh.tag + } as const; + + if (!IntegrationAuthsSchema.safeParse(payload).success) { + throw new Error(`Invalid integration auth payload: ${JSON.stringify(payload)}`); + } + + updatedIntegrationAuths.push(payload); + } + + if (updatedSecrets.length !== secrets.length) { + throw new Error("Failed to update some secrets"); + } + if (updatedSecretVersions.length !== secretVersions.length) { + throw new Error("Failed to update some secret versions"); + } + if (updatedSecretApprovals.length !== approvalSecrets.length) { + throw new Error("Failed to update some secret approvals"); + } + if (updatedIntegrationAuths.length !== projectIntegrationAuths.length) { + throw new Error("Failed to update some integration auths"); + } + + const secretUpdates = await secretDAL.bulkUpdateNoVersionIncrement(updatedSecrets, tx); + const secretVersionUpdates = await secretVersionDAL.bulkUpdateNoVersionIncrement(updatedSecretVersions, tx); + const secretApprovalUpdates = await secretApprovalSecretDAL.bulkUpdateNoVersionIncrement( + updatedSecretApprovals, + tx + ); + const integrationAuthUpdates = await integrationAuthDAL.bulkUpdate( + updatedIntegrationAuths.map((el) => ({ + filter: { id: el.id }, + data: { + ...el, + id: undefined + } + })), + tx + ); + + // Delete all secret versions that are no longer needed. We only store the latest 100 versions for each secret. + await secretVersionDAL.delete( + { + $in: { + id: folderSecretVersionIdsToDelete + } + }, + tx + ); + + if ( + secretUpdates.length !== updatedSecrets.length || + secretVersionUpdates.length !== updatedSecretVersions.length || + secretApprovalUpdates.length !== updatedSecretApprovals.length || + integrationAuthUpdates.length !== updatedIntegrationAuths.length + ) { + throw new Error("Parts of the upgrade failed. Some secrets were not updated"); + } + + await projectDAL.setProjectUpgradeStatus(data.projectId, null, tx); + + // await new Promise((resolve) => setTimeout(resolve, 15_000)); + // throw new Error("Transaction was successful!"); + }); + } catch (err) { + const [project] = await projectDAL + .find({ + id: data.projectId, + version: ProjectVersion.V1 + }) + .catch(() => [null]); + + if (!project) { + logger.error("Failed to upgrade project, because no project was found", data); + } else { + await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.Failed); + logger.error("Failed to upgrade project", err, { + extra: { + project, + jobData: data + } + }); + } + + throw err; + } + }); + + queueService.listen(QueueName.UpgradeProjectToGhost, "failed", (job, err) => { + logger.error(err, "Upgrade project failed", job?.data); + }); + + return { + upgradeProject + }; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 08d75b0dd..f58fd7788 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,22 +1,46 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { ProjectMembershipRole } from "@app/db/schemas"; +import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { getConfig } from "@app/lib/config/env"; import { createSecretBlindIndex } from "@app/lib/crypto"; -import { BadRequestError } from "@app/lib/errors"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TProjectPermission } from "@app/lib/types"; +import { ActorType } from "../auth/auth-type"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; +import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TOrgServiceFactory } from "../org/org-service"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; -import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types"; +import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; +import { TProjectQueueFactory } from "./project-queue"; +import { + TCreateProjectDTO, + TDeleteProjectDTO, + TGetProjectDTO, + TToggleProjectAutoCapitalizationDTO, + TUpdateProjectDTO, + TUpdateProjectNameDTO, + TUpgradeProjectDTO +} from "./project-types"; export const DEFAULT_PROJECT_ENVS = [ { name: "Development", slug: "dev" }, @@ -26,36 +50,73 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - folderDAL: Pick; - projectEnvDAL: Pick; - projectMembershipDAL: Pick; + projectQueue: TProjectQueueFactory; + userDAL: TUserDALFactory; + folderDAL: TSecretFolderDALFactory; + projectEnvDAL: Pick; + identityOrgMembershipDAL: TIdentityOrgDALFactory; + identityProjectDAL: TIdentityProjectDALFactory; + identityProjectMembershipRoleDAL: Pick; + projectKeyDAL: Pick; + projectBotDAL: Pick; + projectMembershipDAL: Pick; + projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; + orgService: Pick; licenseService: Pick; + orgDAL: Pick; + keyStore: Pick; }; export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectQueue, + projectKeyDAL, permissionService, + orgDAL, + userDAL, folderDAL, + orgService, + identityProjectDAL, + projectBotDAL, + identityOrgMembershipDAL, secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, - licenseService + licenseService, + projectUserMembershipRoleDAL, + identityProjectMembershipRoleDAL, + keyStore }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin * */ - const createProject = async ({ orgId, actor, actorId, workspaceName }: TCreateProjectDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + const createProject = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + workspaceName, + slug: projectSlug + }: TCreateProjectDTO) => { + const organization = await orgDAL.findOne({ id: actorOrgId }); + + const { permission, membership: orgMembership } = await permissionService.getOrgPermission( + actor, + actorId, + organization.id, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); const appCfg = getConfig(); const blindIndex = createSecretBlindIndex(appCfg.ROOT_ENCRYPTION_KEY, appCfg.ENCRYPTION_KEY); - const plan = await licenseService.getPlan(orgId); + const plan = await licenseService.getPlan(organization.id); if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { // case: limit imposed on number of workspaces allowed // case: number of workspaces used exceeds the number of workspaces allowed @@ -64,20 +125,31 @@ export const projectServiceFactory = ({ }); } - const newProject = projectDAL.transaction(async (tx) => { + const results = await projectDAL.transaction(async (tx) => { + const ghostUser = await orgService.addGhostUser(organization.id, tx); + const project = await projectDAL.create( - { name: workspaceName, orgId, slug: slugify(`${workspaceName}-${alphaNumericNanoId(4)}`) }, + { + name: workspaceName, + orgId: organization.id, + slug: projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), + version: ProjectVersion.V2 + }, tx ); - // set user as admin member for proeject - await projectMembershipDAL.create( + // set ghost user as admin of project + const projectMembership = await projectMembershipDAL.create( { - userId: actorId, - role: ProjectMembershipRole.Admin, + userId: ghostUser.user.id, projectId: project.id }, tx ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); + // generate the blind index for project await secretBlindIndexDAL.create( { @@ -99,18 +171,186 @@ export const projectServiceFactory = ({ envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })), tx ); - // _id for backward compat - return { ...project, environments: envs, _id: project.id }; + + // 3. Create a random key that we'll use as the project key. + const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ + publicKey: ghostUser.keys.publicKey, + privateKey: ghostUser.keys.plainPrivateKey + }); + + // 4. Save the project key for the ghost user. + await projectKeyDAL.create( + { + projectId: project.id, + receiverId: ghostUser.user.id, + encryptedKey: encryptedProjectKey, + nonce: encryptedProjectKeyIv, + senderId: ghostUser.user.id + }, + tx + ); + + const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey); + + // 5. Create & a bot for the project + await projectBotDAL.create( + { + name: "Infisical Bot (Ghost)", + projectId: project.id, + tag, + iv, + encryptedProjectKey, + encryptedProjectKeyNonce: encryptedProjectKeyIv, + encryptedPrivateKey: ciphertext, + isActive: true, + publicKey: ghostUser.keys.publicKey, + senderId: ghostUser.user.id, + algorithm, + keyEncoding: encoding + }, + tx + ); + + // Find the ghost users latest key + const latestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx); + + if (!latestKey) { + throw new Error("Latest key not found for user"); + } + + // If the project is being created by a user, add the user to the project as an admin + if (actor === ActorType.USER) { + // Find public key of user + const user = await userDAL.findUserEncKeyByUserId(actorId); + + if (!user) { + throw new Error("User not found"); + } + + const [projectAdmin] = assignWorkspaceKeysToMembers({ + decryptKey: latestKey, + userPrivateKey: ghostUser.keys.plainPrivateKey, + members: [ + { + userPublicKey: user.publicKey, + orgMembershipId: orgMembership.id, + projectMembershipRole: ProjectMembershipRole.Admin + } + ] + }); + + // Create a membership for the user + const userProjectMembership = await projectMembershipDAL.create( + { + projectId: project.id, + userId: user.id + }, + tx + ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole }, + tx + ); + + // Create a project key for the user + await projectKeyDAL.create( + { + encryptedKey: projectAdmin.workspaceEncryptedKey, + nonce: projectAdmin.workspaceEncryptedNonce, + senderId: ghostUser.user.id, + receiverId: user.id, + projectId: project.id + }, + tx + ); + } + + // If the project is being created by an identity, add the identity to the project as an admin + else if (actor === ActorType.IDENTITY) { + // Find identity org membership + const identityOrgMembership = await identityOrgMembershipDAL.findOne( + { + identityId: actorId, + orgId: project.orgId + }, + tx + ); + + // If identity org membership not found, throw error + if (!identityOrgMembership) { + throw new BadRequestError({ + message: `Failed to find identity with id ${actorId}` + }); + } + + // Get the role permission for the identity + const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( + OrgMembershipRole.Member, + organization.id + ); + + // Identity has to be at least a member in order to create projects + const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPrivilege) + throw new ForbiddenRequestError({ + message: "Failed to add identity to project with more privileged role" + }); + const isCustomRole = Boolean(customRole); + + const identityProjectMembership = await identityProjectDAL.create( + { + identityId: actorId, + projectId: project.id + }, + tx + ); + + await identityProjectMembershipRoleDAL.create( + { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, + customRoleId: customRole?.id + }, + tx + ); + } + + return { + ...project, + environments: envs, + _id: project.id + }; }); - return newProject; + await keyStore.deleteItem(`infisical-cloud-plan-${actorOrgId}`); + return results; }; - const deleteProject = async ({ actor, actorId, projectId }: TDeleteProjectDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteProject = async ({ actor, actorId, actorOrgId, actorAuthMethod, filter }: TDeleteProjectDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - const deletedProject = await projectDAL.deleteById(projectId); + const deletedProject = await projectDAL.transaction(async (tx) => { + const delProject = await projectDAL.deleteById(project.id, tx); + const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(project.id, tx).catch(() => null); + + // Delete the org membership for the ghost user if it's found. + if (projectGhostUser) { + await userDAL.deleteById(projectGhostUser.id, tx); + } + + return delProject; + }); + + await keyStore.deleteItem(`infisical-cloud-plan-${actorOrgId}`); return deletedProject; }; @@ -119,38 +359,148 @@ export const projectServiceFactory = ({ return workspaces; }; - const getAProject = async ({ actorId, projectId, actor }: TGetProjectDTO) => { - await permissionService.getProjectPermission(actor, actorId, projectId); - return projectDAL.findProjectById(projectId); + const getAProject = async ({ actorId, actorOrgId, actorAuthMethod, filter, actor }: TGetProjectDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + await permissionService.getProjectPermission(actor, actorId, project.id, actorAuthMethod, actorOrgId); + return project; + }; + + const updateProject = async ({ actor, actorId, actorOrgId, actorAuthMethod, update, filter }: TUpdateProjectDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + const updatedProject = await projectDAL.updateById(project.id, { + name: update.name, + autoCapitalization: update.autoCapitalization + }); + return updatedProject; }; const toggleAutoCapitalization = async ({ projectId, actor, actorId, + actorOrgId, + actorAuthMethod, autoCapitalization - }: TGetProjectDTO & { autoCapitalization: boolean }) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + }: TToggleProjectAutoCapitalizationDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); const updatedProject = await projectDAL.updateById(projectId, { autoCapitalization }); return updatedProject; }; - const updateName = async ({ projectId, actor, actorId, name }: TGetProjectDTO & { name: string }) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const updateName = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + name + }: TUpdateProjectNameDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); const updatedProject = await projectDAL.updateById(projectId, { name }); return updatedProject; }; + const upgradeProject = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + userPrivateKey + }: TUpgradeProjectDTO) => { + const { permission, hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ + message: "User must be admin" + }); + } + + const encryptedPrivateKey = infisicalSymmetricEncypt(userPrivateKey); + + await projectQueue.upgradeProject({ + projectId, + startedByUserId: actorId, + encryptedPrivateKey: { + encryptedKey: encryptedPrivateKey.ciphertext, + encryptedKeyIv: encryptedPrivateKey.iv, + encryptedKeyTag: encryptedPrivateKey.tag, + keyEncoding: encryptedPrivateKey.encoding + } + }); + }; + + const getProjectUpgradeStatus = async ({ + projectId, + actor, + actorAuthMethod, + actorOrgId, + actorId + }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); + + const project = await projectDAL.findProjectById(projectId); + + if (!project) { + throw new BadRequestError({ + message: `Project with id ${projectId} not found` + }); + } + + return project.upgradeStatus || null; + }; + return { createProject, deleteProject, getProjects, + updateProject, + getProjectUpgradeStatus, getAProject, toggleAutoCapitalization, - updateName + updateName, + upgradeProject }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 2b8c5e908..dcd424e18 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,20 +1,77 @@ -import { ActorType } from "../auth/auth-type"; +import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export enum ProjectFilterType { + ID = "id", + SLUG = "slug" +} + +export type Filter = + | { + type: ProjectFilterType.ID; + projectId: string; + } + | { + type: ProjectFilterType.SLUG; + slug: string; + orgId: string | undefined; + }; export type TCreateProjectDTO = { actor: ActorType; + actorAuthMethod: ActorAuthMethod; actorId: string; - orgId: string; + actorOrgId?: string; workspaceName: string; + slug?: string; }; -export type TDeleteProjectDTO = { +export type TDeleteProjectBySlugDTO = { + slug: string; actor: ActorType; actorId: string; - projectId: string; + actorOrgId: string | undefined; }; export type TGetProjectDTO = { + filter: Filter; +} & Omit; + +export type TToggleProjectAutoCapitalizationDTO = { + autoCapitalization: boolean; +} & TProjectPermission; + +export type TUpdateProjectNameDTO = { + name: string; +} & TProjectPermission; + +export type TUpdateProjectDTO = { + filter: Filter; + update: { + name?: string; + autoCapitalization?: boolean; + }; +} & Omit; + +export type TDeleteProjectDTO = { + filter: Filter; actor: ActorType; actorId: string; - projectId: string; + actorOrgId: string | undefined; +} & Omit; + +export type TUpgradeProjectDTO = { + userPrivateKey: string; +} & TProjectPermission; + +export type AddUserToWsDTO = { + decryptKey: TProjectKeys & { sender: { publicKey: string } }; + userPrivateKey: string; + members: { + orgMembershipId: string; + projectMembershipRole: ProjectMembershipRole; + userPublicKey: string; + }[]; }; diff --git a/backend/src/services/secret-blind-index/secret-blind-index-service.ts b/backend/src/services/secret-blind-index/secret-blind-index-service.ts index bb565e295..bf2728e95 100644 --- a/backend/src/services/secret-blind-index/secret-blind-index-service.ts +++ b/backend/src/services/secret-blind-index/secret-blind-index-service.ts @@ -24,16 +24,34 @@ export const secretBlindIndexServiceFactory = ({ permissionService, secretDAL }: TSecretBlindIndexServiceFactoryDep) => { - const getSecretBlindIndexStatus = async ({ actor, projectId, actorId }: TGetProjectBlindIndexStatusDTO) => { - await permissionService.getProjectPermission(actor, actorId, projectId); + const getSecretBlindIndexStatus = async ({ + actor, + projectId, + actorId, + actorAuthMethod, + actorOrgId + }: TGetProjectBlindIndexStatusDTO) => { + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); const secretCount = await secretBlindIndexDAL.countOfSecretsWithNullSecretBlindIndex(projectId); return Number(secretCount); }; - const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => { - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); - if (membership?.role !== ProjectMembershipRole.Admin) { + const getProjectSecrets = async ({ + projectId, + actorId, + actorAuthMethod, + actorOrgId, + actor + }: TGetProjectSecretsDTO) => { + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + if (!hasRole(ProjectMembershipRole.Admin)) { throw new UnauthorizedError({ message: "User must be admin" }); } @@ -45,10 +63,18 @@ export const secretBlindIndexServiceFactory = ({ projectId, actor, actorId, + actorAuthMethod, + actorOrgId, secretsToUpdate }: TUpdateProjectSecretNameDTO) => { - const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); - if (membership?.role !== ProjectMembershipRole.Admin) { + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + if (!hasRole(ProjectMembershipRole.Admin)) { throw new UnauthorizedError({ message: "User must be admin" }); } diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index 023d039ca..b3147d1fa 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -170,7 +170,8 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str // if the given folder id is root folder id then intial path is set as / instead of /root // if not root folder the path here will be / path: db.raw(`CONCAT('/', (CASE WHEN "parentId" is NULL THEN '' ELSE ${TableName.SecretFolder}.name END))`), - child: db.raw("NULL::uuid") + child: db.raw("NULL::uuid"), + environmentSlug: `${TableName.Environment}.slug` }) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .where({ projectId }) @@ -190,14 +191,15 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str ELSE CONCAT('/', secret_folders.name) END, parent.path )` ), - child: db.raw("COALESCE(parent.child, parent.id)") + child: db.raw("COALESCE(parent.child, parent.id)"), + environmentSlug: "parent.environmentSlug" }) .from(TableName.SecretFolder) .join("parent", "parent.parentId", `${TableName.SecretFolder}.id`) ); }) .select("*") - .from("parent"); + .from("parent"); export type TSecretFolderDALFactory = ReturnType; // never change this. If u do write a migration for it @@ -257,10 +259,12 @@ export const secretFolderDALFactory = (db: TDbClient) => { const findSecretPathByFolderIds = async (projectId: string, folderIds: string[], tx?: Knex) => { try { const folders = await sqlFindSecretPathByFolderId(tx || db, projectId, folderIds); + const rootFolders = groupBy( folders.filter(({ parentId }) => parentId === null), (i) => i.child || i.id // root condition then child and parent will null ); + return folderIds.map((folderId) => rootFolders[folderId]?.[0]); } catch (error) { throw new DatabaseError({ error, name: "Find by secret path" }); diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 082674485..da429d88a 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -8,9 +8,16 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; -import { TCreateFolderDTO, TDeleteFolderDTO, TGetFolderDTO, TUpdateFolderDTO } from "./secret-folder-types"; +import { + TCreateFolderDTO, + TDeleteFolderDTO, + TGetFolderDTO, + TUpdateFolderDTO, + TUpdateManyFoldersDTO +} from "./secret-folder-types"; import { TSecretFolderVersionDALFactory } from "./secret-folder-version-dal"; type TSecretFolderServiceFactoryDep = { @@ -19,6 +26,7 @@ type TSecretFolderServiceFactoryDep = { folderDAL: TSecretFolderDALFactory; projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; + projectDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -28,10 +36,26 @@ export const secretFolderServiceFactory = ({ snapshotService, permissionService, projectEnvDAL, - folderVersionDAL + folderVersionDAL, + projectDAL }: TSecretFolderServiceFactoryDep) => { - const createFolder = async ({ projectId, actor, actorId, name, environment, path: secretPath }: TCreateFolderDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const createFolder = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + name, + environment, + path: secretPath + }: TCreateFolderDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) @@ -101,16 +125,123 @@ export const secretFolderServiceFactory = ({ return folder; }; + const updateManyFolders = async ({ + actor, + actorId, + projectSlug, + actorAuthMethod, + actorOrgId, + folders + }: TUpdateManyFoldersDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + folders.forEach(({ environment, path: secretPath }) => { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + }); + + const result = await folderDAL.transaction(async (tx) => + Promise.all( + folders.map(async (newFolder) => { + const { environment, path: secretPath, id, name } = newFolder; + + const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + if (!parentFolder) { + throw new BadRequestError({ message: "Secret path not found", name: "Batch update folder" }); + } + + const env = await projectEnvDAL.findOne({ projectId: project.id, slug: environment }); + if (!env) { + throw new BadRequestError({ message: "Environment not found", name: "Batch update folder" }); + } + const folder = await folderDAL + .findOne({ envId: env.id, id, parentId: parentFolder.id }) + // now folder api accepts id based change + // this is for cli backward compatiability and when cli removes this, we will remove this logic + .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + if (name !== folder.name) { + // ensure that new folder name is unique + const folderToCheck = await folderDAL.findOne({ + name, + envId: env.id, + parentId: parentFolder.id + }); + + if (folderToCheck) { + throw new BadRequestError({ + message: "Folder with specified name already exists", + name: "Batch update folder" + }); + } + } + + const [doc] = await folderDAL.update( + { envId: env.id, id: folder.id, parentId: parentFolder.id }, + { name }, + tx + ); + await folderVersionDAL.create( + { + name: doc.name, + envId: doc.envId, + version: doc.version, + folderId: doc.id + }, + tx + ); + if (!doc) { + throw new BadRequestError({ message: "Folder not found", name: "Batch update folder" }); + } + + return { oldFolder: folder, newFolder: doc }; + }) + ) + ); + + await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string))); + + return { + projectId: project.id, + newFolders: result.map((res) => res.newFolder), + oldFolders: result.map((res) => res.oldFolder) + }; + }; + const updateFolder = async ({ projectId, actor, actorId, + actorOrgId, + actorAuthMethod, name, environment, path: secretPath, id }: TUpdateFolderDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) @@ -128,6 +259,21 @@ export const secretFolderServiceFactory = ({ .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); if (!folder) throw new BadRequestError({ message: "Folder not found" }); + if (name !== folder.name) { + // ensure that new folder name is unique + const folderToCheck = await folderDAL.findOne({ + name, + envId: env.id, + parentId: parentFolder.id + }); + + if (folderToCheck) { + throw new BadRequestError({ + message: "Folder with specified name already exists", + name: "Update folder" + }); + } + } const newFolder = await folderDAL.transaction(async (tx) => { const [doc] = await folderDAL.update({ envId: env.id, id: folder.id, parentId: parentFolder.id }, { name }, tx); @@ -148,8 +294,23 @@ export const secretFolderServiceFactory = ({ return { folder: newFolder, old: folder }; }; - const deleteFolder = async ({ projectId, actor, actorId, environment, path: secretPath, id }: TDeleteFolderDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteFolder = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + path: secretPath, + idOrName + }: TDeleteFolderDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) @@ -162,7 +323,10 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx); if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); - const [doc] = await folderDAL.delete({ envId: env.id, id, parentId: parentFolder.id }, tx); + const [doc] = await folderDAL.delete( + { envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id }, + tx + ); if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); return doc; }); @@ -171,10 +335,18 @@ export const secretFolderServiceFactory = ({ return folder; }; - const getFolders = async ({ projectId, actor, actorId, environment, path: secretPath }: TGetFolderDTO) => { + const getFolders = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + path: secretPath + }: TGetFolderDTO) => { // folder list is allowed to be read by anyone // permission to check does user has access - await permissionService.getProjectPermission(actor, actorId, projectId); + await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); const env = await projectEnvDAL.findOne({ projectId, slug: environment }); if (!env) throw new BadRequestError({ message: "Environment not found", name: "get folders" }); @@ -183,12 +355,14 @@ export const secretFolderServiceFactory = ({ if (!parentFolder) return []; const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id }); + return folders; }; return { createFolder, updateFolder, + updateManyFolders, deleteFolder, getFolders }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 7a68434f5..1405f8bd7 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -13,10 +13,20 @@ export type TUpdateFolderDTO = { name: string; } & TProjectPermission; +export type TUpdateManyFoldersDTO = { + projectSlug: string; + folders: { + environment: string; + path: string; + id: string; + name: string; + }[]; +} & Omit; + export type TDeleteFolderDTO = { environment: string; path: string; - id: string; + idOrName: string; } & TProjectPermission; export type TGetFolderDTO = { diff --git a/backend/src/services/secret-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index f9c6f1be7..aa45d410d 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -49,7 +49,7 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; - const find = async (filter: Partial, tx?: Knex) => { + const find = async (filter: Partial, tx?: Knex) => { try { const docs = await (tx || db)(TableName.SecretImport) .where(filter) @@ -70,9 +70,31 @@ export const secretImportDALFactory = (db: TDbClient) => { } }; + const findByFolderIds = async (folderIds: string[], tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.SecretImport) + .whereIn("folderId", folderIds) + .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) + .select( + db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, + db.ref("slug").withSchema(TableName.Environment), + db.ref("name").withSchema(TableName.Environment), + db.ref("id").withSchema(TableName.Environment).as("envId") + ) + .orderBy("position", "asc"); + return docs.map(({ envId, slug, name, ...el }) => ({ + ...el, + importEnv: { id: envId, slug, name } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find secret imports" }); + } + }; + return { ...secretImportOrm, find, + findByFolderIds, findLastImportPosition, updateAllPosition }; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 1fa55f214..fffc22a99 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -1,33 +1,66 @@ -import { SecretType, TSecretImports } from "@app/db/schemas"; +import { SecretType, TSecretImports, TSecrets } from "@app/db/schemas"; import { groupBy } from "@app/lib/fn"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretImportDALFactory } from "./secret-import-dal"; +type TSecretImportSecrets = { + secretPath: string; + environment: string; + environmentInfo: { + id: string; + slug: string; + name: string; + }; + folderId: string | undefined; + importFolderId: string; + secrets: (TSecrets & { workspace: string; environment: string; _id: string })[]; +}; + +const LEVEL_BREAK = 10; +const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`; export const fnSecretsFromImports = async ({ - allowedImports, + allowedImports: possibleCyclicImports, folderDAL, - secretDAL + secretDAL, + secretImportDAL, + depth = 0, + cyclicDetector = new Set() }: { allowedImports: (Omit & { importEnv: { id: string; slug: string; name: string }; })[]; folderDAL: Pick; secretDAL: Pick; + secretImportDAL: Pick; + depth?: number; + cyclicDetector?: Set; }) => { - const importedFolders = await folderDAL.findByManySecretPath( - allowedImports.map(({ importEnv, importPath }) => ({ - envId: importEnv.id, - secretPath: importPath - })) + // avoid going more than a depth + if (depth >= LEVEL_BREAK) return []; + + const allowedImports = possibleCyclicImports.filter( + ({ importPath, importEnv }) => !cyclicDetector.has(getImportUniqKey(importEnv.slug, importPath)) ); - const folderIds = importedFolders.map((el) => el?.id).filter(Boolean) as string[]; - if (!folderIds.length) { + + const importedFolders = ( + await folderDAL.findByManySecretPath( + allowedImports.map(({ importEnv, importPath }) => ({ + envId: importEnv.id, + secretPath: importPath + })) + ) + ).filter(Boolean); // remove undefined ones + if (!importedFolders.length) { return []; } + + const importedFolderIds = importedFolders.map((el) => el?.id) as string[]; + const importedFolderGroupBySourceImport = groupBy(importedFolders, (i) => `${i?.envId}-${i?.path}`); const importedSecrets = await secretDAL.find( { - $in: { folderId: folderIds }, + $in: { folderId: importedFolderIds }, type: SecretType.Shared }, { @@ -35,19 +68,50 @@ export const fnSecretsFromImports = async ({ } ); - const importedSecsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); - return allowedImports.map(({ importPath, importEnv }, i) => ({ - secretPath: importPath, - environment: importEnv.slug, - environmentInfo: importEnv, - folderId: importedFolders?.[i]?.id, - secrets: importedFolders?.[i]?.id - ? importedSecsGroupByFolderId[importedFolders?.[i]?.id as string].map((item) => ({ + const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); + + allowedImports.forEach(({ importPath, importEnv }) => { + cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); + }); + // now we need to check recursively deeper imports made inside other imports + // we go level wise meaning we take all imports of a tree level and then go deeper ones level by level + const deeperImports = await secretImportDAL.findByFolderIds(importedFolderIds); + let secretsFromDeeperImports: TSecretImportSecrets[] = []; + if (deeperImports.length) { + secretsFromDeeperImports = await fnSecretsFromImports({ + allowedImports: deeperImports, + secretImportDAL, + folderDAL, + secretDAL, + depth: depth + 1, + cyclicDetector + }); + } + const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId); + + const secrets = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => { + const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0]; + const folderDeeperImportSecrets = + secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || []; + + return { + secretPath: importPath, + environment: importEnv.slug, + environmentInfo: importEnv, + folderId: importedFolders?.[i]?.id, + id, + importFolderId: folderId, + // this will ensure for cases when secrets are empty. Could be due to missing folder for a path or when emtpy secrets inside a given path + secrets: (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) + .map((item) => ({ ...item, environment: importEnv.slug, workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend. _id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend. })) - : [] - })); + .concat(folderDeeperImportSecrets) + }; + }); + + return secrets; }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 57142424f..43676ba04 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -4,8 +4,10 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; +import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "./secret-import-dal"; import { fnSecretsFromImports } from "./secret-import-fns"; @@ -21,8 +23,10 @@ type TSecretImportServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; folderDAL: TSecretFolderDALFactory; secretDAL: Pick; + projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; permissionService: Pick; + secretQueueService: Pick; }; const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" }); @@ -34,10 +38,27 @@ export const secretImportServiceFactory = ({ projectEnvDAL, permissionService, folderDAL, - secretDAL + projectDAL, + secretDAL, + secretQueueService }: TSecretImportServiceFactoryDep) => { - const createImport = async ({ environment, data, actor, actorId, projectId, path }: TCreateSecretImportDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const createImport = async ({ + environment, + data, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + path + }: TCreateSecretImportDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); // check if user has permission to import into destination path ForbiddenError.from(permission).throwUnlessCan( @@ -54,13 +75,24 @@ export const secretImportServiceFactory = ({ }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" }); - // TODO(akhilmhdh-pg): updated permission check add here const [importEnv] = await projectEnvDAL.findBySlugs(projectId, [data.environment]); if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); + const sourceFolder = await folderDAL.findBySecretPath(projectId, data.environment, data.path); + if (sourceFolder) { + const existingImport = await secretImportDAL.findOne({ + folderId: sourceFolder.id, + importEnv: folder.environment.id, + importPath: path + }); + if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); + } + const secImport = await secretImportDAL.transaction(async (tx) => { const lastPos = await secretImportDAL.findLastImportPosition(folder.id, tx); return secretImportDAL.create( @@ -74,11 +106,33 @@ export const secretImportServiceFactory = ({ ); }); + await secretQueueService.syncSecrets({ + secretPath: secImport.importPath, + projectId, + environment: importEnv.slug + }); + return { ...secImport, importEnv }; }; - const updateImport = async ({ path, environment, projectId, actor, actorId, data, id }: TUpdateSecretImportDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const updateImport = async ({ + path, + environment, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + data, + id + }: TUpdateSecretImportDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -95,6 +149,20 @@ export const secretImportServiceFactory = ({ : await projectEnvDAL.findById(secImpDoc.importEnv); if (!importedEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); + const sourceFolder = await folderDAL.findBySecretPath( + projectId, + importedEnv.slug, + data.path || secImpDoc.importPath + ); + if (sourceFolder) { + const existingImport = await secretImportDAL.findOne({ + folderId: sourceFolder.id, + importEnv: folder.environment.id, + importPath: path + }); + if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); + } + const updatedSecImport = await secretImportDAL.transaction(async (tx) => { const secImp = await secretImportDAL.findOne({ folderId: folder.id, id }); if (!secImp) throw ERR_SEC_IMP_NOT_FOUND; @@ -115,8 +183,23 @@ export const secretImportServiceFactory = ({ return { ...updatedSecImport, importEnv: importedEnv }; }; - const deleteImport = async ({ path, environment, projectId, actor, actorId, id }: TDeleteSecretImportDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteImport = async ({ + path, + environment, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + id + }: TDeleteSecretImportDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -134,11 +217,32 @@ export const secretImportServiceFactory = ({ if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); return { ...doc, importEnv }; }); + + await secretQueueService.syncSecrets({ + secretPath: path, + projectId, + environment + }); + return secImport; }; - const getImports = async ({ path, environment, projectId, actor, actorId }: TGetSecretImportsDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getImports = async ({ + path, + environment, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSecretImportsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -151,8 +255,22 @@ export const secretImportServiceFactory = ({ return secImports; }; - const getSecretsFromImports = async ({ path, environment, projectId, actor, actorId }: TGetSecretsFromImportDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getSecretsFromImports = async ({ + path, + environment, + projectId, + actor, + actorAuthMethod, + actorId, + actorOrgId + }: TGetSecretsFromImportDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) @@ -172,7 +290,7 @@ export const secretImportServiceFactory = ({ }) ) ); - return fnSecretsFromImports({ allowedImports, folderDAL, secretDAL }); + return fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL }); }; return { diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index 361f2d009..ed8f5fec7 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -15,11 +15,26 @@ type TSecretTagServiceFactoryDep = { export type TSecretTagServiceFactory = ReturnType; export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSecretTagServiceFactoryDep) => { - const createTag = async ({ name, slug, actor, color, actorId, projectId }: TCreateTagDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const createTag = async ({ + name, + slug, + actor, + color, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }: TCreateTagDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); - const existingTag = await secretTagDAL.findOne({ slug }); + const existingTag = await secretTagDAL.findOne({ slug, projectId }); if (existingTag) throw new BadRequestError({ message: "Tag already exist" }); const newTag = await secretTagDAL.create({ @@ -32,19 +47,31 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe return newTag; }; - const deleteTag = async ({ actorId, actor, id }: TDeleteTagDTO) => { + const deleteTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteTagDTO) => { const tag = await secretTagDAL.findById(id); if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, tag.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Tags); const deletedTag = await secretTagDAL.deleteById(tag.id); return deletedTag; }; - const getProjectTags = async ({ actor, actorId, projectId }: TListProjectTagsDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getProjectTags = async ({ actor, actorId, actorOrgId, actorAuthMethod, projectId }: TListProjectTagsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); const tags = await secretTagDAL.find({ projectId }, { sort: [["createdAt", "asc"]] }); diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index ba65033cb..1a2e414dd 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -22,7 +22,11 @@ export const secretDALFactory = (db: TDbClient) => { // the idea is to use postgres specific function // insert with id this will cause a conflict then merge the data - const bulkUpdate = async (data: Array<{ filter: Partial; data: TSecretsUpdate }>, tx?: Knex) => { + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TSecretsUpdate }>, + + tx?: Knex + ) => { try { const secs = await Promise.all( data.map(async ({ filter, data: updateData }) => { @@ -41,6 +45,35 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const bulkUpdateNoVersionIncrement = async (data: TSecrets[], tx?: Knex) => { + try { + const existingSecrets = await secretOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingSecrets.length !== data.length) { + throw new BadRequestError({ message: "Some of the secrets do not exist" }); + } + + if (data.length === 0) return []; + + const updatedSecrets = await (tx || db)(TableName.Secret) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedSecrets; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + const deleteMany = async ( data: Array<{ blindIndex: string; type: SecretType }>, folderId: string, @@ -57,6 +90,12 @@ export const secretDALFactory = (db: TDbClient) => { type: el.type, ...(el.type === SecretType.Personal ? { userId } : {}) }); + if (el.type === SecretType.Shared) { + void bd.orWhere({ + secretBlindIndex: el.blindIndex, + type: SecretType.Personal + }); + } }); }) .delete() @@ -111,6 +150,71 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const getSecretTags = async (secretId: string, tx?: Knex) => { + try { + const tags = await (tx || db)(TableName.JnSecretTag) + .join(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .where({ [`${TableName.Secret}Id` as const]: secretId }) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")); + + return tags.map((el) => ({ + id: el.tagId, + color: el.tagColor, + slug: el.tagSlug, + name: el.tagName + })); + } catch (error) { + throw new DatabaseError({ error, name: "get secret tags" }); + } + }; + + const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => { + try { + // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) + if (userId && !uuidValidate(userId)) { + // eslint-disable-next-line no-param-reassign + userId = undefined; + } + + const secs = await (tx || db)(TableName.Secret) + .whereIn("folderId", folderIds) + .where((bd) => { + void bd.whereNull("userId").orWhere({ userId: userId || null }); + }) + .leftJoin(TableName.JnSecretTag, `${TableName.Secret}.id`, `${TableName.JnSecretTag}.${TableName.Secret}Id`) + .leftJoin(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .select(selectAllTableCols(TableName.Secret)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")) + .orderBy("id", "asc"); + const data = sqlNestRelationships({ + data: secs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsSchema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + id, + color, + slug, + name + }) + } + ] + }); + return data; + } catch (error) { + throw new DatabaseError({ error, name: "get all secret" }); + } + }; + const findByBlindIndexes = async ( folderId: string, blindIndexes: Array<{ blindIndex: string; type: SecretType }>, @@ -139,5 +243,86 @@ export const secretDALFactory = (db: TDbClient) => { } }; - return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes }; + const upsertSecretReferences = async ( + data: { + secretId: string; + references: Array<{ environment: string; secretPath: string }>; + }[] = [], + tx?: Knex + ) => { + try { + if (!data.length) return; + + await (tx || db)(TableName.SecretReference) + .whereIn( + "secretId", + data.map(({ secretId }) => secretId) + ) + .delete(); + const newSecretReferences = data + .filter(({ references }) => references.length) + .flatMap(({ secretId, references }) => + references.map(({ environment, secretPath }) => ({ + secretPath, + secretId, + environment + })) + ); + if (!newSecretReferences.length) return; + const secretReferences = await (tx || db)(TableName.SecretReference).insert(newSecretReferences); + return secretReferences; + } catch (error) { + throw new DatabaseError({ error, name: "UpsertSecretReference" }); + } + }; + + const findReferencedSecretReferences = async (projectId: string, envSlug: string, secretPath: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.SecretReference) + .where({ + secretPath, + environment: envSlug + }) + .join(TableName.Secret, `${TableName.Secret}.id`, `${TableName.SecretReference}.secretId`) + .join(TableName.SecretFolder, `${TableName.Secret}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where("projectId", projectId) + .select(selectAllTableCols(TableName.SecretReference)) + .select("folderId"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindReferencedSecretReferences" }); + } + }; + + // special query to backfill secret value + const findAllProjectSecretValues = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.Secret) + .join(TableName.SecretFolder, `${TableName.Secret}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where("projectId", projectId) + // not empty + .whereNotNull("secretValueCiphertext") + .select("secretValueTag", "secretValueCiphertext", "secretValueIV", `${TableName.Secret}.id` as "id"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindAllProjectSecretValues" }); + } + }; + + return { + ...secretOrm, + update, + bulkUpdate, + deleteMany, + bulkUpdateNoVersionIncrement, + getSecretTags, + findByFolderId, + findByFolderIds, + findByBlindIndexes, + upsertSecretReferences, + findReferencedSecretReferences, + findAllProjectSecretValues + }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 0f6caa248..51ad7a6aa 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -1,12 +1,42 @@ /* eslint-disable no-await-in-loop */ +import { subject } from "@casl/ability"; import path from "path"; -import { SecretKeyEncoding, TSecretBlindIndexes, TSecrets } from "@app/db/schemas"; +import { + SecretEncryptionAlgo, + SecretKeyEncoding, + SecretType, + TableName, + TSecretBlindIndexes, + TSecretFolders, + TSecrets +} from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; -import { buildSecretBlindIndexFromName, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { + buildSecretBlindIndexFromName, + decryptSymmetric128BitHexKeyUTF8, + encryptSymmetric128BitHexKeyUTF8 +} from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { getBotKeyFnFactory } from "../project-bot/project-bot-fns"; +import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretDALFactory } from "./secret-dal"; +import { + TCreateManySecretsRawFn, + TCreateManySecretsRawFnFactory, + TFnSecretBlindIndexCheck, + TFnSecretBulkInsert, + TFnSecretBulkUpdate, + TUpdateManySecretsRawFn, + TUpdateManySecretsRawFnFactory +} from "./secret-types"; export const generateSecretBlindIndexBySalt = async (secretName: string, secretBlindIndexDoc: TSecretBlindIndexes) => { const appCfg = getConfig(); @@ -22,6 +52,141 @@ export const generateSecretBlindIndexBySalt = async (secretName: string, secretB return secretBlindIndex; }; +type TRecursivelyFetchSecretsFromFoldersArg = { + permissionService: Pick; + folderDAL: Pick; + projectEnvDAL: Pick; +}; + +type TGetPathsDTO = { + projectId: string; + environment: string; + currentPath: string; + + auth: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }; +}; + +// Introduce a new interface for mapping parent IDs to their children +interface FolderMap { + [parentId: string]: TSecretFolders[]; +} +const buildHierarchy = (folders: TSecretFolders[]): FolderMap => { + const map: FolderMap = {}; + map.null = []; // Initialize mapping for root directory + + folders.forEach((folder) => { + const parentId = folder.parentId || "null"; + if (!map[parentId]) { + map[parentId] = []; + } + map[parentId].push(folder); + }); + + return map; +}; + +const generatePaths = ( + map: FolderMap, + parentId: string = "null", + basePath: string = "", + currentDepth: number = 0 +): { path: string; folderId: string }[] => { + const children = map[parentId || "null"] || []; + let paths: { path: string; folderId: string }[] = []; + + children.forEach((child) => { + // Determine if this is the root folder of the environment. If no parentId is present and the name is root, it's the root folder + const isRootFolder = child.name === "root" && !child.parentId; + + // Form the current path based on the base path and the current child + // eslint-disable-next-line no-nested-ternary + const currPath = basePath === "" ? (isRootFolder ? "/" : `/${child.name}`) : `${basePath}/${child.name}`; + + // Add the current path + paths.push({ + path: currPath, + folderId: child.id + }); + + // We make sure that the recursion depth doesn't exceed 20. + // We do this to create "circuit break", basically to ensure that we can't encounter any potential memory leaks. + if (currentDepth >= 20) { + logger.info(`generatePaths: Recursion depth exceeded 20, breaking out of recursion [map=${JSON.stringify(map)}]`); + return; + } + // Recursively generate paths for children, passing down the formatted path + const childPaths = generatePaths(map, child.id, currPath, currentDepth + 1); + paths = paths.concat( + childPaths.map((p) => ({ + path: p.path, + folderId: p.folderId + })) + ); + }); + + return paths; +}; + +export const recursivelyGetSecretPaths = ({ + folderDAL, + projectEnvDAL, + permissionService +}: TRecursivelyFetchSecretsFromFoldersArg) => { + const getPaths = async ({ projectId, environment, currentPath, auth }: TGetPathsDTO) => { + const env = await projectEnvDAL.findOne({ + projectId, + slug: environment + }); + + if (!env) { + throw new Error(`'${environment}' environment not found in project with ID ${projectId}`); + } + + // Fetch all folders in env once with a single query + const folders = await folderDAL.find({ + envId: env.id + }); + + // Build the folder hierarchy map + const folderMap = buildHierarchy(folders); + + // Generate the paths paths and normalize the root path to / + const paths = generatePaths(folderMap).map((p) => ({ + path: p.path === "/" ? p.path : p.path.substring(1), + folderId: p.folderId + })); + + const { permission } = await permissionService.getProjectPermission( + auth.actor, + auth.actorId, + projectId, + auth.actorAuthMethod, + auth.actorOrgId + ); + + // Filter out paths that the user does not have permission to access, and paths that are not in the current path + const allowedPaths = paths.filter( + (folder) => + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath: folder.path + }) + ) && folder.path.startsWith(currentPath === "/" ? "" : currentPath) + ); + + return allowedPaths; + }; + + return getPaths; +}; + type TInterpolateSecretArg = { projectId: string; secretEncKey: string; @@ -29,6 +194,7 @@ type TInterpolateSecretArg = { folderDAL: Pick; }; +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderDAL }: TInterpolateSecretArg) => { const fetchSecretsCrossEnv = () => { const fetchCache: Record> = {}; @@ -70,7 +236,6 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; }; - const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; const recursivelyExpandSecret = async ( expandedSec: Record, interpolatedSec: Record, @@ -179,9 +344,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD ); // eslint-disable-next-line - secrets[key].value = secrets[key].skipMultilineEncoding - ? expandedVal - : formatMultiValueEnv(expandedVal); + secrets[key].value = secrets[key].skipMultilineEncoding ? expandedVal : formatMultiValueEnv(expandedVal); } return secrets; @@ -189,7 +352,10 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD return expandSecrets; }; -export const decryptSecretRaw = (secret: TSecrets & { workspace: string; environment: string }, key: string) => { +export const decryptSecretRaw = ( + secret: TSecrets & { workspace: string; environment: string; secretPath: string }, + key: string +) => { const secretKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secret.secretKeyCiphertext, iv: secret.secretKeyIV, @@ -217,6 +383,7 @@ export const decryptSecretRaw = (secret: TSecrets & { workspace: string; environ return { secretKey, + secretPath: secret.secretPath, workspace: secret.workspace, environment: secret.environment, secretValue, @@ -228,3 +395,422 @@ export const decryptSecretRaw = (secret: TSecrets & { workspace: string; environ user: secret.userId }; }; + +/** + * Grabs and processes nested secret references from a string + * + * This function looks for patterns that match the interpolation syntax in the input string. + * It filters out references that include nested paths, splits them into environment and + * secret path parts, and then returns an array of objects with the environment and the + * joined secret path. + * + * @param {string} maybeSecretReference - The string that has the potential secret references. + * @returns {Array<{ environment: string, secretPath: string }>} - An array of objects + * with the environment and joined secret path. + * + * @example + * const value = "Hello ${dev.someFolder.OtherFolder.SECRET_NAME} and ${prod.anotherFolder.SECRET_NAME}"; + * const result = getAllNestedSecretReferences(value); + * // result will be: + * // [ + * // { environment: 'dev', secretPath: '/someFolder/OtherFolder' }, + * // { environment: 'prod', secretPath: '/anotherFolder' } + * // ] + */ +export const getAllNestedSecretReferences = (maybeSecretReference: string) => { + const references = Array.from(maybeSecretReference.matchAll(INTERPOLATION_SYNTAX_REG), (m) => m[1]); + return references + .filter((el) => el.includes(".")) + .map((el) => { + const [environment, ...secretPathList] = el.split("."); + return { environment, secretPath: path.join("/", ...secretPathList.slice(0, -1)) }; + }); +}; + +/** + * Checks and handles secrets using a blind index method. + * The function generates mappings between secret names and their blind indexes, validates user IDs for personal secrets, and retrieves secrets from the database based on their blind indexes. + * For new secrets (isNew = true), it ensures they don't already exist in the database. + * For existing secrets, it verifies their presence in the database. + * If discrepancies are found, errors are thrown. The function returns mappings and the fetched secrets. + */ +export const fnSecretBlindIndexCheck = async ({ + inputSecrets, + folderId, + isNew, + userId, + blindIndexCfg, + secretDAL +}: TFnSecretBlindIndexCheck) => { + const blindIndex2KeyName: Record = {}; // used at audit log point + const keyName2BlindIndex = await Promise.all( + inputSecrets.map(({ secretName }) => generateSecretBlindIndexBySalt(secretName, blindIndexCfg)) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[inputSecrets[i].secretName] = curr; + blindIndex2KeyName[curr] = inputSecrets[i].secretName; + return prev; + }, {}) + ); + + if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { + throw new BadRequestError({ message: "Missing user id for personal secret" }); + } + + const secrets = await secretDAL.findByBlindIndexes( + folderId, + inputSecrets.map(({ secretName, type }) => ({ + blindIndex: keyName2BlindIndex[secretName], + type: type || SecretType.Shared + })), + userId + ); + + if (isNew) { + if (secrets.length) throw new BadRequestError({ message: "Secret already exist" }); + } else { + const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map( + (el) => blindIndex2KeyName[el.secretBlindIndex as string] + ); + const hasUnknownSecretsProvided = secretKeysInDB.length !== inputSecrets.length; + if (hasUnknownSecretsProvided) { + const keysMissingInDB = Object.keys(keyName2BlindIndex).filter((key) => !secretKeysInDB.includes(key)); + throw new BadRequestError({ + message: `Secret not found: blind index ${keysMissingInDB.join(",")}` + }); + } + } + + return { blindIndex2KeyName, keyName2BlindIndex, secrets }; +}; + +// these functions are special functions shared by a couple of resources +// used by secret approval, rotation or anywhere in which secret needs to modified +export const fnSecretBulkInsert = async ({ + // TODO: Pick types here + folderId, + inputSecrets, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx +}: TFnSecretBulkInsert) => { + const newSecrets = await secretDAL.insertMany( + inputSecrets.map(({ tags, references, ...el }) => ({ ...el, folderId })), + tx + ); + const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string); + const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) => + secretTags.map((tag) => ({ + [`${TableName.SecretTag}Id` as const]: tag, + [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id + })) + ); + const secretVersions = await secretVersionDAL.insertMany( + inputSecrets.map(({ tags, references, ...el }) => ({ + ...el, + folderId, + secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id + })), + tx + ); + await secretDAL.upsertSecretReferences( + inputSecrets.map(({ references = [], secretBlindIndex }) => ({ + secretId: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id, + references + })), + tx + ); + if (newSecretTags.length) { + const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); + const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ + [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, + [`${TableName.SecretTag}Id` as const]: secret_tagsId + })); + await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); + } + + return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); +}; + +export const fnSecretBulkUpdate = async ({ + tx, + inputSecrets, + folderId, + projectId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL +}: TFnSecretBulkUpdate) => { + const newSecrets = await secretDAL.bulkUpdate( + inputSecrets.map(({ filter, data: { tags, references, ...data } }) => ({ + filter: { ...filter, folderId }, + data + })), + tx + ); + const secretVersions = await secretVersionDAL.insertMany( + newSecrets.map(({ id, createdAt, updatedAt, ...el }) => ({ + ...el, + secretId: id + })), + tx + ); + await secretDAL.upsertSecretReferences( + inputSecrets + .filter(({ data: { references } }) => Boolean(references)) + .map(({ data: { references = [] } }, i) => ({ + secretId: newSecrets[i].id, + references + })), + tx + ); + const secsUpdatedTag = inputSecrets.flatMap(({ data: { tags } }, i) => + tags !== undefined ? { tags, secretId: newSecrets[i].id } : [] + ); + if (secsUpdatedTag.length) { + await secretTagDAL.deleteTagsManySecret( + projectId, + secsUpdatedTag.map(({ secretId }) => secretId), + tx + ); + const newSecretTags = secsUpdatedTag.flatMap(({ tags: secretTags = [], secretId }) => + secretTags.map((tag) => ({ + [`${TableName.SecretTag}Id` as const]: tag, + [`${TableName.Secret}Id` as const]: secretId + })) + ); + if (newSecretTags.length) { + const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); + const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ + [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, + [`${TableName.SecretTag}Id` as const]: secret_tagsId + })); + await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); + } + } + + return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); +}; + +export const createManySecretsRawFnFactory = ({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL +}: TCreateManySecretsRawFnFactory) => { + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); + const createManySecretsRawFn = async ({ + projectId, + environment, + path: secretPath, + secrets, + userId + }: TCreateManySecretsRawFn) => { + const botKey = await getBotKeyFn(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); + const folderId = folder.id; + + const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); + if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Create secret" }); + + // insert operation + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: secrets, + folderId, + isNew: true, + blindIndexCfg, + userId, + secretDAL + }); + + const inputSecrets = secrets.map((secret) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretReferences = getAllNestedSecretReferences(secret.secretValue || ""); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + + return { + type: secret.type, + userId: secret.type === SecretType.Personal ? userId : null, + secretName: secret.secretName, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding: secret.skipMultilineEncoding, + tags: secret.tags, + references: secretReferences + }; + }); + + // get all tags + const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); + const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; + if (tags.length !== tagIds.length) throw new BadRequestError({ message: "Tag not found" }); + + const newSecrets = await secretDAL.transaction(async (tx) => + fnSecretBulkInsert({ + inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({ + ...el, + version: 0, + secretBlindIndex: keyName2BlindIndex[secretName], + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + })), + folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + return newSecrets; + }; + + return createManySecretsRawFn; +}; + +export const updateManySecretsRawFnFactory = ({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL +}: TUpdateManySecretsRawFnFactory) => { + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); + const updateManySecretsRawFn = async ({ + projectId, + environment, + path: secretPath, + secrets, // consider accepting instead ciphertext secrets + userId + }: TUpdateManySecretsRawFn): Promise> => { + const botKey = await getBotKeyFn(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Update secret" + }); + const folderId = folder.id; + + const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); + if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: secrets, + folderId, + isNew: false, + blindIndexCfg, + secretDAL, + userId + }); + + const inputSecrets = secrets.map((secret) => { + if (secret.newSecretName === "") { + throw new BadRequestError({ message: "New secret name cannot be empty" }); + } + + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretReferences = getAllNestedSecretReferences(secret.secretValue || ""); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + + return { + type: secret.type, + userId: secret.type === SecretType.Personal ? userId : null, + secretName: secret.secretName, + newSecretName: secret.newSecretName, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding: secret.skipMultilineEncoding, + tags: secret.tags, + references: secretReferences + }; + }); + + const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); + const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; + if (tagIds.length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + // now find any secret that needs to update its name + // same process as above + const nameUpdatedSecrets = inputSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); + const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: nameUpdatedSecrets, + folderId, + isNew: true, + blindIndexCfg, + secretDAL + }); + + const updatedSecrets = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId, + projectId, + tx, + inputSecrets: inputSecrets.map(({ secretName, newSecretName, ...el }) => ({ + filter: { secretBlindIndex: keyName2BlindIndex[secretName], type: SecretType.Shared }, + data: { + ...el, + folderId, + secretBlindIndex: + newSecretName && newKeyName2BlindIndex[newSecretName] + ? newKeyName2BlindIndex[newSecretName] + : keyName2BlindIndex[secretName], + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + } + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL + }) + ); + + return updatedSecrets; + }; + + return updateManySecretsRawFn; +}; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index b797b7caf..f3e3f1731 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -3,9 +3,15 @@ import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; import { BadRequestError } from "@app/lib/errors"; -import { isSamePath } from "@app/lib/fn"; +import { groupBy, isSamePath, unique } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@app/services/secret/secret-fns"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; @@ -17,7 +23,6 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; -import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; @@ -26,21 +31,25 @@ import { interpolateSecrets } from "./secret-fns"; import { TCreateSecretReminderDTO, THandleReminderDTO, TRemoveSecretReminderDTO } from "./secret-types"; export type TSecretQueueFactory = ReturnType; - type TSecretQueueFactoryDep = { queueService: TQueueServiceFactory; - integrationDAL: Pick; + integrationDAL: Pick; projectBotService: Pick; integrationAuthService: Pick; - folderDAL: Pick; - secretDAL: Pick; + folderDAL: TSecretFolderDALFactory; + secretDAL: TSecretDALFactory; secretImportDAL: Pick; webhookDAL: Pick; projectEnvDAL: Pick; - projectDAL: Pick; + projectDAL: TProjectDALFactory; + projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: Pick; smtpService: TSmtpService; orgDAL: Pick; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; }; export type TGetSecrets = { @@ -49,6 +58,9 @@ export type TGetSecrets = { environment: string; }; +const MAX_SYNC_SECRET_DEPTH = 5; +const uniqueIntegrationKey = (environment: string, secretPath: string) => `integration-${environment}-${secretPath}`; + export const secretQueueFactory = ({ queueService, integrationDAL, @@ -62,27 +74,64 @@ export const secretQueueFactory = ({ orgDAL, smtpService, projectDAL, - projectMembershipDAL + projectBotDAL, + projectMembershipDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL }: TSecretQueueFactoryDep) => { - const syncIntegrations = async (dto: TGetSecrets) => { + const createManySecretsRawFn = createManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); + + const updateManySecretsRawFn = updateManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); + + const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record }) => { await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { - attempts: 5, + attempts: 3, delay: 1000, backoff: { type: "exponential", delay: 3000 }, removeOnComplete: true, - removeOnFail: { - count: 5 // keep the most recent jobs - } + removeOnFail: true }); }; - const syncSecrets = async (dto: TGetSecrets) => { + const syncSecrets = async ({ + deDupeQueue = {}, + ...dto + }: TGetSecrets & { depth?: number; deDupeQueue?: Record }) => { + const deDuplicationKey = uniqueIntegrationKey(dto.environment, dto.secretPath); + if (deDupeQueue?.[deDuplicationKey]) { + return; + } + // eslint-disable-next-line + deDupeQueue[deDuplicationKey] = true; + logger.info( + `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environment}] [path=${dto.secretPath}]` + ); await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, dto, { jobId: `secret-webhook-${dto.environment}-${dto.projectId}-${dto.secretPath}`, - removeOnFail: { count: 5 }, + removeOnFail: true, removeOnComplete: true, delay: 1000, attempts: 5, @@ -91,7 +140,7 @@ export const secretQueueFactory = ({ delay: 3000 } }); - await syncIntegrations(dto); + await syncIntegrations({ ...dto, deDupeQueue }); }; const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { @@ -189,60 +238,42 @@ export const secretQueueFactory = ({ } }; - const getIntegrationSecrets = async (dto: TGetSecrets & { folderId: string }, key: string) => { + type Content = Record; + + /** + * Return the secrets in a given [folderId] including secrets from + * nested imported folders recursively. + */ + const getIntegrationSecrets = async (dto: { + projectId: string; + environment: string; + folderId: string; + key: string; + depth: number; + }) => { + let content: Content = {}; + if (dto.depth > MAX_SYNC_SECRET_DEPTH) { + logger.info( + `getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]` + ); + return content; + } + + // process secrets in current folder const secrets = await secretDAL.findByFolderId(dto.folderId); - if (!secrets.length) return {}; - - // get imported secrets - const secretImport = await secretImportDAL.find({ folderId: dto.folderId }); - const importedSecrets = await fnSecretsFromImports({ - allowedImports: secretImport, - secretDAL, - folderDAL - }); - const content: Record = {}; - - importedSecrets.forEach(({ secrets: secs }) => { - secs.forEach((secret) => { - const secretKey = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretKeyCiphertext, - iv: secret.secretKeyIV, - tag: secret.secretKeyTag, - key - }); - const secretValue = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key - }); - content[secretKey] = { value: secretValue }; - content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); - - if (secret.secretCommentCiphertext && secret.secretCommentIV && secret.secretCommentTag) { - const commentValue = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretCommentCiphertext, - iv: secret.secretCommentIV, - tag: secret.secretCommentTag, - key - }); - content[secretKey].comment = commentValue; - } - }); - }); secrets.forEach((secret) => { const secretKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secret.secretKeyCiphertext, iv: secret.secretKeyIV, tag: secret.secretKeyTag, - key + key: dto.key }); const secretValue = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secret.secretValueCiphertext, iv: secret.secretValueIV, tag: secret.secretValueTag, - key + key: dto.key }); content[secretKey] = { value: secretValue }; @@ -252,38 +283,158 @@ export const secretQueueFactory = ({ ciphertext: secret.secretCommentCiphertext, iv: secret.secretCommentIV, tag: secret.secretCommentTag, - key + key: dto.key }); content[secretKey].comment = commentValue; } content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); }); + const expandSecrets = interpolateSecrets({ projectId: dto.projectId, - secretEncKey: key, + secretEncKey: dto.key, folderDAL, secretDAL }); + await expandSecrets(content); + + // check if current folder has any imports from other folders + const secretImport = await secretImportDAL.find({ folderId: dto.folderId }); + + // if no imports then return secrets in the current folder + if (!secretImport) return content; + + const importedFolders = await folderDAL.findByManySecretPath( + secretImport.map(({ importEnv, importPath }) => ({ + envId: importEnv.id, + secretPath: importPath + })) + ); + + for await (const folder of importedFolders) { + if (folder) { + // get secrets contained in each imported folder by recursively calling + // this function against the imported folder + const importedSecrets = await getIntegrationSecrets({ + environment: dto.environment, + projectId: dto.projectId, + folderId: folder.id, + key: dto.key, + depth: dto.depth + 1 + }); + + // add the imported secrets to the current folder secrets + content = { ...importedSecrets, ...content }; + } + } + return content; }; queueService.start(QueueName.IntegrationSync, async (job) => { - const { environment, projectId, secretPath } = job.data; + const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) { - logger.error("Secret path not found"); + logger.error(new Error("Secret path not found")); return; } - const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); + // start syncing all linked imports also + if (depth < MAX_SYNC_SECRET_DEPTH) { + // find all imports made with the given environment and secret path + const linkSourceDto = { + projectId, + importEnv: folder.environment.id, + importPath: secretPath + }; + const imports = await secretImportDAL.find(linkSourceDto); + + if (imports.length) { + // keep calling sync secret for all the imports made + const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); + const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); + const foldersGroupedById = groupBy(importedFolders, (i) => i.child || i.id); + logger.info( + `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + imports + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0].path)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueIntegrationKey( + foldersGroupedById[folderId][0].environmentSlug, + foldersGroupedById[folderId][0].path + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + depth: depth + 1, + projectId, + secretPath: foldersGroupedById[folderId][0].path, + environment: foldersGroupedById[folderId][0].environmentSlug, + deDupeQueue + }) + ) + ); + } + + const secretReferences = await secretDAL.findReferencedSecretReferences( + projectId, + folder.environment.slug, + secretPath + ); + if (secretReferences.length) { + const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); + const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); + const referencedFoldersGroupedById = groupBy(referencedFolders, (i) => i.child || i.id); + logger.info( + `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + secretReferences + .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0].path)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueIntegrationKey( + referencedFoldersGroupedById[folderId][0].environmentSlug, + referencedFoldersGroupedById[folderId][0].path + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + depth: depth + 1, + projectId, + secretPath: referencedFoldersGroupedById[folderId][0].path, + environment: referencedFoldersGroupedById[folderId][0].environmentSlug, + deDupeQueue + }) + ) + ); + } + } else { + logger.info(`getIntegrationSecrets: Secret depth exceeded for [projectId=${projectId}] [folderId=${folder.id}]`); + } + + const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment const toBeSyncedIntegrations = integrations.filter( + // note: sync only the integrations sourced from secretPath ({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath) ); if (!integrations.length) return; - logger.info("Secret integration sync started", job.data, job.id); + logger.info( + `getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]` + ); for (const integration of toBeSyncedIntegrations) { const integrationAuth = { ...integration.integrationAuth, @@ -294,7 +445,13 @@ export const secretQueueFactory = ({ const botKey = await projectBotService.getBotKey(projectId); const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(integrationAuth, botKey); - const secrets = await getIntegrationSecrets({ environment, projectId, secretPath, folderId: folder.id }, botKey); + const secrets = await getIntegrationSecrets({ + environment, + projectId, + folderId: folder.id, + key: botKey, + depth: 1 + }); const suffixedSecrets: typeof secrets = {}; const metadata = integration.metadata as Record; if (metadata) { @@ -306,20 +463,40 @@ export const secretQueueFactory = ({ }); } - await syncIntegrationSecrets({ - integration, - integrationAuth, - secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, - accessId: accessId as string, - accessToken, - appendices: { - prefix: metadata?.secretPrefix || "", - suffix: metadata?.secretSuffix || "" - } - }); + try { + await syncIntegrationSecrets({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, + integration, + integrationAuth, + secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, + accessId: accessId as string, + accessToken, + appendices: { + prefix: metadata?.secretPrefix || "", + suffix: metadata?.secretSuffix || "" + } + }); + + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: "", + isSynced: true + }); + } catch (err: unknown) { + logger.info("Secret integration sync error:", err); + await integrationDAL.updateById(integration.id, { + lastSyncJobId: job.id, + lastUsed: new Date(), + syncMessage: (err as Error)?.message, + isSynced: false + }); + } } - logger.info("Secret integration sync ended", job.id); + logger.info("Secret integration sync ended: %s", job.id); }); queueService.start(QueueName.SecretReminder, async ({ data }) => { @@ -350,7 +527,7 @@ export const secretQueueFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.SecretReminder, subjectLine: "Infisical secret reminder", - recipients: [...projectMembers.map((m) => m.user.email)], + recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), substitutions: { reminderNote: data.note, // May not be present. projectName: project.name, @@ -360,7 +537,7 @@ export const secretQueueFactory = ({ }); queueService.listen(QueueName.IntegrationSync, "failed", (job, err) => { - logger.error("Failed to sync integration", job?.data, err); + logger.error(err, "Failed to sync integration %s", job?.id); }); queueService.start(QueueName.SecretWebhook, async (job) => { @@ -368,7 +545,8 @@ export const secretQueueFactory = ({ }); return { - syncSecrets, + // depth is internal only field thus no need to make it available outside + syncSecrets: (dto: TGetSecrets) => syncSecrets(dto), syncIntegrations, addSecretReminder, removeSecretReminder, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 0071216b1..39e47a28e 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1,44 +1,67 @@ +/* eslint-disable no-unreachable-loop */ +/* eslint-disable no-await-in-loop */ import { ForbiddenError, subject } from "@casl/ability"; -import { SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, SecretType, TableName } from "@app/db/schemas"; +import { + ProjectMembershipRole, + SecretEncryptionAlgo, + SecretKeyEncoding, + SecretsSchema, + SecretType +} from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { getConfig } from "@app/lib/config/env"; -import { buildSecretBlindIndexFromName, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { + buildSecretBlindIndexFromName, + decryptSymmetric128BitHexKeyUTF8, + encryptSymmetric128BitHexKeyUTF8 +} from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; +import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretDALFactory } from "./secret-dal"; -import { decryptSecretRaw, generateSecretBlindIndexBySalt } from "./secret-fns"; +import { + decryptSecretRaw, + fnSecretBlindIndexCheck, + fnSecretBulkInsert, + fnSecretBulkUpdate, + getAllNestedSecretReferences, + interpolateSecrets, + recursivelyGetSecretPaths +} from "./secret-fns"; import { TSecretQueueFactory } from "./secret-queue"; import { + TAttachSecretTagsDTO, + TBackFillSecretReferencesDTO, TCreateBulkSecretDTO, + TCreateManySecretRawDTO, TCreateSecretDTO, TCreateSecretRawDTO, TDeleteBulkSecretDTO, + TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, - TFnSecretBlindIndexCheck, TFnSecretBlindIndexCheckV2, TFnSecretBulkDelete, - TFnSecretBulkInsert, - TFnSecretBulkUpdate, TGetASecretDTO, TGetASecretRawDTO, TGetSecretsDTO, TGetSecretsRawDTO, TGetSecretVersionsDTO, - TListSecretVersionDTO, TUpdateBulkSecretDTO, + TUpdateManySecretRawDTO, TUpdateSecretDTO, TUpdateSecretRawDTO } from "./secret-types"; @@ -49,19 +72,25 @@ type TSecretServiceFactoryDep = { secretDAL: TSecretDALFactory; secretTagDAL: TSecretTagDALFactory; secretVersionDAL: TSecretVersionDALFactory; - folderDAL: Pick; + projectDAL: Pick; + projectEnvDAL: Pick; + folderDAL: Pick< + TSecretFolderDALFactory, + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + >; secretBlindIndexDAL: TSecretBlindIndexDALFactory; permissionService: Pick; snapshotService: Pick; secretQueueService: Pick; projectBotService: Pick; - secretImportDAL: Pick; + secretImportDAL: Pick; secretVersionTagDAL: Pick; }; export type TSecretServiceFactory = ReturnType; export const secretServiceFactory = ({ secretDAL, + projectEnvDAL, secretTagDAL, secretVersionDAL, folderDAL, @@ -69,10 +98,27 @@ export const secretServiceFactory = ({ permissionService, snapshotService, secretQueueService, + projectDAL, projectBotService, secretImportDAL, secretVersionTagDAL }: TSecretServiceFactoryDep) => { + const getSecretReference = async (projectId: string) => { + // if bot key missing means e2e still exist + const botKey = await projectBotService.getBotKey(projectId).catch(() => null); + return (el: { ciphertext?: string; iv: string; tag: string }) => + botKey + ? getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: el.ciphertext || "", + iv: el.iv, + tag: el.tag, + key: botKey + }) + ) + : undefined; + }; + // utility function to get secret blind index data const interalGenSecBlindIndexByName = async (projectId: string, secretName: string) => { const appCfg = getConfig(); @@ -93,85 +139,6 @@ export const secretServiceFactory = ({ return secretBlindIndex; }; - // these functions are special functions shared by a couple of resources - // used by secret approval, rotation or anywhere in which secret needs to modified - const fnSecretBulkInsert = async ({ folderId, inputSecrets, tx }: TFnSecretBulkInsert) => { - const newSecrets = await secretDAL.insertMany( - inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })), - tx - ); - const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string); - const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) => - secretTags.map((tag) => ({ - [`${TableName.SecretTag}Id` as const]: tag, - [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id - })) - ); - const secretVersions = await secretVersionDAL.insertMany( - inputSecrets.map(({ tags, ...el }) => ({ - ...el, - folderId, - secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id - })), - tx - ); - if (newSecretTags.length) { - const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); - const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); - const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ - [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, - [`${TableName.SecretTag}Id` as const]: secret_tagsId - })); - await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); - } - - return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); - }; - - const fnSecretBulkUpdate = async ({ tx, inputSecrets, folderId, projectId }: TFnSecretBulkUpdate) => { - const newSecrets = await secretDAL.bulkUpdate( - inputSecrets.map(({ filter, data: { tags, ...data } }) => ({ - filter: { ...filter, folderId }, - data - })), - tx - ); - const secretVersions = await secretVersionDAL.insertMany( - newSecrets.map(({ id, createdAt, updatedAt, ...el }) => ({ - ...el, - secretId: id - })), - tx - ); - const secsUpdatedTag = inputSecrets.flatMap(({ data: { tags } }, i) => - tags !== undefined ? { tags, secretId: newSecrets[i].id } : [] - ); - if (secsUpdatedTag.length) { - await secretTagDAL.deleteTagsManySecret( - projectId, - secsUpdatedTag.map(({ secretId }) => secretId), - tx - ); - const newSecretTags = secsUpdatedTag.flatMap(({ tags: secretTags = [], secretId }) => - secretTags.map((tag) => ({ - [`${TableName.SecretTag}Id` as const]: tag, - [`${TableName.Secret}Id` as const]: secretId - })) - ); - if (newSecretTags.length) { - const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); - const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); - const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ - [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, - [`${TableName.SecretTag}Id` as const]: secret_tagsId - })); - await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); - } - } - - return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); - }; - const fnSecretBulkDelete = async ({ folderId, inputSecrets, tx, actorId }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretBlindIndex }) => ({ @@ -200,54 +167,6 @@ export const secretServiceFactory = ({ return deletedSecrets; }; - // this is a utility function for secret modification - // this will check given secret name blind index exist or not - // if its a created secret set isNew to true - // thus if these blindindex exist it will throw an error - // vice versa when u need to check for updated secret - // this will also return the blind index grouped by secretName - const fnSecretBlindIndexCheck = async ({ - inputSecrets, - folderId, - isNew, - userId, - blindIndexCfg - }: TFnSecretBlindIndexCheck) => { - const blindIndex2KeyName: Record = {}; // used at audit log point - const keyName2BlindIndex = await Promise.all( - inputSecrets.map(({ secretName }) => generateSecretBlindIndexBySalt(secretName, blindIndexCfg)) - ).then((blindIndexes) => - blindIndexes.reduce>((prev, curr, i) => { - // eslint-disable-next-line - prev[inputSecrets[i].secretName] = curr; - blindIndex2KeyName[curr] = inputSecrets[i].secretName; - return prev; - }, {}) - ); - - if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { - throw new BadRequestError({ message: "Missing user id for personal secret" }); - } - - const secrets = await secretDAL.findByBlindIndexes( - folderId, - inputSecrets.map(({ secretName, type }) => ({ - blindIndex: keyName2BlindIndex[secretName], - type: type || SecretType.Shared - })), - userId - ); - - if (isNew) { - if (secrets.length) throw new BadRequestError({ message: "Secret already exist" }); - } else if (secrets.length !== inputSecrets.length) - throw new BadRequestError({ - message: `Secret not found: blind index ${JSON.stringify(keyName2BlindIndex)}` - }); - - return { blindIndex2KeyName, keyName2BlindIndex, secrets }; - }; - // this is used when secret blind index already exist // mainly for secret approval const fnSecretBlindIndexCheckV2 = async ({ inputSecrets, folderId, userId }: TFnSecretBlindIndexCheckV2) => { @@ -267,15 +186,36 @@ export const secretServiceFactory = ({ return { secsGroupedByBlindIndex, secrets }; }; - const createSecret = async ({ path, actor, actorId, environment, projectId, ...inputSecret }: TCreateSecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const createSecret = async ({ + path, + actor, + actorId, + actorOrgId, + environment, + actorAuthMethod, + projectId, + ...inputSecret + }: TCreateSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -290,7 +230,8 @@ export const secretServiceFactory = ({ folderId, isNew: true, userId: actorId, - blindIndexCfg + blindIndexCfg, + secretDAL }); // if user creating personal check its shared also exist @@ -312,6 +253,7 @@ export const secretServiceFactory = ({ if ((inputSecret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); const { secretName, type, ...el } = inputSecret; + const references = await getSecretReference(projectId); const secret = await secretDAL.transaction((tx) => fnSecretBulkInsert({ folderId, @@ -324,9 +266,18 @@ export const secretServiceFactory = ({ userId: inputSecret.type === SecretType.Personal ? actorId : null, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8, - tags: inputSecret.tags + tags: inputSecret.tags, + references: references({ + ciphertext: inputSecret.secretValueCiphertext, + iv: inputSecret.secretValueIV, + tag: inputSecret.secretValueTag + }) } ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -334,18 +285,43 @@ export const secretServiceFactory = ({ await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); // TODO(akhilmhdh-pg): licence check, posthog service and snapshot - return { ...secret[0], environment, workspace: projectId, tags }; + return { ...secret[0], environment, workspace: projectId, tags, secretPath: path }; }; - const updateSecret = async ({ path, actor, actorId, environment, projectId, ...inputSecret }: TUpdateSecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const updateSecret = async ({ + path, + actor, + actorId, + actorOrgId, + environment, + actorAuthMethod, + projectId, + ...inputSecret + }: TUpdateSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + + if (inputSecret.newSecretName === "") { + throw new BadRequestError({ message: "New secret name cannot be empty" }); + } + const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -360,7 +336,8 @@ export const secretServiceFactory = ({ folderId, isNew: false, blindIndexCfg, - userId: actorId + userId: actorId, + secretDAL }); if (inputSecret.newSecretName && inputSecret.type === SecretType.Personal) { throw new BadRequestError({ message: "Personal secret cannot change the key name" }); @@ -372,7 +349,8 @@ export const secretServiceFactory = ({ inputSecrets: [{ secretName: inputSecret.newSecretName }], folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); newSecretNameBlindIndex = kN2NewBlindIndex[inputSecret.newSecretName]; } @@ -390,6 +368,8 @@ export const secretServiceFactory = ({ if ((inputSecret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); const { secretName, ...el } = inputSecret; + + const references = await getSecretReference(projectId); const updatedSecret = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -415,10 +395,19 @@ export const secretServiceFactory = ({ "secretReminderRepeatDays", "tags" ]), - secretBlindIndex: newSecretNameBlindIndex || keyName2BlindIndex[secretName] + secretBlindIndex: newSecretNameBlindIndex || keyName2BlindIndex[secretName], + references: references({ + ciphertext: inputSecret.secretValueCiphertext, + iv: inputSecret.secretValueIV, + tag: inputSecret.secretValueTag + }) } } ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -426,18 +415,39 @@ export const secretServiceFactory = ({ await snapshotService.performSnapshot(folderId); await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); // TODO(akhilmhdh-pg): licence check, posthog service and snapshot - return { ...updatedSecret[0], workspace: projectId, environment }; + return { ...updatedSecret[0], workspace: projectId, environment, secretPath: path }; }; - const deleteSecret = async ({ path, actor, actorId, environment, projectId, ...inputSecret }: TDeleteSecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const deleteSecret = async ({ + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + projectId, + ...inputSecret + }: TDeleteSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -451,7 +461,8 @@ export const secretServiceFactory = ({ inputSecrets: [{ secretName: inputSecret.secretName }], folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const deletedSecret = await secretDAL.transaction(async (tx) => @@ -473,23 +484,73 @@ export const secretServiceFactory = ({ await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); // TODO(akhilmhdh-pg): licence check, posthog service and snapshot - return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment }; + return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path }; }; - const getSecrets = async ({ actorId, path, environment, projectId, actor, includeImports }: TGetSecretsDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + const getSecrets = async ({ + actorId, + path, + environment, + projectId, + actor, + actorOrgId, + actorAuthMethod, + includeImports, + recursive + }: TGetSecretsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; - const folderId = folder.id; + let paths: { folderId: string; path: string }[] = []; + + if (recursive) { + const getPaths = recursivelyGetSecretPaths({ + permissionService, + folderDAL, + projectEnvDAL + }); + + const deepPaths = await getPaths({ + projectId, + environment, + currentPath: path, + auth: { + actor, + actorId, + actorAuthMethod, + actorOrgId + } + }); + + if (!deepPaths) return { secrets: [], imports: [] }; + + paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) return { secrets: [], imports: [] }; + + paths = [{ folderId: folder.id, path }]; + } + + const groupedPaths = groupBy(paths, (p) => p.folderId); + + const secrets = await secretDAL.findByFolderIds( + paths.map((p) => p.folderId), + actorId + ); - const secrets = await secretDAL.findByFolderId(folderId, actorId); if (includeImports) { - const secretImports = await secretImportDAL.find({ folderId }); + const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); const allowedImports = secretImports.filter(({ importEnv, importPath }) => // if its service token allow full access over imported one actor === ActorType.SERVICE @@ -505,19 +566,36 @@ export const secretServiceFactory = ({ const importedSecrets = await fnSecretsFromImports({ allowedImports, secretDAL, - folderDAL + folderDAL, + secretImportDAL }); + return { - secrets: secrets.map((el) => ({ ...el, workspace: projectId, environment })), + secrets: secrets.map((secret) => ({ + ...secret, + workspace: projectId, + environment, + secretPath: groupedPaths[secret.folderId][0].path + })), imports: importedSecrets }; } - return { secrets: secrets.map((el) => ({ ...el, workspace: projectId, environment })) }; + + return { + secrets: secrets.map((secret) => ({ + ...secret, + workspace: projectId, + environment, + secretPath: groupedPaths[secret.folderId][0].path + })) + }; }; const getSecretByName = async ({ actorId, actor, + actorOrgId, + actorAuthMethod, projectId, environment, path, @@ -526,13 +604,23 @@ export const secretServiceFactory = ({ version, includeImports }: TGetASecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); const folderId = folder.id; const secretBlindIndex = await interalGenSecBlindIndexByName(projectId, secretName); @@ -584,7 +672,8 @@ export const secretServiceFactory = ({ const importedSecrets = await fnSecretsFromImports({ allowedImports, secretDAL, - folderDAL + folderDAL, + secretImportDAL }); for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { for (let j = 0; j < importedSecrets[i].secrets.length; j += 1) { @@ -592,7 +681,8 @@ export const secretServiceFactory = ({ return { ...importedSecrets[i].secrets[j], workspace: projectId, - environment: importedSecrets[i].environment + environment: importedSecrets[i].environment, + secretPath: importedSecrets[i].secretPath }; } } @@ -600,35 +690,50 @@ export const secretServiceFactory = ({ } if (!secret) throw new BadRequestError({ message: "Secret not found" }); - return { ...secret, workspace: projectId, environment }; + return { ...secret, workspace: projectId, environment, secretPath: path }; }; const createManySecret = async ({ path, actor, actorId, + actorAuthMethod, + actorOrgId, environment, projectId, secrets: inputSecrets }: TCreateBulkSecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); - if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Create secret" }); const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ inputSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); // get all tags @@ -636,6 +741,7 @@ export const secretServiceFactory = ({ const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; if (tags.length !== tagIds.length) throw new BadRequestError({ message: "Tag not found" }); + const references = await getSecretReference(projectId); const newSecrets = await secretDAL.transaction(async (tx) => fnSecretBulkInsert({ inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({ @@ -644,9 +750,18 @@ export const secretServiceFactory = ({ secretBlindIndex: keyName2BlindIndex[secretName], type: SecretType.Shared, algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 + keyEncoding: SecretKeyEncoding.UTF8, + references: references({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag + }) })), folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -661,18 +776,32 @@ export const secretServiceFactory = ({ path, actor, actorId, + actorOrgId, + actorAuthMethod, environment, projectId, secrets: inputSecrets }: TUpdateBulkSecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Update secret" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -682,7 +811,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); // now find any secret that needs to update its name @@ -692,13 +822,16 @@ export const secretServiceFactory = ({ inputSecrets: nameUpdatedSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); // get all tags const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; if (tagIds.length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + const references = await getSecretReference(projectId); const secrets = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -715,9 +848,21 @@ export const secretServiceFactory = ({ ? newKeyName2BlindIndex[newSecretName] : keyName2BlindIndex[secretName], algorithm: SecretEncryptionAlgo.AES_256_GCM, - keyEncoding: SecretKeyEncoding.UTF8 + keyEncoding: SecretKeyEncoding.UTF8, + references: + el.secretValueIV && el.secretValueTag + ? references({ + ciphertext: el.secretValueCiphertext, + iv: el.secretValueIV, + tag: el.secretValueTag + }) + : undefined } - })) + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) ); @@ -733,16 +878,30 @@ export const secretServiceFactory = ({ environment, projectId, actor, - actorId + actorId, + actorAuthMethod, + actorOrgId }: TDeleteBulkSecretDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); + await projectDAL.checkProjectUpgradeStatus(projectId); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) + throw new BadRequestError({ + message: "Folder not found for the given environment slug & secret path", + name: "Create secret" + }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -752,7 +911,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secretsDeleted = await secretDAL.transaction(async (tx) => @@ -774,20 +934,18 @@ export const secretServiceFactory = ({ return secretsDeleted; }; - const listSecretVersionsBySecretId = async ({ actorId, actor, limit, offset, secretId }: TListSecretVersionDTO) => { - const secret = await secretDAL.findById(secretId); - if (!secret) throw new BadRequestError({ message: "Failed to find secret" }); - - const folder = await folderDAL.findById(secret.folderId); - if (!folder) throw new BadRequestError({ message: "Folder not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, folder.projectId); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); - - const secretVersions = await secretVersionDAL.find({ secretId }, { limit, offset, sort: [["createdAt", "desc"]] }); - return secretVersions; - }; - - const getSecretsRaw = async ({ projectId, path, actor, actorId, environment, includeImports }: TGetSecretsRawDTO) => { + const getSecretsRaw = async ({ + projectId, + path, + actor, + actorId, + actorOrgId, + actorAuthMethod, + environment, + includeImports, + expandSecretReferences, + recursive + }: TGetSecretsRawDTO) => { const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); @@ -796,18 +954,79 @@ export const secretServiceFactory = ({ projectId, environment, actor, + actorOrgId, + actorAuthMethod, path, - includeImports + includeImports, + recursive }); - return { - secrets: secrets.map((el) => decryptSecretRaw(el, botKey)), - imports: (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({ - ...el, - secrets: importedSecrets.map((sec) => - decryptSecretRaw({ ...sec, environment: el.environment, workspace: projectId }, botKey) + const decryptedSecrets = secrets.map((el) => decryptSecretRaw(el, botKey)); + const decryptedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({ + ...el, + secrets: importedSecrets.map((sec) => + decryptSecretRaw( + { ...sec, environment: el.environment, workspace: projectId, secretPath: el.secretPath }, + botKey ) - })) + ) + })); + + if (expandSecretReferences) { + const expandSecrets = interpolateSecrets({ + folderDAL, + projectId, + secretDAL, + secretEncKey: botKey + }); + + const batchSecretsExpand = async ( + secretBatch: { secretKey: string; secretValue: string; secretComment?: string; secretPath: string }[] + ) => { + // Group secrets by secretPath + const secretsByPath: Record = {}; + + secretBatch.forEach((secret) => { + if (!secretsByPath[secret.secretPath]) { + secretsByPath[secret.secretPath] = []; + } + secretsByPath[secret.secretPath].push(secret); + }); + + // Expand secrets for each group + for (const secPath in secretsByPath) { + if (!Object.hasOwn(secretsByPath, path)) { + // eslint-disable-next-line no-continue + continue; + } + + const secretRecord: Record = {}; + secretsByPath[secPath].forEach((decryptedSecret) => { + secretRecord[decryptedSecret.secretKey] = { + value: decryptedSecret.secretValue, + comment: decryptedSecret.secretComment + }; + }); + + await expandSecrets(secretRecord); + + secretsByPath[secPath].forEach((decryptedSecret) => { + // eslint-disable-next-line no-param-reassign + decryptedSecret.secretValue = secretRecord[decryptedSecret.secretKey].value; + }); + } + }; + + // expand secrets + await batchSecretsExpand(decryptedSecrets); + + // expand imports by batch + await Promise.all(decryptedImports.map((decryptedImport) => batchSecretsExpand(decryptedImport.secrets))); + } + + return { + secrets: decryptedSecrets, + imports: decryptedImports }; }; @@ -816,26 +1035,34 @@ export const secretServiceFactory = ({ path, actor, environment, - projectId, + projectId: workspaceId, + projectSlug, actorId, + actorOrgId, + actorAuthMethod, secretName, includeImports, version }: TGetASecretRawDTO) => { + const projectId = workspaceId || (await projectDAL.findProjectBySlug(projectSlug as string, actorOrgId)).id; + const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); const secret = await getSecretByName({ actorId, projectId, + actorAuthMethod, environment, actor, + actorOrgId, path, secretName, type, includeImports, version }); + return decryptSecretRaw(secret, botKey); }; @@ -845,6 +1072,8 @@ export const secretServiceFactory = ({ projectId, environment, actor, + actorOrgId, + actorAuthMethod, type, secretPath, secretValue, @@ -866,6 +1095,8 @@ export const secretServiceFactory = ({ path: secretPath, actor, actorId, + actorAuthMethod, + actorOrgId, secretKeyCiphertext: secretKeyEncrypted.ciphertext, secretKeyIV: secretKeyEncrypted.iv, secretKeyTag: secretKeyEncrypted.tag, @@ -890,6 +1121,8 @@ export const secretServiceFactory = ({ projectId, environment, actor, + actorOrgId, + actorAuthMethod, type, secretPath, secretValue, @@ -908,6 +1141,8 @@ export const secretServiceFactory = ({ path: secretPath, actor, actorId, + actorOrgId, + actorAuthMethod, secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, @@ -926,6 +1161,8 @@ export const secretServiceFactory = ({ projectId, environment, actor, + actorOrgId, + actorAuthMethod, type, secretPath }: TDeleteSecretRawDTO) => { @@ -939,7 +1176,9 @@ export const secretServiceFactory = ({ type, path: secretPath, actor, - actorId + actorId, + actorOrgId, + actorAuthMethod }); await snapshotService.performSnapshot(secret.folderId); @@ -948,21 +1187,426 @@ export const secretServiceFactory = ({ return decryptSecretRaw(secret, botKey); }; - const getSecretVersions = async ({ actorId, actor, limit = 20, offset = 0, secretId }: TGetSecretVersionsDTO) => { + const createManySecretsRaw = async ({ + actorId, + projectSlug, + environment, + actor, + actorOrgId, + actorAuthMethod, + secretPath, + secrets: inputSecrets = [] + }: TCreateManySecretRawDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const botKey = await projectBotService.getBotKey(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + const secrets = await createManySecret({ + projectId, + environment, + path: secretPath, + actor, + actorId, + actorOrgId, + actorAuthMethod, + secrets: inputSecrets.map(({ secretComment, secretKey, secretValue, skipMultilineEncoding }) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey); + return { + secretName: secretKey, + skipMultilineEncoding, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag + }; + }) + }); + + await snapshotService.performSnapshot(secrets[0].folderId); + await secretQueueService.syncSecrets({ secretPath, projectId, environment }); + + return secrets.map((secret) => + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + ); + }; + + const updateManySecretsRaw = async ({ + actorId, + projectSlug, + environment, + actor, + actorOrgId, + actorAuthMethod, + secretPath, + secrets: inputSecrets = [] + }: TUpdateManySecretRawDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const botKey = await projectBotService.getBotKey(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + const secrets = await updateManySecret({ + projectId, + environment, + path: secretPath, + actor, + actorId, + actorOrgId, + actorAuthMethod, + secrets: inputSecrets.map(({ secretComment, secretKey, secretValue, skipMultilineEncoding }) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secretKey, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secretComment || "", botKey); + return { + secretName: secretKey, + type: SecretType.Shared, + skipMultilineEncoding, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag + }; + }) + }); + + await snapshotService.performSnapshot(secrets[0].folderId); + await secretQueueService.syncSecrets({ secretPath, projectId, environment }); + + return secrets.map((secret) => + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + ); + }; + + const deleteManySecretsRaw = async ({ + actorId, + projectSlug, + environment, + actor, + actorOrgId, + actorAuthMethod, + secretPath, + secrets: inputSecrets = [] + }: TDeleteManySecretRawDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const botKey = await projectBotService.getBotKey(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + const secrets = await deleteManySecret({ + projectId, + environment, + path: secretPath, + actor, + actorId, + actorOrgId, + actorAuthMethod, + secrets: inputSecrets.map(({ secretKey }) => ({ secretName: secretKey, type: SecretType.Shared })) + }); + + await snapshotService.performSnapshot(secrets[0].folderId); + await secretQueueService.syncSecrets({ secretPath, projectId, environment }); + + return secrets.map((secret) => + decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) + ); + }; + + const getSecretVersions = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + limit = 20, + offset = 0, + secretId + }: TGetSecretVersionsDTO) => { const secret = await secretDAL.findById(secretId); if (!secret) throw new BadRequestError({ message: "Failed to find secret" }); const folder = await folderDAL.findById(secret.folderId); if (!folder) throw new BadRequestError({ message: "Failed to find secret" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, folder.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + folder.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); const secretVersions = await secretVersionDAL.find({ secretId }, { offset, limit, sort: [["createdAt", "desc"]] }); return secretVersions; }; + const attachTags = async ({ + secretName, + tagSlugs, + path: secretPath, + environment, + type, + projectSlug, + actor, + actorAuthMethod, + actorOrgId, + actorId + }: TAttachSecretTagsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const secret = await getSecretByName({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId: project.id, + environment, + path: secretPath, + secretName, + type + }); + + if (!secret) { + throw new BadRequestError({ message: "Secret not found" }); + } + const folder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + + const tags = await secretTagDAL.find({ + projectId: project.id, + $in: { + slug: tagSlugs + } + }); + + if (tags.length !== tagSlugs.length) { + throw new BadRequestError({ message: "One or more tags not found." }); + } + + const existingSecretTags = await secretDAL.getSecretTags(secret.id); + + if (existingSecretTags.some((tag) => tagSlugs.includes(tag.slug))) { + throw new BadRequestError({ message: "One or more tags already exist on the secret" }); + } + + const combinedTags = new Set([...existingSecretTags.map((tag) => tag.id), ...tags.map((el) => el.id)]); + + const updatedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId: folder.id, + projectId: project.id, + inputSecrets: [ + { + filter: { id: secret.id }, + data: { + tags: Array.from(combinedTags) + } + } + ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + + return { + ...updatedSecret[0], + tags: [...existingSecretTags, ...tags].map((t) => ({ id: t.id, slug: t.slug, name: t.name, color: t.color })) + }; + }; + + const detachTags = async ({ + secretName, + tagSlugs, + path: secretPath, + environment, + type, + projectSlug, + actor, + actorAuthMethod, + actorOrgId, + actorId + }: TAttachSecretTagsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const secret = await getSecretByName({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId: project.id, + environment, + path: secretPath, + secretName, + type + }); + + if (!secret) { + throw new BadRequestError({ message: "Secret not found" }); + } + const folder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + + const tags = await secretTagDAL.find({ + projectId: project.id, + $in: { + slug: tagSlugs + } + }); + + if (tags.length !== tagSlugs.length) { + throw new BadRequestError({ message: "One or more tags not found." }); + } + + const existingSecretTags = await secretDAL.getSecretTags(secret.id); + + // Make sure all the tags exist on the secret + const tagIdsToRemove = tags.map((tag) => tag.id); + const secretTagIds = existingSecretTags.map((tag) => tag.id); + + if (!tagIdsToRemove.every((el) => secretTagIds.includes(el))) { + throw new BadRequestError({ message: "One or more tags not found on the secret" }); + } + + const newTags = existingSecretTags.filter((tag) => !tagIdsToRemove.includes(tag.id)); + + const updatedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId: folder.id, + projectId: project.id, + inputSecrets: [ + { + filter: { id: secret.id }, + data: { + tags: newTags.map((tag) => tag.id) + } + } + ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + + return { + ...updatedSecret[0], + tags: newTags + }; + }; + + // this is a backfilling API for secret references + // what it does is it will go through all the secret values and parse all references + // populate the secret reference to do sync integrations + const backfillSecretReferences = async ({ + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TBackFillSecretReferencesDTO) => { + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + if (!hasRole(ProjectMembershipRole.Admin)) + throw new BadRequestError({ message: "Only admins are allowed to take this action" }); + + const botKey = await projectBotService.getBotKey(projectId); + if (!botKey) + throw new BadRequestError({ message: "Please upgrade your project first", name: "bot_not_found_error" }); + + await secretDAL.transaction(async (tx) => { + const secrets = await secretDAL.findAllProjectSecretValues(projectId, tx); + await secretDAL.upsertSecretReferences( + secrets.map(({ id, secretValueCiphertext, secretValueIV, secretValueTag }) => ({ + secretId: id, + references: getAllNestedSecretReferences( + decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secretValueCiphertext, + iv: secretValueIV, + tag: secretValueTag, + key: botKey + }) + ) + })), + tx + ); + }); + + return { message: "Successfully backfilled secret references" }; + }; + return { + attachTags, + detachTags, createSecret, deleteSecret, updateSecret, @@ -976,8 +1620,11 @@ export const secretServiceFactory = ({ createSecretRaw, updateSecretRaw, deleteSecretRaw, - listSecretVersionsBySecretId, + createManySecretsRaw, + updateManySecretsRaw, + deleteManySecretsRaw, getSecretVersions, + backfillSecretReferences, // external services function fnSecretBulkDelete, fnSecretBulkUpdate, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 89e16d77b..7e713a80f 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -2,6 +2,14 @@ import { Knex } from "knex"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; type TPartialSecret = Pick; @@ -66,6 +74,7 @@ export type TGetSecretsDTO = { path: string; environment: string; includeImports?: boolean; + recursive?: boolean; } & TProjectPermission; export type TGetASecretDTO = { @@ -128,16 +137,12 @@ export type TDeleteBulkSecretDTO = { }>; } & TProjectPermission; -export type TListSecretVersionDTO = { - secretId: string; - offset?: number; - limit?: number; -} & Omit; - export type TGetSecretsRawDTO = { + expandSecretReferences?: boolean; path: string; environment: string; includeImports?: boolean; + recursive?: boolean; } & TProjectPermission; export type TGetASecretRawDTO = { @@ -147,7 +152,9 @@ export type TGetASecretRawDTO = { type: "shared" | "personal"; includeImports?: boolean; version?: number; -} & TProjectPermission; + projectSlug?: string; + projectId?: string; +} & Omit; export type TCreateSecretRawDTO = TProjectPermission & { secretPath: string; @@ -177,25 +184,80 @@ export type TDeleteSecretRawDTO = TProjectPermission & { type: SecretType; }; +export type TCreateManySecretRawDTO = Omit & { + secretPath: string; + projectSlug: string; + environment: string; + secrets: { + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + }[]; +}; + +export type TUpdateManySecretRawDTO = Omit & { + secretPath: string; + projectSlug: string; + environment: string; + secrets: { + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding?: boolean; + }[]; +}; + +export type TDeleteManySecretRawDTO = Omit & { + secretPath: string; + projectSlug: string; + environment: string; + secrets: { + secretKey: string; + }[]; +}; + export type TGetSecretVersionsDTO = Omit & { limit?: number; offset?: number; secretId: string; }; +export type TSecretReference = { environment: string; secretPath: string }; + export type TFnSecretBulkInsert = { folderId: string; tx?: Knex; - inputSecrets: Array & { tags?: string[] }>; + inputSecrets: Array & { tags?: string[]; references?: TSecretReference[] }>; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; }; export type TFnSecretBulkUpdate = { folderId: string; projectId: string; - inputSecrets: { filter: Partial; data: TSecretsUpdate & { tags?: string[] } }[]; + inputSecrets: { + filter: Partial; + data: TSecretsUpdate & { tags?: string[]; references?: TSecretReference[] }; + }[]; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; tx?: Knex; }; +export type TAttachSecretTagsDTO = { + projectSlug: string; + secretName: string; + tagSlugs: string[]; + environment: string; + path: string; + type: SecretType; +} & Omit; + export type TFnSecretBulkDelete = { folderId: string; projectId: string; @@ -210,6 +272,7 @@ export type TFnSecretBlindIndexCheck = { blindIndexCfg: TSecretBlindIndexes; inputSecrets: Array<{ secretName: string; type?: SecretType }>; isNew: boolean; + secretDAL: Pick; }; // when blind index is already present @@ -235,3 +298,68 @@ export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; }; + +export type TBackFillSecretReferencesDTO = TProjectPermission; + +// --- + +export type TCreateManySecretsRawFnFactory = { + projectDAL: TProjectDALFactory; + projectBotDAL: TProjectBotDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; +}; + +export type TCreateManySecretsRawFn = { + projectId: string; + environment: string; + path: string; + secrets: { + secretName: string; + secretValue: string; + type: SecretType; + secretComment?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; + metadata?: { + source?: string; + }; + }[]; + userId?: string; // only relevant for personal secret(s) +}; + +export type TUpdateManySecretsRawFnFactory = { + projectDAL: TProjectDALFactory; + projectBotDAL: TProjectBotDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; +}; + +export type TUpdateManySecretsRawFn = { + projectId: string; + environment: string; + path: string; + secrets: { + secretName: string; + newSecretName?: string; + secretValue: string; + type: SecretType; + secretComment?: string; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + tags?: string[]; + metadata?: { + source?: string; + }; + }[]; + userId?: string; +}; diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 7a6695e18..758352ed2 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -1,8 +1,8 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TSecretVersions } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TSecretVersionDALFactory = ReturnType; @@ -36,6 +36,57 @@ export const secretVersionDALFactory = (db: TDbClient) => { } }; + const bulkUpdate = async ( + data: Array<{ filter: Partial; data: TSecretVersionsUpdate }>, + tx?: Knex + ) => { + try { + const secs = await Promise.all( + data.map(async ({ filter, data: updateData }) => { + const [doc] = await (tx || db)(TableName.SecretVersion) + .where(filter) + .update(updateData) + .increment("version", 1) // TODO: Is this really needed? + .returning("*"); + if (!doc) throw new BadRequestError({ message: "Failed to update document" }); + return doc; + }) + ); + return secs; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + + const bulkUpdateNoVersionIncrement = async (data: TSecretVersions[], tx?: Knex) => { + try { + const existingSecretVersions = await secretVersionOrm.find( + { + $in: { + id: data.map((el) => el.id) + } + }, + { tx } + ); + + if (existingSecretVersions.length !== data.length) { + throw new BadRequestError({ message: "Some of the secret versions do not exist" }); + } + + if (data.length === 0) return []; + + const updatedSecretVersions = await (tx || db)(TableName.SecretVersion) + .insert(data) + .onConflict("id") // this will cause a conflict then merge the data + .merge() // Merge the data with the existing data + .returning("*"); + + return updatedSecretVersions; + } catch (error) { + throw new DatabaseError({ error, name: "bulk update secret" }); + } + }; + const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { try { const docs: Array = await (tx || db)(TableName.SecretVersion) @@ -59,5 +110,11 @@ export const secretVersionDALFactory = (db: TDbClient) => { } }; - return { ...secretVersionOrm, findLatestVersionMany, findLatestVersionByFolderId }; + return { + ...secretVersionOrm, + findLatestVersionMany, + bulkUpdate, + findLatestVersionByFolderId, + bulkUpdateNoVersionIncrement + }; }; diff --git a/backend/src/services/service-token/service-token-dal.ts b/backend/src/services/service-token/service-token-dal.ts index b94c7ee35..5d3fcc5c8 100644 --- a/backend/src/services/service-token/service-token-dal.ts +++ b/backend/src/services/service-token/service-token-dal.ts @@ -1,10 +1,32 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { TableName, TUsers } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TServiceTokenDALFactory = ReturnType; export const serviceTokenDALFactory = (db: TDbClient) => { const stOrm = ormify(db, TableName.ServiceToken); - return stOrm; + + const findById = async (id: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.ServiceToken) + .leftJoin( + TableName.Users, + `${TableName.Users}.id`, + db.raw(`${TableName.ServiceToken}."createdBy"::uuid`) + ) + .where(`${TableName.ServiceToken}.id`, id) + .select(selectAllTableCols(TableName.ServiceToken)) + .select(db.ref("email").withSchema(TableName.Users).as("createdByEmail")) + .first(); + return doc; + } catch (err) { + throw new DatabaseError({ error: err, name: "FindById" }); + } + }; + + return { ...stOrm, findById }; }; diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 9a4fd6afe..e434bd91f 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -9,6 +9,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorType } from "../auth/auth-type"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TServiceTokenDALFactory } from "./service-token-dal"; @@ -24,6 +25,7 @@ type TServiceTokenServiceFactoryDep = { userDAL: TUserDALFactory; permissionService: Pick; projectEnvDAL: Pick; + projectDAL: Pick; }; export type TServiceTokenServiceFactory = ReturnType; @@ -32,13 +34,16 @@ export const serviceTokenServiceFactory = ({ serviceTokenDAL, userDAL, permissionService, - projectEnvDAL + projectEnvDAL, + projectDAL }: TServiceTokenServiceFactoryDep) => { const createServiceToken = async ({ iv, tag, name, actor, + actorOrgId, + actorAuthMethod, scopes, actorId, projectId, @@ -46,7 +51,13 @@ export const serviceTokenServiceFactory = ({ permissions, encryptedKey }: TCreateServiceTokenDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); scopes.forEach(({ environment, secretPath }) => { @@ -90,11 +101,17 @@ export const serviceTokenServiceFactory = ({ return { token, serviceToken }; }; - const deleteServiceToken = async ({ actorId, actor, id }: TDeleteServiceTokenDTO) => { + const deleteServiceToken = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteServiceTokenDTO) => { const serviceToken = await serviceTokenDAL.findById(id); if (!serviceToken) throw new BadRequestError({ message: "Token not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, serviceToken.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + serviceToken.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.ServiceTokens); const deletedServiceToken = await serviceTokenDAL.deleteById(id); @@ -113,8 +130,20 @@ export const serviceTokenServiceFactory = ({ return { serviceToken, user: serviceTokenUser }; }; - const getProjectServiceTokens = async ({ actorId, actor, projectId }: TProjectServiceTokensDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const getProjectServiceTokens = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TProjectServiceTokensDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); const tokens = await serviceTokenDAL.find({ projectId }, { sort: [["createdAt", "desc"]] }); @@ -124,7 +153,11 @@ export const serviceTokenServiceFactory = ({ const fnValidateServiceToken = async (token: string) => { const [, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>token.split(".", 3); const serviceToken = await serviceTokenDAL.findById(TOKEN_IDENTIFIER); + if (!serviceToken) throw new UnauthorizedError(); + const project = await projectDAL.findById(serviceToken.projectId); + + if (!project) throw new UnauthorizedError({ message: "Service token project not found" }); if (serviceToken.expiresAt && new Date(serviceToken.expiresAt) < new Date()) { await serviceTokenDAL.deleteById(serviceToken.id); @@ -136,7 +169,8 @@ export const serviceTokenServiceFactory = ({ const updatedToken = await serviceTokenDAL.updateById(serviceToken.id, { lastUsed: new Date() }); - return updatedToken; + + return { ...serviceToken, lastUsed: updatedToken.lastUsed, orgId: project.orgId }; }; return { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 142993ceb..81680537d 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -17,15 +17,18 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { + SignupEmailVerification = "signupEmailVerification.handlebars", EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", + AccessApprovalRequest = "accessApprovalRequest.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", ResetPassword = "passwordReset.handlebars", SecretLeakIncident = "secretLeakIncident.handlebars", - WorkspaceInvite = "workspaceInvitation.handlebars" + WorkspaceInvite = "workspaceInvitation.handlebars", + ScimUserProvisioned = "scimUserProvisioned.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars new file mode 100644 index 000000000..82c66ce5f --- /dev/null +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -0,0 +1,50 @@ + + + + + + Access Approval Request + + + +

Infisical

+

New access approval request pending your review

+

You have a new access approval request pending review in project "{{projectName}}".

+ +

+ {{requesterFullName}} + ({{requesterEmail}}) has requested + {{#if isTemporary}} + temporary + {{else}} + permanent + {{/if}} + access to + {{secretPath}} + in the + {{environment}} + environment. + + {{#if isTemporary}} +
+ This access will expire + {{expiresIn}} + after it has been approved. + {{/if}} +

+

+ The following permissions are requested: +

    + {{#each permissions}} +
  • {{this}}
  • + {{/each}} +
+

+ +

+ View the request and approve or deny it + here. +

+ + + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index fc738d202..ad9694d5c 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -1,17 +1,15 @@ - - - - + + + Code - + - +

Confirm your email address

-

Your confirmation code is below β€” enter it in the browser window where you've started signing up for Infisical.

+

Your confirmation code is below β€” enter it in the browser window where you've started confirming your email.

{{code}}

-

Questions about setting up Infisical? Email us at support@infisical.com

- + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index b281786f4..024fca132 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -8,7 +8,7 @@

Join your organization on Infisical

-

{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical organization β€” {{organizationName}}

+

{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization β€” {{organizationName}}

Join now

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars new file mode 100644 index 000000000..b1482aa17 --- /dev/null +++ b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars @@ -0,0 +1,16 @@ + + + + + + + Organization Invitation + + +

Join your organization on Infisical

+

You've been invited to join the Infisical organization β€” {{organizationName}}

+ Join now +

What is Infisical?

+

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars new file mode 100644 index 000000000..fc738d202 --- /dev/null +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -0,0 +1,17 @@ + + + + + + + Code + + + +

Confirm your email address

+

Your confirmation code is below β€” enter it in the browser window where you've started signing up for Infisical.

+

{{code}}

+

Questions about setting up Infisical? Email us at support@infisical.com

+ + + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars index 60556555c..39a9b74ba 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -1,15 +1,15 @@ - - - - + + + Project Invitation - - + +

Join your team on Infisical

-

{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical project β€” {{workspaceName}}

+

You have been invited to a new Infisical project β€” {{workspaceName}}

Join now

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- +

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets + and configs.

+ \ No newline at end of file diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 48a192e16..bec8f3f37 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,4 +1,5 @@ import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; @@ -14,6 +15,7 @@ type TSuperAdminServiceFactoryDep = { userDAL: TUserDALFactory; authService: Pick; orgService: Pick; + keyStore: Pick; }; export type TSuperAdminServiceFactory = ReturnType; @@ -21,26 +23,53 @@ export type TSuperAdminServiceFactory = ReturnType Promise; +const ADMIN_CONFIG_KEY = "infisical-admin-cfg"; +const ADMIN_CONFIG_KEY_EXP = 60; // 60s +const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; + export const superAdminServiceFactory = ({ serverCfgDAL, userDAL, authService, - orgService + orgService, + keyStore }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself - getServerCfg = () => serverCfgDAL.findOne({}); + getServerCfg = async () => { + const config = await keyStore.getItem(ADMIN_CONFIG_KEY); + // missing in keystore means fetch from db + if (!config) { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (serverCfg) { + await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(serverCfg)); // insert it back to keystore + } + return serverCfg; + } - const serverCfg = await serverCfgDAL.findOne({}); + const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin; + return { + ...keyStoreServerCfg, + // this is to allow admin router to work + createdAt: new Date(keyStoreServerCfg.createdAt), + updatedAt: new Date(keyStoreServerCfg.updatedAt) + }; + }; + + // reset on initialized + await keyStore.deleteItem(ADMIN_CONFIG_KEY); + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (serverCfg) return; - const newCfg = await serverCfgDAL.create({ initialized: false, allowSignUp: true }); + + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + const newCfg = await serverCfgDAL.create({ initialized: false, allowSignUp: true, id: ADMIN_CONFIG_DB_UUID }); return newCfg; }; const updateServerCfg = async (data: TSuperAdminUpdate) => { - const serverCfg = await getServerCfg(); - const cfg = await serverCfgDAL.updateById(serverCfg.id, data); - return cfg; + const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data); + await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); + return updatedServerCfg; }; const adminSignUp = async ({ @@ -68,10 +97,13 @@ export const superAdminServiceFactory = ({ { firstName, lastName, + username: email, email, superAdmin: true, + isGhost: false, isAccepted: true, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isEmailVerified: true }, tx ); @@ -96,12 +128,22 @@ export const superAdminServiceFactory = ({ const initialOrganizationName = appCfg.INITIAL_ORGANIZATION_NAME ?? "Admin Org"; - await orgService.createOrganization(userInfo.user.id, userInfo.user.email, initialOrganizationName); + const organization = await orgService.createOrganization({ + userId: userInfo.user.id, + userEmail: userInfo.user.email, + orgName: initialOrganizationName + }); await updateServerCfg({ initialized: true }); - const token = await authService.generateUserTokens(userInfo.user, ip, userAgent); + const token = await authService.generateUserTokens({ + user: userInfo.user, + authMethod: AuthMethod.EMAIL, + ip, + userAgent, + organizationId: undefined + }); // TODO(akhilmhdh-pg): telemetry service - return { token, user: userInfo }; + return { token, user: userInfo, organization }; }; return { diff --git a/backend/src/services/telemetry/telemetry-dal.ts b/backend/src/services/telemetry/telemetry-dal.ts new file mode 100644 index 000000000..9fac4f1ef --- /dev/null +++ b/backend/src/services/telemetry/telemetry-dal.ts @@ -0,0 +1,39 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; + +export type TTelemetryDALFactory = ReturnType; + +export const telemetryDALFactory = (db: TDbClient) => { + const getTelemetryInstanceStats = async () => { + try { + const userCount = (await db(TableName.Users).where({ isGhost: false }).count().first())?.count as string; + const users = parseInt(userCount || "0", 10); + + const identityCount = (await db(TableName.Identity).count().first())?.count as string; + const identities = parseInt(identityCount || "0", 10); + + const projectCount = (await db(TableName.Project).count().first())?.count as string; + const projects = parseInt(projectCount || "0", 10); + + const secretCount = (await db(TableName.Secret).count().first())?.count as string; + const secrets = parseInt(secretCount || "0", 10); + + const organizationNames = await db(TableName.Organization).select("name"); + const organizations = organizationNames.length; + + return { + users, + identities, + projects, + secrets, + organizations, + organizationNames: organizationNames.map(({ name }) => name) + }; + } catch (error) { + throw new DatabaseError({ error, name: "TelemtryInstanceStats" }); + } + }; + + return { getTelemetryInstanceStats }; +}; diff --git a/backend/src/services/telemetry/telemetry-queue.ts b/backend/src/services/telemetry/telemetry-queue.ts new file mode 100644 index 000000000..02e906fe6 --- /dev/null +++ b/backend/src/services/telemetry/telemetry-queue.ts @@ -0,0 +1,78 @@ +import { PostHog } from "posthog-node"; + +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { getServerCfg } from "../super-admin/super-admin-service"; +import { TTelemetryDALFactory } from "./telemetry-dal"; +import { TELEMETRY_SECRET_OPERATIONS_KEY, TELEMETRY_SECRET_PROCESSED_KEY } from "./telemetry-service"; +import { PostHogEventTypes } from "./telemetry-types"; + +type TTelemetryQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + keyStore: Pick; + telemetryDAL: TTelemetryDALFactory; +}; + +export type TTelemetryQueueServiceFactory = ReturnType; + +export const telemetryQueueServiceFactory = ({ + queueService, + keyStore, + telemetryDAL +}: TTelemetryQueueServiceFactoryDep) => { + const appCfg = getConfig(); + const postHog = + appCfg.isProductionMode && appCfg.TELEMETRY_ENABLED + ? new PostHog(appCfg.POSTHOG_PROJECT_API_KEY, { host: appCfg.POSTHOG_HOST, flushAt: 1, flushInterval: 0 }) + : undefined; + + queueService.start(QueueName.TelemetryInstanceStats, async () => { + const { instanceId } = await getServerCfg(); + const telemtryStats = await telemetryDAL.getTelemetryInstanceStats(); + // parse the redis values into integer + const numberOfSecretOperationsMade = parseInt((await keyStore.getItem(TELEMETRY_SECRET_OPERATIONS_KEY)) || "0", 10); + const numberOfSecretProcessed = parseInt((await keyStore.getItem(TELEMETRY_SECRET_PROCESSED_KEY)) || "0", 10); + const stats = { ...telemtryStats, numberOfSecretProcessed, numberOfSecretOperationsMade }; + + // send to postHog + postHog?.capture({ + event: PostHogEventTypes.TelemetryInstanceStats, + distinctId: instanceId, + properties: stats + }); + // reset the stats + await keyStore.deleteItem(TELEMETRY_SECRET_PROCESSED_KEY); + await keyStore.deleteItem(TELEMETRY_SECRET_OPERATIONS_KEY); + }); + + // every day at midnight a telemetry job executes on self hosted + // this sends some telemetry information like instance id secrets operated etc + const startTelemetryCheck = async () => { + // this is a fast way to check its cloud or not + if (appCfg.INFISICAL_CLOUD) return; + // clear previous job + await queueService.stopRepeatableJob( + QueueName.TelemetryInstanceStats, + QueueJobs.TelemetryInstanceStats, + { pattern: "0 0 * * *", utc: true }, + QueueName.TelemetryInstanceStats // just a job id + ); + if (postHog) { + await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, { + jobId: QueueName.TelemetryInstanceStats, + repeat: { pattern: "0 0 * * *", utc: true } + }); + } + }; + + queueService.listen(QueueName.TelemetryInstanceStats, "failed", (err) => { + logger.error(err?.failedReason, `${QueueName.TelemetryInstanceStats}: failed`); + }); + + return { + startTelemetryCheck + }; +}; diff --git a/backend/src/services/telemetry/telemetry-service.ts b/backend/src/services/telemetry/telemetry-service.ts index c386abd95..9912e0101 100644 --- a/backend/src/services/telemetry/telemetry-service.ts +++ b/backend/src/services/telemetry/telemetry-service.ts @@ -1,15 +1,24 @@ import { PostHog } from "posthog-node"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { InstanceType } from "@app/ee/services/license/license-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { logger } from "@app/lib/logger"; -import { TPostHogEvent } from "./telemetry-types"; +import { PostHogEventTypes, TPostHogEvent, TSecretModifiedEvent } from "./telemetry-types"; + +export const TELEMETRY_SECRET_PROCESSED_KEY = "telemetry-secret-processed"; +export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations"; export type TTelemetryServiceFactory = ReturnType; +export type TTelemetryServiceFactoryDep = { + keyStore: Pick; + licenseService: Pick; +}; -// type TTelemetryServiceFactoryDep = {}; -export const telemetryServiceFactory = () => { +export const telemetryServiceFactory = ({ keyStore, licenseService }: TTelemetryServiceFactoryDep) => { const appCfg = getConfig(); if (appCfg.isProductionMode && !appCfg.TELEMETRY_ENABLED) { @@ -21,10 +30,9 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme `); } - const postHog = - appCfg.isProductionMode && appCfg.TELEMETRY_ENABLED - ? new PostHog(appCfg.POSTHOG_PROJECT_API_KEY, { host: appCfg.POSTHOG_HOST }) - : undefined; + const postHog = appCfg.TELEMETRY_ENABLED + ? new PostHog(appCfg.POSTHOG_PROJECT_API_KEY, { host: appCfg.POSTHOG_HOST }) + : undefined; // used for email marketting email sending purpose const sendLoopsEvent = async (email: string, firstName?: string, lastName?: string) => { @@ -51,18 +59,45 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme } }; - const sendPostHogEvents = (event: TPostHogEvent) => { + const sendPostHogEvents = async (event: TPostHogEvent) => { if (postHog) { - postHog.capture({ - event: event.event, - distinctId: event.distinctId, - properties: event.properties - }); + const instanceType = licenseService.getInstanceType(); + // capture posthog only when its cloud or signup event happens in self hosted + if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) { + postHog.capture({ + event: event.event, + distinctId: event.distinctId, + properties: event.properties + }); + return; + } + + if ( + [ + PostHogEventTypes.SecretPulled, + PostHogEventTypes.SecretCreated, + PostHogEventTypes.SecretDeleted, + PostHogEventTypes.SecretUpdated + ].includes(event.event) + ) { + await keyStore.incrementBy( + TELEMETRY_SECRET_PROCESSED_KEY, + (event as TSecretModifiedEvent).properties.numberOfSecrets + ); + await keyStore.incrementBy(TELEMETRY_SECRET_OPERATIONS_KEY, 1); + } + } + }; + + const flushAll = async () => { + if (postHog) { + await postHog.shutdownAsync(); } }; return { sendLoopsEvent, - sendPostHogEvents + sendPostHogEvents, + flushAll }; }; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index d97c3c7ac..b168fe5d7 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -8,7 +8,12 @@ export enum PostHogEventTypes { UserSignedUp = "User Signed Up", SecretRotated = "secrets rotated", SecretScannerFull = "historical cloud secret scan", - SecretScannerPush = "cloud secret scan" + SecretScannerPush = "cloud secret scan", + ProjectCreated = "Project Created", + IntegrationCreated = "Integration Created", + MachineIdentityCreated = "Machine Identity Created", + UserOrgInvitation = "User Org Invitation", + TelemetryInstanceStats = "Self Hosted Instance Stats" } export type TSecretModifiedEvent = { @@ -32,6 +37,7 @@ export type TSecretModifiedEvent = { export type TAdminInitEvent = { event: PostHogEventTypes.AdminInit; properties: { + username: string; email: string; firstName: string; lastName: string; @@ -41,6 +47,7 @@ export type TAdminInitEvent = { export type TUserSignedUpEvent = { event: PostHogEventTypes.UserSignedUp; properties: { + username: string; email: string; attributionSource?: string; }; @@ -53,9 +60,72 @@ export type TSecretScannerEvent = { }; }; +export type TProjectCreateEvent = { + event: PostHogEventTypes.ProjectCreated; + properties: { + name: string; + orgId: string; + }; +}; + +export type TMachineIdentityCreatedEvent = { + event: PostHogEventTypes.MachineIdentityCreated; + properties: { + name: string; + orgId: string; + identityId: string; + }; +}; + +export type TIntegrationCreatedEvent = { + event: PostHogEventTypes.IntegrationCreated; + properties: { + projectId: string; + 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; + }; +}; + +export type TUserOrgInvitedEvent = { + event: PostHogEventTypes.UserOrgInvitation; + properties: { + inviteeEmail: string; + }; +}; + +export type TTelemetryInstanceStatsEvent = { + event: PostHogEventTypes.TelemetryInstanceStats; + properties: { + users: number; + identities: number; + projects: number; + secrets: number; + organizations: number; + organizationNames: number; + numberOfSecretOperationsMade: number; + numberOfSecretProcessed: number; + }; +}; + export type TPostHogEvent = { distinctId: string } & ( | TSecretModifiedEvent | TAdminInitEvent | TUserSignedUpEvent | TSecretScannerEvent + | TUserOrgInvitedEvent + | TMachineIdentityCreatedEvent + | TIntegrationCreatedEvent + | TProjectCreateEvent + | TTelemetryInstanceStatsEvent ); diff --git a/backend/src/services/user-alias/user-alias-dal.ts b/backend/src/services/user-alias/user-alias-dal.ts new file mode 100644 index 000000000..366eadce0 --- /dev/null +++ b/backend/src/services/user-alias/user-alias-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TUserAliasDALFactory = ReturnType; + +export const userAliasDALFactory = (db: TDbClient) => { + const userAliasOrm = ormify(db, TableName.UserAliases); + + return { + ...userAliasOrm + }; +}; diff --git a/backend/src/services/user-alias/user-alias-types.ts b/backend/src/services/user-alias/user-alias-types.ts new file mode 100644 index 000000000..09204644f --- /dev/null +++ b/backend/src/services/user-alias/user-alias-types.ts @@ -0,0 +1,4 @@ +export enum UserAliasType { + LDAP = "ldap", + SAML = "saml" +} diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 0de490399..f2da0df0e 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -16,14 +16,17 @@ export type TUserDALFactory = ReturnType; export const userDALFactory = (db: TDbClient) => { const userOrm = ormify(db, TableName.Users); - const findUserByEmail = async (email: string, tx?: Knex) => userOrm.findOne({ email }, tx); + const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx); // USER ENCRYPTION FUNCTIONS // ------------------------- - const findUserEncKeyByEmail = async (email: string) => { + const findUserEncKeyByUsername = async ({ username }: { username: string }) => { try { return await db(TableName.Users) - .where({ email }) + .where({ + username, + isGhost: false + }) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .first(); } catch (error) { @@ -31,6 +34,19 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findUserEncKeyByUserIdsBatch = async ({ userIds }: { userIds: string[] }, tx?: Knex) => { + try { + return await (tx || db)(TableName.Users) + .where({ + isGhost: false + }) + .whereIn(`${TableName.Users}.id`, userIds) + .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`); + } catch (error) { + throw new DatabaseError({ error, name: "Find user enc by user ids batch" }); + } + }; + const findUserEncKeyByUserId = async (userId: string) => { try { const user = await db(TableName.Users) @@ -47,6 +63,28 @@ export const userDALFactory = (db: TDbClient) => { } }; + const findUserByProjectMembershipId = async (projectMembershipId: string) => { + try { + return await db(TableName.ProjectMembership) + .where({ [`${TableName.ProjectMembership}.id` as "id"]: projectMembershipId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .first(); + } catch (error) { + throw new DatabaseError({ error, name: "Find user by project membership id" }); + } + }; + + const findUsersByProjectMembershipIds = async (projectMembershipIds: string[]) => { + try { + return await db(TableName.ProjectMembership) + .whereIn(`${TableName.ProjectMembership}.id`, projectMembershipIds) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .select("*"); + } catch (error) { + throw new DatabaseError({ error, name: "Find users by project membership ids" }); + } + }; + const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => { try { const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*"); @@ -107,10 +145,13 @@ export const userDALFactory = (db: TDbClient) => { return { ...userOrm, - findUserByEmail, - findUserEncKeyByEmail, + findUserByUsername, + findUserEncKeyByUsername, + findUserEncKeyByUserIdsBatch, findUserEncKeyByUserId, updateUserEncryptionByUserId, + findUserByProjectMembershipId, + findUsersByProjectMembershipIds, upsertUserEncryptionKey, createUserEncryption, findOneUserAction, diff --git a/backend/src/services/user/user-fns.ts b/backend/src/services/user/user-fns.ts new file mode 100644 index 000000000..639320e24 --- /dev/null +++ b/backend/src/services/user/user-fns.ts @@ -0,0 +1,21 @@ +import slugify from "@sindresorhus/slugify"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +export const normalizeUsername = async (username: string, userDAL: Pick) => { + let attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); + + let user = await userDAL.findOne({ username: attempt }); + if (!user) return attempt; + + while (true) { + attempt = slugify(`${username}-${alphaNumericNanoId(4)}`); + // eslint-disable-next-line no-await-in-loop + user = await userDAL.findOne({ username: attempt }); + + if (!user) { + return attempt; + } + } +}; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index b700869c2..089f3b8c6 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,16 +1,156 @@ import { BadRequestError } from "@app/lib/errors"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { - userDAL: TUserDALFactory; + userDAL: Pick< + TUserDALFactory, + | "find" + | "findOne" + | "findById" + | "transaction" + | "updateById" + | "update" + | "deleteById" + | "findOneUserAction" + | "createUserAction" + | "findUserEncKeyByUserId" + >; + userAliasDAL: Pick; + orgMembershipDAL: Pick; + tokenService: Pick; + smtpService: Pick; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ + userDAL, + userAliasDAL, + orgMembershipDAL, + tokenService, + smtpService +}: TUserServiceFactoryDep) => { + const sendEmailVerificationCode = async (username: string) => { + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + }; + + const verifyEmailVerificationCode = async (username: string, code: string) => { + const user = await userDAL.findOne({ username }); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id, + code + }); + + const { email } = user; + + await userDAL.transaction(async (tx) => { + await userDAL.updateById( + user.id, + { + isEmailVerified: true + }, + tx + ); + + // check if there are users with the same email. + const users = await userDAL.find( + { + email, + isEmailVerified: true + }, + { tx } + ); + + if (users.length > 1) { + // merge users + const mergeUser = users.find((u) => u.id !== user.id); + if (!mergeUser) throw new BadRequestError({ name: "Failed to find merge user" }); + + const mergeUserOrgMembershipSet = new Set( + (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) + ); + const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter( + (m) => !mergeUserOrgMembershipSet.has(m.orgId) + ); + + const userAliases = await userAliasDAL.find( + { + userId: user.id + }, + { tx } + ); + await userDAL.deleteById(user.id, tx); + + if (myOrgMemberships.length) { + await orgMembershipDAL.insertMany( + myOrgMemberships.map((orgMembership) => ({ + ...orgMembership, + userId: mergeUser.id + })), + tx + ); + } + + if (userAliases.length) { + await userAliasDAL.insertMany( + userAliases.map((userAlias) => ({ + ...userAlias, + userId: mergeUser.id + })), + tx + ); + } + } else { + // update current user's username to [email] + await userDAL.updateById( + user.id, + { + username: email + }, + tx + ); + } + }); + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { + const user = await userDAL.findById(userId); + + if (!user || !user.email) throw new BadRequestError({ name: "Failed to toggle MFA" }); + const updatedUser = await userDAL.updateById(userId, { isMfaEnabled, mfaMethods: isMfaEnabled ? ["email"] : [] @@ -30,14 +170,11 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { const user = await userDAL.findById(userId); if (!user) throw new BadRequestError({ name: "Update auth methods" }); - const hasSamlEnabled = user?.authMethods?.some((method) => - [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes(method as AuthMethod) - ); - if (hasSamlEnabled) - throw new BadRequestError({ - name: "Update auth method", - message: "Failed to update auth methods due to SAML SSO " - }); + if (user.authMethods?.includes(AuthMethod.LDAP)) + throw new BadRequestError({ message: "LDAP auth method cannot be updated", name: "Update auth methods" }); + + if (authMethods.includes(AuthMethod.LDAP)) + throw new BadRequestError({ message: "LDAP auth method cannot be updated", name: "Update auth methods" }); const updatedUser = await userDAL.updateById(userId, { authMethods }); return updatedUser; @@ -71,6 +208,8 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { }; return { + sendEmailVerificationCode, + verifyEmailVerificationCode, toggleUserMfa, updateUserName, updateAuthMethods, diff --git a/backend/src/services/webhook/webhook-service.ts b/backend/src/services/webhook/webhook-service.ts index c208ba472..4a05ad219 100644 --- a/backend/src/services/webhook/webhook-service.ts +++ b/backend/src/services/webhook/webhook-service.ts @@ -30,13 +30,21 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer const createWebhook = async ({ actor, actorId, + actorOrgId, + actorAuthMethod, projectId, webhookUrl, environment, secretPath, webhookSecretKey }: TCreateWebhookDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Webhooks); const env = await projectEnvDAL.findOne({ projectId, slug: environment }); if (!env) throw new BadRequestError({ message: "Env not found" }); @@ -72,33 +80,51 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer return { ...webhook, projectId, environment: env }; }; - const updateWebhook = async ({ actorId, actor, id, isDisabled }: TUpdateWebhookDTO) => { + const updateWebhook = async ({ actorId, actor, actorOrgId, actorAuthMethod, id, isDisabled }: TUpdateWebhookDTO) => { const webhook = await webhookDAL.findById(id); if (!webhook) throw new BadRequestError({ message: "Webhook not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, webhook.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + webhook.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); const updatedWebhook = await webhookDAL.updateById(id, { isDisabled }); return { ...webhook, ...updatedWebhook }; }; - const deleteWebhook = async ({ id, actor, actorId }: TDeleteWebhookDTO) => { + const deleteWebhook = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteWebhookDTO) => { const webhook = await webhookDAL.findById(id); if (!webhook) throw new BadRequestError({ message: "Webhook not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, webhook.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + webhook.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); const deletedWebhook = await webhookDAL.deleteById(id); return { ...webhook, ...deletedWebhook }; }; - const testWebhook = async ({ id, actor, actorId }: TTestWebhookDTO) => { + const testWebhook = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TTestWebhookDTO) => { const webhook = await webhookDAL.findById(id); if (!webhook) throw new BadRequestError({ message: "Webhook not found" }); - const { permission } = await permissionService.getProjectPermission(actor, actorId, webhook.projectId); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + webhook.projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); let webhookError: string | undefined; @@ -118,8 +144,22 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer return { ...webhook, ...updatedWebhook }; }; - const listWebhooks = async ({ actorId, actor, projectId, secretPath, environment }: TListWebhookDTO) => { - const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + const listWebhooks = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + secretPath, + environment + }: TListWebhookDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); return webhookDAL.findAllWebhooks(projectId, environment, secretPath); diff --git a/backend/tsup.config.js b/backend/tsup.config.js index 4e182b9d2..9e37870ba 100644 --- a/backend/tsup.config.js +++ b/backend/tsup.config.js @@ -23,7 +23,8 @@ export default defineConfig({ loader: { ".handlebars": "copy", ".md": "copy", - ".txt": "copy" + ".txt": "copy", + ".pem": "copy" }, external: ["../../../frontend/node_modules/next/dist/server/next-server.js"], outDir: "dist", diff --git a/backend/vitest.e2e.config.ts b/backend/vitest.e2e.config.ts index e8636d405..c660fed14 100644 --- a/backend/vitest.e2e.config.ts +++ b/backend/vitest.e2e.config.ts @@ -4,12 +4,16 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, + env: { + NODE_ENV: "test" + }, environment: "./e2e-test/vitest-environment-knex.ts", include: ["./e2e-test/**/*.spec.ts"], poolOptions: { threads: { singleThread: true, - useAtomics: true + useAtomics: true, + isolate: false } } }, diff --git a/cli/.gitignore b/cli/.gitignore index dcc148f21..5fa3e39c5 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,2 +1,3 @@ .infisical.json dist/ +agent-config.test.yaml diff --git a/cli/agent-config.yaml b/cli/agent-config.yaml index ae130d3a8..e767fdca5 100644 --- a/cli/agent-config.yaml +++ b/cli/agent-config.yaml @@ -1,5 +1,5 @@ infisical: - address: "http://localhost:8080" + address: "https://app.infisical.com/" auth: type: "universal-auth" config: @@ -13,3 +13,12 @@ sinks: templates: - source-path: my-dot-ev-secret-template destination-path: my-dot-env.env + config: + polling-interval: 60s + execute: + command: docker-compose -f docker-compose.prod.yml down && docker-compose -f docker-compose.prod.yml up -d + - source-path: my-dot-ev-secret-template1 + destination-path: my-dot-env-1.env + config: + exec: + command: mkdir hello-world1 diff --git a/cli/go.mod b/cli/go.mod index d3b8eff0c..833745eff 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -29,6 +29,7 @@ require ( require ( github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect + github.com/bradleyjkemp/cupaloy/v2 v2.8.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index a73ac5185..353579136 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -51,6 +51,8 @@ github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGL github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= @@ -324,6 +326,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 9ae356cd0..01f29a03a 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -145,6 +145,47 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo return loginTwoV2Response, nil } +func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse, error) { + var orgResponse GetOrganizationsResponse + response, err := httpClient. + R(). + SetResult(&orgResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/organization", config.INFISICAL_URL)) + + if err != nil { + return GetOrganizationsResponse{}, err + } + + if response.IsError() { + return GetOrganizationsResponse{}, fmt.Errorf("CallGetAllOrganizations: Unsuccessful response: [response=%v]", response) + } + + return orgResponse, nil +} + +func CallSelectOrganization(httpClient *resty.Client, request SelectOrganizationRequest) (SelectOrganizationResponse, error) { + var selectOrgResponse SelectOrganizationResponse + + response, err := httpClient. + R(). + SetBody(request). + SetResult(&selectOrgResponse). + SetHeader("User-Agent", USER_AGENT). + Post(fmt.Sprintf("%v/v3/auth/select-organization", config.INFISICAL_URL)) + + if err != nil { + return SelectOrganizationResponse{}, err + } + + if response.IsError() { + return SelectOrganizationResponse{}, fmt.Errorf("CallSelectOrganization: Unsuccessful response: [response=%v]", response) + } + + return selectOrgResponse, nil + +} + func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesResponse, error) { var workSpacesResponse GetWorkSpacesResponse response, err := httpClient. @@ -236,6 +277,10 @@ func CallGetSecretsV3(httpClient *resty.Client, request GetEncryptedSecretsV3Req SetQueryParam("environment", request.Environment). SetQueryParam("workspaceId", request.WorkspaceId) + if request.Recursive { + httpRequest.SetQueryParam("recursive", "true") + } + if request.IncludeImport { httpRequest.SetQueryParam("include_imports", "true") } @@ -365,14 +410,14 @@ func CallDeleteSecretsV3(httpClient *resty.Client, request DeleteSecretV3Request return nil } -func CallUpdateSecretsV3(httpClient *resty.Client, request UpdateSecretByNameV3Request) error { +func CallUpdateSecretsV3(httpClient *resty.Client, request UpdateSecretByNameV3Request, secretName string) error { var secretsResponse GetEncryptedSecretsV3Response response, err := httpClient. R(). SetResult(&secretsResponse). SetHeader("User-Agent", USER_AGENT). SetBody(request). - Patch(fmt.Sprintf("%v/v3/secrets/%s", config.INFISICAL_URL, request.SecretName)) + Patch(fmt.Sprintf("%v/v3/secrets/%s", config.INFISICAL_URL, secretName)) if err != nil { return fmt.Errorf("CallUpdateSecretsV3: Unable to complete api request [err=%s]", err) @@ -467,16 +512,23 @@ func CallUniversalAuthRefreshAccessToken(httpClient *resty.Client, request Unive func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Request) (GetRawSecretsV3Response, error) { var getRawSecretsV3Response GetRawSecretsV3Response - response, err := httpClient. + req := httpClient. R(). SetResult(&getRawSecretsV3Response). SetHeader("User-Agent", USER_AGENT). SetBody(request). SetQueryParam("workspaceId", request.WorkspaceId). SetQueryParam("environment", request.Environment). - SetQueryParam("secretPath", request.SecretPath). - SetQueryParam("include_imports", "false"). - Get(fmt.Sprintf("%v/v3/secrets/raw", config.INFISICAL_URL)) + SetQueryParam("secretPath", request.SecretPath) + + if request.IncludeImport { + req.SetQueryParam("include_imports", "true") + } + if request.Recursive { + req.SetQueryParam("recursive", "true") + } + + response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", config.INFISICAL_URL)) if err != nil { return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unable to complete api request [err=%w]", err) @@ -490,5 +542,27 @@ func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Reques return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) } + getRawSecretsV3Response.ETag = response.Header().Get(("etag")) + return getRawSecretsV3Response, nil } + +func CallCreateDynamicSecretLeaseV1(httpClient *resty.Client, request CreateDynamicSecretLeaseV1Request) (CreateDynamicSecretLeaseV1Response, error) { + var createDynamicSecretLeaseResponse CreateDynamicSecretLeaseV1Response + response, err := httpClient. + R(). + SetResult(&createDynamicSecretLeaseResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/dynamic-secrets/leases", config.INFISICAL_URL)) + + if err != nil { + return CreateDynamicSecretLeaseV1Response{}, fmt.Errorf("CreateDynamicSecretLeaseV1: Unable to complete api request [err=%w]", err) + } + + if response.IsError() { + return CreateDynamicSecretLeaseV1Response{}, fmt.Errorf("CreateDynamicSecretLeaseV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + } + + return createDynamicSecretLeaseResponse, nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 3c6466382..56b9807f7 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -120,14 +120,29 @@ type PullSecretsByInfisicalTokenResponse struct { type GetWorkSpacesResponse struct { Workspaces []struct { - ID string `json:"_id"` - Name string `json:"name"` - Plan string `json:"plan,omitempty"` - V int `json:"__v"` - Organization string `json:"organization,omitempty"` + ID string `json:"_id"` + Name string `json:"name"` + Plan string `json:"plan,omitempty"` + V int `json:"__v"` + OrganizationId string `json:"orgId"` } `json:"workspaces"` } +type GetOrganizationsResponse struct { + Organizations []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"organizations"` +} + +type SelectOrganizationResponse struct { + Token string `json:"token"` +} + +type SelectOrganizationRequest struct { + OrganizationId string `json:"organizationId"` +} + type Secret struct { SecretKeyCiphertext string `json:"secretKeyCiphertext,omitempty"` SecretKeyIV string `json:"secretKeyIV,omitempty"` @@ -276,6 +291,7 @@ type GetEncryptedSecretsV3Request struct { WorkspaceId string `json:"workspaceId"` SecretPath string `json:"secretPath"` IncludeImport bool `json:"include_imports"` + Recursive bool `json:"recursive"` } type GetFoldersV1Request struct { @@ -292,10 +308,10 @@ type GetFoldersV1Response struct { } type CreateFolderV1Request struct { - FolderName string `json:"folderName"` + FolderName string `json:"name"` WorkspaceId string `json:"workspaceId"` Environment string `json:"environment"` - Directory string `json:"directory"` + Path string `json:"path"` } type CreateFolderV1Response struct { @@ -355,6 +371,22 @@ type ImportedSecretV3 struct { Secrets []EncryptedSecretV3 `json:"secrets"` } +type ImportedRawSecretV3 struct { + SecretPath string `json:"secretPath"` + Environment string `json:"environment"` + FolderId string `json:"folderId"` + Secrets []struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + Environment string `json:"environment"` + Version int `json:"version"` + Type string `json:"type"` + SecretKey string `json:"secretKey"` + SecretValue string `json:"secretValue"` + SecretComment string `json:"secretComment"` + } `json:"secrets"` +} + type GetEncryptedSecretsV3Response struct { Secrets []EncryptedSecretV3 `json:"secrets"` ImportedSecrets []ImportedSecretV3 `json:"imports,omitempty"` @@ -386,7 +418,6 @@ type DeleteSecretV3Request struct { } type UpdateSecretByNameV3Request struct { - SecretName string `json:"secretName"` WorkspaceID string `json:"workspaceId"` Environment string `json:"environment"` Type string `json:"type"` @@ -486,11 +517,34 @@ type UniversalAuthRefreshResponse struct { AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` } +type CreateDynamicSecretLeaseV1Request struct { + Environment string `json:"environment"` + ProjectSlug string `json:"projectSlug"` + SecretPath string `json:"secretPath,omitempty"` + Slug string `json:"slug"` + TTL string `json:"ttl,omitempty"` +} + +type CreateDynamicSecretLeaseV1Response struct { + Lease struct { + Id string `json:"id"` + ExpireAt time.Time `json:"expireAt"` + } `json:"lease"` + DynamicSecret struct { + Id string `json:"id"` + DefaultTTL string `json:"defaultTTL"` + MaxTTL string `json:"maxTTL"` + Type string `json:"type"` + } `json:"dynamicSecret"` + Data map[string]interface{} `json:"data"` +} + type GetRawSecretsV3Request struct { Environment string `json:"environment"` WorkspaceId string `json:"workspaceId"` SecretPath string `json:"secretPath"` IncludeImport bool `json:"include_imports"` + Recursive bool `json:"recursive"` } type GetRawSecretsV3Response struct { @@ -504,5 +558,6 @@ type GetRawSecretsV3Response struct { SecretValue string `json:"secretValue"` SecretComment string `json:"secretComment"` } `json:"secrets"` - Imports []any `json:"imports"` + Imports []ImportedRawSecretV3 `json:"imports"` + ETag string } diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 8857f5806..03bf9af4d 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -5,12 +5,16 @@ package cmd import ( "bytes" + "context" "encoding/base64" "fmt" "io/ioutil" "os" + "os/exec" "os/signal" "path" + "runtime" + "slices" "strings" "sync" "syscall" @@ -30,6 +34,9 @@ import ( const DEFAULT_INFISICAL_CLOUD_URL = "https://app.infisical.com" +// duration to reduce from expiry of dynamic leases so that it gets triggered before expiry +const DYNAMIC_SECRET_PRUNE_EXPIRE_BUFFER = -15 + type Config struct { Infisical InfisicalConfig `yaml:"infisical"` Auth AuthConfig `yaml:"auth"` @@ -71,12 +78,165 @@ type Template struct { SourcePath string `yaml:"source-path"` Base64TemplateContent string `yaml:"base64-template-content"` DestinationPath string `yaml:"destination-path"` + + Config struct { // Configurations for the template + PollingInterval string `yaml:"polling-interval"` // How often to poll for changes in the secret + Execute struct { + Command string `yaml:"command"` // Command to execute once the template has been rendered + Timeout int64 `yaml:"timeout"` // Timeout for the command + } `yaml:"execute"` // Command to execute once the template has been rendered + } `yaml:"config"` +} + +func newAgentTemplateChannels(templates []Template) map[string]chan bool { + // we keep each destination as an identifier for various channel + templateChannel := make(map[string]chan bool) + for _, template := range templates { + templateChannel[template.DestinationPath] = make(chan bool) + } + return templateChannel +} + +type DynamicSecretLease struct { + LeaseID string + ExpireAt time.Time + Environment string + SecretPath string + Slug string + ProjectSlug string + Data map[string]interface{} + TemplateIDs []int +} + +type DynamicSecretLeaseManager struct { + leases []DynamicSecretLease + mutex sync.Mutex +} + +func (d *DynamicSecretLeaseManager) Prune() { + d.mutex.Lock() + defer d.mutex.Unlock() + + d.leases = slices.DeleteFunc(d.leases, func(s DynamicSecretLease) bool { + return time.Now().After(s.ExpireAt.Add(DYNAMIC_SECRET_PRUNE_EXPIRE_BUFFER * time.Second)) + }) +} + +func (d *DynamicSecretLeaseManager) Append(lease DynamicSecretLease) { + d.mutex.Lock() + defer d.mutex.Unlock() + + index := slices.IndexFunc(d.leases, func(s DynamicSecretLease) bool { + if lease.SecretPath == s.SecretPath && lease.Environment == s.Environment && lease.ProjectSlug == s.ProjectSlug && lease.Slug == s.Slug { + return true + } + return false + }) + + if index != -1 { + d.leases[index].TemplateIDs = append(d.leases[index].TemplateIDs, lease.TemplateIDs...) + return + } + d.leases = append(d.leases, lease) +} + +func (d *DynamicSecretLeaseManager) RegisterTemplate(projectSlug, environment, secretPath, slug string, templateId int) { + d.mutex.Lock() + defer d.mutex.Unlock() + + index := slices.IndexFunc(d.leases, func(lease DynamicSecretLease) bool { + if lease.SecretPath == secretPath && lease.Environment == environment && lease.ProjectSlug == projectSlug && lease.Slug == slug { + return true + } + return false + }) + + if index != -1 { + d.leases[index].TemplateIDs = append(d.leases[index].TemplateIDs, templateId) + } +} + +func (d *DynamicSecretLeaseManager) GetLease(projectSlug, environment, secretPath, slug string) *DynamicSecretLease { + d.mutex.Lock() + defer d.mutex.Unlock() + + for _, lease := range d.leases { + if lease.SecretPath == secretPath && lease.Environment == environment && lease.ProjectSlug == projectSlug && lease.Slug == slug { + return &lease + } + } + + return nil +} + +// for a given template find the first expiring lease +// The bool indicates whether it contains valid expiry list +func (d *DynamicSecretLeaseManager) GetFirstExpiringLeaseTime(templateId int) (time.Time, bool) { + d.mutex.Lock() + defer d.mutex.Unlock() + + if len(d.leases) == 0 { + return time.Time{}, false + } + + var firstExpiry time.Time + for i, el := range d.leases { + if i == 0 { + firstExpiry = el.ExpireAt + } + newLeaseTime := el.ExpireAt.Add(DYNAMIC_SECRET_PRUNE_EXPIRE_BUFFER * time.Second) + if newLeaseTime.Before(firstExpiry) { + firstExpiry = newLeaseTime + } + } + return firstExpiry, true +} + +func NewDynamicSecretLeaseManager(sigChan chan os.Signal) *DynamicSecretLeaseManager { + manager := &DynamicSecretLeaseManager{} + return manager } func ReadFile(filePath string) ([]byte, error) { return ioutil.ReadFile(filePath) } +func ExecuteCommandWithTimeout(command string, timeout int64) error { + + shell := [2]string{"sh", "-c"} + if runtime.GOOS == "windows" { + shell = [2]string{"cmd", "/C"} + } else { + currentShell := os.Getenv("SHELL") + if currentShell != "" { + shell[0] = currentShell + } + } + + ctx := context.Background() + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + } + + cmd := exec.CommandContext(ctx, shell[0], shell[1], command) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + if exitError, ok := err.(*exec.ExitError); ok { // type assertion + if exitError.ProcessState.ExitCode() == -1 { + return fmt.Errorf("command timed out") + } + } + return err + } else { + return nil + } +} + func FileExists(filepath string) bool { info, err := os.Stat(filepath) if os.IsNotExist(err) { @@ -170,26 +330,66 @@ func ParseAgentConfig(configFile []byte) (*Config, error) { return config, nil } -func secretTemplateFunction(accessToken string) func(string, string, string) ([]models.SingleEnvironmentVariable, error) { +func secretTemplateFunction(accessToken string, existingEtag string, currentEtag *string) func(string, string, string) ([]models.SingleEnvironmentVariable, error) { return func(projectID, envSlug, secretPath string) ([]models.SingleEnvironmentVariable, error) { - secrets, err := util.GetPlainTextSecretsViaMachineIdentity(accessToken, projectID, envSlug, secretPath, false) + res, err := util.GetPlainTextSecretsViaMachineIdentity(accessToken, projectID, envSlug, secretPath, false, false) if err != nil { return nil, err } - return secrets, nil + if existingEtag != res.Etag { + *currentEtag = res.Etag + } + + expandedSecrets := util.ExpandSecrets(res.Secrets, models.ExpandSecretsAuthentication{UniversalAuthAccessToken: accessToken}, "") + + return expandedSecrets, nil } } -func ProcessTemplate(templatePath string, data interface{}, accessToken string) (*bytes.Buffer, error) { +func dynamicSecretTemplateFunction(accessToken string, dynamicSecretManager *DynamicSecretLeaseManager, templateId int) func(...string) (map[string]interface{}, error) { + return func(args ...string) (map[string]interface{}, error) { + argLength := len(args) + if argLength != 4 && argLength != 5 { + return nil, fmt.Errorf("Invalid arguments found for dynamic-secret function. Check template %i", templateId) + } + + projectSlug, envSlug, secretPath, slug, ttl := args[0], args[1], args[2], args[3], "" + if argLength == 5 { + ttl = args[4] + } + dynamicSecretData := dynamicSecretManager.GetLease(projectSlug, envSlug, secretPath, slug) + if dynamicSecretData != nil { + dynamicSecretManager.RegisterTemplate(projectSlug, envSlug, secretPath, slug, templateId) + return dynamicSecretData.Data, nil + } + + res, err := util.CreateDynamicSecretLease(accessToken, projectSlug, envSlug, secretPath, slug, ttl) + if err != nil { + return nil, err + } + + dynamicSecretManager.Append(DynamicSecretLease{LeaseID: res.Lease.Id, ExpireAt: res.Lease.ExpireAt, Environment: envSlug, SecretPath: secretPath, Slug: slug, ProjectSlug: projectSlug, Data: res.Data, TemplateIDs: []int{templateId}}) + return res.Data, nil + } +} + +func ProcessTemplate(templateId int, templatePath string, data interface{}, accessToken string, existingEtag string, currentEtag *string, dynamicSecretManager *DynamicSecretLeaseManager) (*bytes.Buffer, error) { // custom template function to fetch secrets from Infisical - secretFunction := secretTemplateFunction(accessToken) + secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) + dynamicSecretFunction := dynamicSecretTemplateFunction(accessToken, dynamicSecretManager, templateId) funcs := template.FuncMap{ - "secret": secretFunction, + "secret": secretFunction, + "dynamic_secret": dynamicSecretFunction, + "minus": func(a, b int) int { + return a - b + }, + "add": func(a, b int) int { + return a + b + }, } templateName := path.Base(templatePath) - tmpl, err := template.New(templateName).Funcs(funcs).ParseFiles(templatePath) if err != nil { return nil, err @@ -203,7 +403,7 @@ func ProcessTemplate(templatePath string, data interface{}, accessToken string) return &buf, nil } -func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken string) (*bytes.Buffer, error) { +func ProcessBase64Template(templateId int, encodedTemplate string, data interface{}, accessToken string, existingEtag string, currentEtag *string, dynamicSecretLeaser *DynamicSecretLeaseManager) (*bytes.Buffer, error) { // custom template function to fetch secrets from Infisical decoded, err := base64.StdEncoding.DecodeString(encodedTemplate) if err != nil { @@ -212,9 +412,11 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken templateString := string(decoded) - secretFunction := secretTemplateFunction(accessToken) + secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) // TODO: Fix this + dynamicSecretFunction := dynamicSecretTemplateFunction(accessToken, dynamicSecretLeaser, templateId) funcs := template.FuncMap{ - "secret": secretFunction, + "secret": secretFunction, + "dynamic_secret": dynamicSecretFunction, } templateName := "base64Template" @@ -232,7 +434,7 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken return &buf, nil } -type TokenManager struct { +type AgentManager struct { accessToken string accessTokenTTL time.Duration accessTokenMaxTTL time.Duration @@ -241,6 +443,7 @@ type TokenManager struct { mutex sync.Mutex filePaths []Sink // Store file paths if needed templates []Template + dynamicSecretLeases *DynamicSecretLeaseManager clientIdPath string clientSecretPath string newAccessTokenNotificationChan chan bool @@ -249,11 +452,20 @@ type TokenManager struct { exitAfterAuth bool } -func NewTokenManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *TokenManager { - return &TokenManager{filePaths: fileDeposits, templates: templates, clientIdPath: clientIdPath, clientSecretPath: clientSecretPath, newAccessTokenNotificationChan: newAccessTokenNotificationChan, removeClientSecretOnRead: removeClientSecretOnRead, exitAfterAuth: exitAfterAuth} +func NewAgentManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *AgentManager { + return &AgentManager{ + filePaths: fileDeposits, + templates: templates, + clientIdPath: clientIdPath, + clientSecretPath: clientSecretPath, + newAccessTokenNotificationChan: newAccessTokenNotificationChan, + removeClientSecretOnRead: removeClientSecretOnRead, + exitAfterAuth: exitAfterAuth, + } + } -func (tm *TokenManager) SetToken(token string, accessTokenTTL time.Duration, accessTokenMaxTTL time.Duration) { +func (tm *AgentManager) SetToken(token string, accessTokenTTL time.Duration, accessTokenMaxTTL time.Duration) { tm.mutex.Lock() defer tm.mutex.Unlock() @@ -264,7 +476,7 @@ func (tm *TokenManager) SetToken(token string, accessTokenTTL time.Duration, acc tm.newAccessTokenNotificationChan <- true } -func (tm *TokenManager) GetToken() string { +func (tm *AgentManager) GetToken() string { tm.mutex.Lock() defer tm.mutex.Unlock() @@ -272,8 +484,8 @@ func (tm *TokenManager) GetToken() string { } // Fetches a new access token using client credentials -func (tm *TokenManager) FetchNewAccessToken() error { - clientID := os.Getenv("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID") +func (tm *AgentManager) FetchNewAccessToken() error { + clientID := os.Getenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) if clientID == "" { clientIDAsByte, err := ReadFile(tm.clientIdPath) if err != nil { @@ -303,7 +515,7 @@ func (tm *TokenManager) FetchNewAccessToken() error { // save as cache in memory tm.cachedClientSecret = clientSecret - err, loginResponse := universalAuthLogin(clientID, clientSecret) + loginResponse, err := util.UniversalAuthLogin(clientID, clientSecret) if err != nil { return err } @@ -322,7 +534,7 @@ func (tm *TokenManager) FetchNewAccessToken() error { } // Refreshes the existing access token -func (tm *TokenManager) RefreshAccessToken() error { +func (tm *AgentManager) RefreshAccessToken() error { httpClient := resty.New() httpClient.SetRetryCount(10000). SetRetryMaxWaitTime(20 * time.Second). @@ -343,7 +555,7 @@ func (tm *TokenManager) RefreshAccessToken() error { return nil } -func (tm *TokenManager) ManageTokenLifecycle() { +func (tm *AgentManager) ManageTokenLifecycle() { for { accessTokenMaxTTLExpiresInTime := tm.accessTokenFetchedTime.Add(tm.accessTokenMaxTTL - (5 * time.Second)) accessTokenRefreshedTime := tm.accessTokenRefreshedTime @@ -411,7 +623,7 @@ func (tm *TokenManager) ManageTokenLifecycle() { } } -func (tm *TokenManager) WriteTokenToFiles() { +func (tm *AgentManager) WriteTokenToFiles() { token := tm.GetToken() for _, sinkFile := range tm.filePaths { if sinkFile.Type == "file" { @@ -428,53 +640,95 @@ func (tm *TokenManager) WriteTokenToFiles() { } } -func (tm *TokenManager) FetchSecrets() { - log.Info().Msgf("template engine started...") - for { - token := tm.GetToken() - if token != "" { - for _, secretTemplate := range tm.templates { - var processedTemplate *bytes.Buffer - var err error - if secretTemplate.SourcePath != "" { - processedTemplate, err = ProcessTemplate(secretTemplate.SourcePath, nil, token) - } else { - processedTemplate, err = ProcessBase64Template(secretTemplate.Base64TemplateContent, nil, token) - } - - if err != nil { - log.Error().Msgf("template engine: unable to render secrets because %s. Will try again on next cycle", err) - - continue - } - - if err := WriteBytesToFile(processedTemplate, secretTemplate.DestinationPath); err != nil { - log.Error().Msgf("template engine: unable to write secrets to path because %s. Will try again on next cycle", err) - - continue - } - - log.Info().Msgf("template engine: secret template at path %s has been rendered and saved to path %s", secretTemplate.SourcePath, secretTemplate.DestinationPath) - } - - // fetch new secrets every 5 minutes (TODO: add PubSub in the future ) - time.Sleep(5 * time.Minute) - } +func (tm *AgentManager) WriteTemplateToFile(bytes *bytes.Buffer, template *Template) { + if err := WriteBytesToFile(bytes, template.DestinationPath); err != nil { + log.Error().Msgf("template engine: unable to write secrets to path because %s. Will try again on next cycle", err) + return } + log.Info().Msgf("template engine: secret template at path %s has been rendered and saved to path %s", template.SourcePath, template.DestinationPath) } -func universalAuthLogin(clientId string, clientSecret string) (error, api.UniversalAuthLoginResponse) { - httpClient := resty.New() - httpClient.SetRetryCount(10000). - SetRetryMaxWaitTime(20 * time.Second). - SetRetryWaitTime(5 * time.Second) +func (tm *AgentManager) MonitorSecretChanges(secretTemplate Template, templateId int, sigChan chan os.Signal) { - tokenResponse, err := api.CallUniversalAuthLogin(httpClient, api.UniversalAuthLoginRequest{ClientId: clientId, ClientSecret: clientSecret}) - if err != nil { - return err, api.UniversalAuthLoginResponse{} + pollingInterval := time.Duration(5 * time.Minute) + + if secretTemplate.Config.PollingInterval != "" { + interval, err := util.ConvertPollingIntervalToTime(secretTemplate.Config.PollingInterval) + + if err != nil { + log.Error().Msgf("unable to convert polling interval to time because %v", err) + sigChan <- syscall.SIGINT + return + + } else { + pollingInterval = interval + } } - return nil, tokenResponse + var existingEtag string + var currentEtag string + var firstRun = true + + execTimeout := secretTemplate.Config.Execute.Timeout + execCommand := secretTemplate.Config.Execute.Command + + for { + select { + case <-sigChan: + return + default: + { + tm.dynamicSecretLeases.Prune() + token := tm.GetToken() + if token != "" { + var processedTemplate *bytes.Buffer + var err error + + if secretTemplate.SourcePath != "" { + processedTemplate, err = ProcessTemplate(templateId, secretTemplate.SourcePath, nil, token, existingEtag, ¤tEtag, tm.dynamicSecretLeases) + } else { + processedTemplate, err = ProcessBase64Template(templateId, secretTemplate.Base64TemplateContent, nil, token, existingEtag, ¤tEtag, tm.dynamicSecretLeases) + } + + if err != nil { + log.Error().Msgf("unable to process template because %v", err) + } else { + if (existingEtag != currentEtag) || firstRun { + + tm.WriteTemplateToFile(processedTemplate, &secretTemplate) + existingEtag = currentEtag + + if !firstRun && execCommand != "" { + log.Info().Msgf("executing command: %s", execCommand) + err := ExecuteCommandWithTimeout(execCommand, execTimeout) + + if err != nil { + log.Error().Msgf("unable to execute command because %v", err) + } + + } + if firstRun { + firstRun = false + } + } + } + + // now the idea is we pick the next sleep time in which the one shorter out of + // - polling time + // - first lease that's gonna get expired in the template + firstLeaseExpiry, isValid := tm.dynamicSecretLeases.GetFirstExpiringLeaseTime(templateId) + var waitTime = pollingInterval + if isValid && firstLeaseExpiry.Sub(time.Now()) < pollingInterval { + waitTime = firstLeaseExpiry.Sub(time.Now()) + } + time.Sleep(waitTime) + } else { + // It fails to get the access token. So we will re-try in 3 seconds. We do this because if we don't, the user will have to wait for the next polling interval to get the first secret render. + time.Sleep(3 * time.Second) + } + } + } + } } // runCmd represents the run command @@ -520,7 +774,7 @@ var agentCmd = &cobra.Command{ } if !FileExists(configPath) && agentConfigInBase64 == "" { - log.Error().Msgf("No agent config file provided. Please provide a agent config file", configPath) + log.Error().Msgf("No agent config file provided at %v. Please provide a agent config file", configPath) return } @@ -541,10 +795,15 @@ var agentCmd = &cobra.Command{ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) filePaths := agentConfig.Sinks - tm := NewTokenManager(filePaths, agentConfig.Templates, configUniversalAuthType.ClientIDPath, configUniversalAuthType.ClientSecretPath, tokenRefreshNotifier, configUniversalAuthType.RemoveClientSecretOnRead, agentConfig.Infisical.ExitAfterAuth) + tm := NewAgentManager(filePaths, agentConfig.Templates, configUniversalAuthType.ClientIDPath, configUniversalAuthType.ClientSecretPath, tokenRefreshNotifier, configUniversalAuthType.RemoveClientSecretOnRead, agentConfig.Infisical.ExitAfterAuth) + tm.dynamicSecretLeases = NewDynamicSecretLeaseManager(sigChan) go tm.ManageTokenLifecycle() - go tm.FetchSecrets() + + for i, template := range agentConfig.Templates { + log.Info().Msgf("template engine started for template %v...", i+1) + go tm.MonitorSecretChanges(template, i, sigChan) + } for { select { diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index d0db485b3..983c19255 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -7,6 +7,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "os" "strings" "github.com/Infisical/infisical-merge/packages/models" @@ -44,6 +45,11 @@ var exportCmd = &cobra.Command{ util.HandleError(err) } + includeImports, err := cmd.Flags().GetBool("include-imports") + if err != nil { + util.HandleError(err) + } + projectId, err := cmd.Flags().GetString("projectId") if err != nil { util.HandleError(err) @@ -54,12 +60,17 @@ var exportCmd = &cobra.Command{ util.HandleError(err) } + templatePath, err := cmd.Flags().GetString("template") + if err != nil { + util.HandleError(err) + } + secretOverriding, err := cmd.Flags().GetBool("secret-overriding") if err != nil { util.HandleError(err, "Unable to parse flag") } - infisicalToken, err := cmd.Flags().GetString("token") + token, err := util.GetInfisicalToken(cmd) if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -74,7 +85,46 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, WorkspaceId: projectId, SecretsPath: secretsPath}, "") + request := models.GetAllSecretsParameters{ + Environment: environmentName, + TagSlugs: tagSlugs, + WorkspaceId: projectId, + SecretsPath: secretsPath, + IncludeImport: includeImports, + } + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + request.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + request.UniversalAuthAccessToken = token.Token + } + + if templatePath != "" { + sigChan := make(chan os.Signal, 1) + dynamicSecretLeases := NewDynamicSecretLeaseManager(sigChan) + newEtag := "" + + accessToken := "" + if token != nil { + accessToken = token.Token + } else { + log.Debug().Msg("GetAllEnvironmentVariables: Trying to fetch secrets using logged in details") + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + if err != nil { + util.HandleError(err) + } + accessToken = loggedInUserDetails.UserCredentials.JTWToken + } + + processedTemplate, err := ProcessTemplate(1, templatePath, nil, accessToken, "", &newEtag, dynamicSecretLeases) + if err != nil { + util.HandleError(err) + } + fmt.Print(processedTemplate.String()) + return + } + + secrets, err := util.GetAllEnvironmentVariables(request, "") if err != nil { util.HandleError(err, "Unable to fetch secrets") } @@ -87,16 +137,23 @@ var exportCmd = &cobra.Command{ var output string if shouldExpandSecrets { - substitutions := util.ExpandSecrets(secrets, infisicalToken, "") - output, err = formatEnvs(substitutions, format) - if err != nil { - util.HandleError(err) - } - } else { - output, err = formatEnvs(secrets, format) - if err != nil { - util.HandleError(err) + + authParams := models.ExpandSecretsAuthentication{} + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + authParams.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + authParams.UniversalAuthAccessToken = token.Token } + + secrets = util.ExpandSecrets(secrets, authParams, "") + } + secrets = util.FilterSecretsByTag(secrets, tagSlugs) + secrets = util.SortSecretsByKeys(secrets) + + output, err = formatEnvs(secrets, format) + if err != nil { + util.HandleError(err) } fmt.Print(output) @@ -111,10 +168,12 @@ func init() { exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") + exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets") exportCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") exportCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs") exportCmd.Flags().String("projectId", "", "manually set the projectId to fetch secrets from") exportCmd.Flags().String("path", "/", "get secrets within a folder path") + exportCmd.Flags().String("template", "", "The path to the template file used to render secrets") } // Format according to the format flag diff --git a/cli/packages/cmd/folder.go b/cli/packages/cmd/folder.go index 12e2206fa..9cb76a312 100644 --- a/cli/packages/cmd/folder.go +++ b/cli/packages/cmd/folder.go @@ -22,10 +22,6 @@ var folderCmd = &cobra.Command{ var getCmd = &cobra.Command{ Use: "get", Short: "Get folders in a directory", - PersistentPreRun: func(cmd *cobra.Command, args []string) { - util.RequireLocalWorkspaceFile() - util.RequireLogin() - }, Run: func(cmd *cobra.Command, args []string) { environmentName, _ := cmd.Flags().GetString("env") @@ -36,17 +32,33 @@ var getCmd = &cobra.Command{ } } - infisicalToken, err := cmd.Flags().GetString("token") + projectId, err := cmd.Flags().GetString("projectId") if err != nil { util.HandleError(err, "Unable to parse flag") } + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } foldersPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") } - folders, err := util.GetAllFolders(models.GetAllFoldersParameters{Environment: environmentName, InfisicalToken: infisicalToken, FoldersPath: foldersPath}) + request := models.GetAllFoldersParameters{ + Environment: environmentName, + WorkspaceId: projectId, + FoldersPath: foldersPath, + } + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + request.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + request.UniversalAuthAccessToken = token.Token + } + + folders, err := util.GetAllFolders(request) if err != nil { util.HandleError(err, "Unable to get folders") } diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index 070074fa9..99d2ef502 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -5,7 +5,6 @@ package cmd import ( "encoding/json" - "fmt" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" @@ -52,25 +51,19 @@ var initCmd = &cobra.Command{ httpClient := resty.New() httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) - workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) + + organizationResponse, err := api.CallGetAllOrganizations(httpClient) if err != nil { - util.HandleError(err, "Unable to pull projects that belong to you") + util.HandleError(err, "Unable to pull organizations that belong to you") } - workspaces := workspaceResponse.Workspaces - if len(workspaces) == 0 { - message := fmt.Sprintf("You don't have any projects created in Infisical. You must first create a project at %s", util.INFISICAL_TOKEN_NAME) - util.PrintErrorMessageAndExit(message) - } + organizations := organizationResponse.Organizations - var workspaceNames []string - for _, workspace := range workspaces { - workspaceNames = append(workspaceNames, workspace.Name) - } + organizationNames := util.GetOrganizationsNameList(organizationResponse) prompt := promptui.Select{ - Label: "Which of your Infisical projects would you like to connect this project to?", - Items: workspaceNames, + Label: "Which Infisical organization would you like to select a project from?", + Items: organizationNames, Size: 7, } @@ -79,7 +72,42 @@ var initCmd = &cobra.Command{ util.HandleError(err) } - err = writeWorkspaceFile(workspaces[index]) + selectedOrganization := organizations[index] + + tokenResponse, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + + if err != nil { + util.HandleError(err, "Unable to select organization") + } + + // set the config jwt token to the new token + userCreds.UserCredentials.JTWToken = tokenResponse.Token + err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials) + httpClient.SetAuthToken(tokenResponse.Token) + + if err != nil { + util.HandleError(err, "Unable to store your user credentials") + } + + workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) + if err != nil { + util.HandleError(err, "Unable to pull projects that belong to you") + } + + filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrganization.ID) + + prompt = promptui.Select{ + Label: "Which of your Infisical projects would you like to connect this project to?", + Items: workspaceNames, + Size: 7, + } + + index, _, err = prompt.Run() + if err != nil { + util.HandleError(err) + } + + err = writeWorkspaceFile(filteredWorkspaces[index]) if err != nil { util.HandleError(err) } diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 5cff7770f..bbb2c3a05 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -55,95 +55,157 @@ var loginCmd = &cobra.Command{ Short: "Login into your Infisical account", DisableFlagsInUseLine: true, Run: func(cmd *cobra.Command, args []string) { - currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() - // if the key can't be found or there is an error getting current credentials from key ring, allow them to override - if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) { - log.Debug().Err(err) - } else if err != nil { + + loginMethod, err := cmd.Flags().GetString("method") + if err != nil { + util.HandleError(err) + } + plainOutput, err := cmd.Flags().GetBool("plain") + if err != nil { util.HandleError(err) } - if currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 { - shouldOverride, err := userLoginMenu(currentLoggedInUserDetails.UserCredentials.Email) - if err != nil { + if loginMethod != "user" && loginMethod != "universal-auth" { + util.PrintErrorMessageAndExit("Invalid login method. Please use either 'user' or 'universal-auth'") + } + + if loginMethod == "user" { + + currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + // if the key can't be found or there is an error getting current credentials from key ring, allow them to override + if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) { + log.Debug().Err(err) + } else if err != nil { util.HandleError(err) } - if !shouldOverride { - return + if currentLoggedInUserDetails.IsUserLoggedIn && !currentLoggedInUserDetails.LoginExpired && len(currentLoggedInUserDetails.UserCredentials.PrivateKey) != 0 { + shouldOverride, err := userLoginMenu(currentLoggedInUserDetails.UserCredentials.Email) + if err != nil { + util.HandleError(err) + } + + if !shouldOverride { + return + } } - } - //override domain - domainQuery := true - if config.INFISICAL_URL_MANUAL_OVERRIDE != "" && config.INFISICAL_URL_MANUAL_OVERRIDE != util.INFISICAL_DEFAULT_API_URL { - overrideDomain, err := DomainOverridePrompt() - if err != nil { - util.HandleError(err) + //override domain + domainQuery := true + if config.INFISICAL_URL_MANUAL_OVERRIDE != "" && config.INFISICAL_URL_MANUAL_OVERRIDE != util.INFISICAL_DEFAULT_API_URL { + overrideDomain, err := DomainOverridePrompt() + if err != nil { + util.HandleError(err) + } + + //if not override set INFISICAL_URL to exported var + //set domainQuery to false + if !overrideDomain { + domainQuery = false + config.INFISICAL_URL = config.INFISICAL_URL_MANUAL_OVERRIDE + } + } - //if not override set INFISICAL_URL to exported var - //set domainQuery to false - if !overrideDomain { - domainQuery = false - config.INFISICAL_URL = config.INFISICAL_URL_MANUAL_OVERRIDE + //prompt user to select domain between Infisical cloud and self hosting + if domainQuery { + err = askForDomain() + if err != nil { + util.HandleError(err, "Unable to parse domain url") + } } + var userCredentialsToBeStored models.UserCredentials - } - - //prompt user to select domain between Infisical cloud and self hosting - if domainQuery { - err = askForDomain() - if err != nil { - util.HandleError(err, "Unable to parse domain url") - } - } - var userCredentialsToBeStored models.UserCredentials - - interactiveLogin := false - if cmd.Flags().Changed("interactive") { - interactiveLogin = true - cliDefaultLogin(&userCredentialsToBeStored) - } - - //call browser login function - if !interactiveLogin { - fmt.Println("Logging in via browser... To login via interactive mode run [infisical login -i]") - userCredentialsToBeStored, err = browserCliLogin() - if err != nil { - //default to cli login on error + interactiveLogin := false + if cmd.Flags().Changed("interactive") { + interactiveLogin = true cliDefaultLogin(&userCredentialsToBeStored) } + + //call browser login function + if !interactiveLogin { + fmt.Println("Logging in via browser... To login via interactive mode run [infisical login -i]") + userCredentialsToBeStored, err = browserCliLogin() + if err != nil { + //default to cli login on error + cliDefaultLogin(&userCredentialsToBeStored) + } + } + + err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) + if err != nil { + log.Error().Msgf("Unable to store your credentials in system vault [%s]") + log.Error().Msgf("\nTo trouble shoot further, read https://infisical.com/docs/cli/faq") + log.Debug().Err(err) + //return here + util.HandleError(err) + } + + err = util.WriteInitalConfig(&userCredentialsToBeStored) + if err != nil { + util.HandleError(err, "Unable to write write to Infisical Config file. Please try again") + } + + // clear backed up secrets from prev account + util.DeleteBackupSecrets() + + whilte := color.New(color.FgGreen) + boldWhite := whilte.Add(color.Bold) + time.Sleep(time.Second * 1) + boldWhite.Printf(">>>> Welcome to Infisical!") + boldWhite.Printf(" You are now logged in as %v <<<< \n", userCredentialsToBeStored.Email) + + plainBold := color.New(color.Bold) + + plainBold.Println("\nQuick links") + fmt.Println("- Learn to inject secrets into your application at https://infisical.com/docs/cli/usage") + fmt.Println("- Stuck? Join our slack for quick support https://infisical.com/slack") + Telemetry.CaptureEvent("cli-command:login", posthog.NewProperties().Set("infisical-backend", config.INFISICAL_URL).Set("version", util.CLI_VERSION)) + } else if loginMethod == "universal-auth" { + + clientId, err := cmd.Flags().GetString("client-id") + if err != nil { + util.HandleError(err) + } + + clientSecret, err := cmd.Flags().GetString("client-secret") + if err != nil { + util.HandleError(err) + } + + if clientId == "" { + clientId = os.Getenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) + if clientId == "" { + util.PrintErrorMessageAndExit("Please provide client-id") + } + } + if clientSecret == "" { + clientSecret = os.Getenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) + if clientSecret == "" { + util.PrintErrorMessageAndExit("Please provide client-secret") + } + } + + res, err := util.UniversalAuthLogin(clientId, clientSecret) + + if err != nil { + util.HandleError(err) + } + + if plainOutput { + fmt.Println(res.AccessToken) + return + } + + boldGreen := color.New(color.FgGreen).Add(color.Bold) + boldPlain := color.New(color.Bold) + time.Sleep(time.Second * 1) + boldGreen.Printf(">>>> Successfully authenticated with Universal Auth!\n\n") + boldPlain.Printf("Universal Auth Access Token:\n%v", res.AccessToken) + + plainBold := color.New(color.Bold) + plainBold.Println("\n\nYou can use this access token to authenticate through other commands in the CLI.") + } - - err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) - if err != nil { - log.Error().Msgf("Unable to store your credentials in system vault [%s]") - log.Error().Msgf("\nTo trouble shoot further, read https://infisical.com/docs/cli/faq") - log.Debug().Err(err) - //return here - util.HandleError(err) - } - - err = util.WriteInitalConfig(&userCredentialsToBeStored) - if err != nil { - util.HandleError(err, "Unable to write write to Infisical Config file. Please try again") - } - - // clear backed up secrets from prev account - util.DeleteBackupSecrets() - - whilte := color.New(color.FgGreen) - boldWhite := whilte.Add(color.Bold) - time.Sleep(time.Second * 1) - boldWhite.Printf(">>>> Welcome to Infisical!") - boldWhite.Printf(" You are now logged in as %v <<<< \n", userCredentialsToBeStored.Email) - - plainBold := color.New(color.Bold) - - plainBold.Println("\nQuick links") - fmt.Println("- Learn to inject secrets into your application at https://infisical.com/docs/cli/usage") - fmt.Println("- Stuck? Join our slack for quick support https://infisical.com/slack") - Telemetry.CaptureEvent("cli-command:login", posthog.NewProperties().Set("infisical-backend", config.INFISICAL_URL).Set("version", util.CLI_VERSION)) }, } @@ -301,16 +363,22 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) { log.Debug().Msgf("[decryptedPrivateKey=%s] [email=%s] [loginTwoResponse.Token=%s]", string(decryptedPrivateKey), email, loginTwoResponse.Token) util.PrintErrorMessageAndExit("We were unable to fetch required details to complete your login. Run with -d to see more info") } + // Login is successful so ask user to choose organization + newJwtToken := GetJwtTokenWithOrganizationId(loginTwoResponse.Token) //updating usercredentials userCredentialsToBeStored.Email = email userCredentialsToBeStored.PrivateKey = string(decryptedPrivateKey) - userCredentialsToBeStored.JTWToken = loginTwoResponse.Token + userCredentialsToBeStored.JTWToken = newJwtToken } func init() { rootCmd.AddCommand(loginCmd) loginCmd.Flags().BoolP("interactive", "i", false, "login via the command line") + loginCmd.Flags().String("method", "user", "login method [user, universal-auth]") + loginCmd.Flags().String("client-id", "", "client id for universal auth") + loginCmd.Flags().Bool("plain", false, "only output the token without any formatting") + loginCmd.Flags().String("client-secret", "", "client secret for universal auth") } func DomainOverridePrompt() (bool, error) { @@ -480,6 +548,44 @@ func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2R return &loginOneResponseResult, &loginTwoResponseResult, nil } +func GetJwtTokenWithOrganizationId(oldJwtToken string) string { + log.Debug().Msg(fmt.Sprint("GetJwtTokenWithOrganizationId: ", "oldJwtToken", oldJwtToken)) + + httpClient := resty.New() + httpClient.SetAuthToken(oldJwtToken) + + organizationResponse, err := api.CallGetAllOrganizations(httpClient) + + if err != nil { + util.HandleError(err, "Unable to pull organizations that belong to you") + } + + organizations := organizationResponse.Organizations + + organizationNames := util.GetOrganizationsNameList(organizationResponse) + + prompt := promptui.Select{ + Label: "Which Infisical organization would you like to log into?", + Items: organizationNames, + } + + index, _, err := prompt.Run() + if err != nil { + util.HandleError(err) + } + + selectedOrganization := organizations[index] + + selectedOrgRes, err := api.CallSelectOrganization(httpClient, api.SelectOrganizationRequest{OrganizationId: selectedOrganization.ID}) + + if err != nil { + util.HandleError(err) + } + + return selectedOrgRes.Token + +} + func userLoginMenu(currentLoggedInUserEmail string) (bool, error) { label := fmt.Sprintf("Current logged in user email: %s on domain: %s", currentLoggedInUserEmail, config.INFISICAL_URL) diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 9c7814ecc..06846260f 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -40,8 +40,14 @@ func init() { rootCmd.PersistentFlags().StringP("log-level", "l", "info", "log level (trace, debug, info, warn, error, fatal)") rootCmd.PersistentFlags().Bool("telemetry", true, "Infisical collects non-sensitive telemetry data to enhance features and improve user experience. Participation is voluntary") rootCmd.PersistentFlags().StringVar(&config.INFISICAL_URL, "domain", util.INFISICAL_DEFAULT_API_URL, "Point the CLI to your own backend [can also set via environment variable name: INFISICAL_API_URL]") + rootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - if !util.IsRunningInDocker() { + silent, err := cmd.Flags().GetBool("silent") + if err != nil { + util.HandleError(err) + } + + if !util.IsRunningInDocker() && !silent { util.CheckForUpdate() } } diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 2bb043c26..04fe2588b 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -62,7 +62,7 @@ var runCmd = &cobra.Command{ } } - infisicalToken, err := cmd.Flags().GetString("token") + token, err := util.GetInfisicalToken(cmd) if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -72,6 +72,11 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretOverriding, err := cmd.Flags().GetBool("secret-overriding") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -97,7 +102,27 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}, projectConfigDir) + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + request := models.GetAllSecretsParameters{ + Environment: environmentName, + WorkspaceId: projectId, + TagSlugs: tagSlugs, + SecretsPath: secretsPath, + IncludeImport: includeImports, + Recursive: recursive, + } + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + request.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + request.UniversalAuthAccessToken = token.Token + } + + secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir) if err != nil { util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid") @@ -110,7 +135,16 @@ var runCmd = &cobra.Command{ } if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, projectConfigDir) + + authParams := models.ExpandSecretsAuthentication{} + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + authParams.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + authParams.UniversalAuthAccessToken = token.Token + } + + secrets = util.ExpandSecrets(secrets, authParams, projectConfigDir) } secretsByKey := getSecretsByKeys(secrets) @@ -141,7 +175,15 @@ var runCmd = &cobra.Command{ log.Debug().Msgf("injecting the following environment variables into shell: %v", env) - Telemetry.CaptureEvent("cli-command:run", posthog.NewProperties().Set("secretsCount", len(secrets)).Set("environment", environmentName).Set("isUsingServiceToken", infisicalToken != "").Set("single-command", strings.Join(args, " ")).Set("multi-command", cmd.Flag("command").Value.String()).Set("version", util.CLI_VERSION)) + Telemetry.CaptureEvent("cli-command:run", + posthog.NewProperties(). + Set("secretsCount", len(secrets)). + Set("environment", environmentName). + Set("isUsingServiceToken", token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER). + Set("isUsingUniversalAuthToken", token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER). + Set("single-command", strings.Join(args, " ")). + Set("multi-command", cmd.Flag("command").Value.String()). + Set("version", util.CLI_VERSION)) if cmd.Flags().Changed("command") { command := cmd.Flag("command").Value.String() @@ -196,9 +238,11 @@ func filterReservedEnvVars(env map[string]models.SingleEnvironmentVariable) { func init() { rootCmd.AddCommand(runCmd) runCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") + runCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") runCmd.Flags().Bool("include-imports", true, "Import linked secrets ") + runCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") runCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs ") diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index e4e0db126..90239deeb 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "os" "regexp" "sort" "strings" @@ -38,7 +39,12 @@ var secretsCmd = &cobra.Command{ } } - infisicalToken, err := cmd.Flags().GetString("token") + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := cmd.Flags().GetString("projectId") if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -58,6 +64,11 @@ var secretsCmd = &cobra.Command{ util.HandleError(err) } + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + util.HandleError(err) + } + tagSlugs, err := cmd.Flags().GetString("tags") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -73,7 +84,22 @@ var secretsCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}, "") + request := models.GetAllSecretsParameters{ + Environment: environmentName, + WorkspaceId: projectId, + TagSlugs: tagSlugs, + SecretsPath: secretsPath, + IncludeImport: includeImports, + Recursive: recursive, + } + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + request.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + request.UniversalAuthAccessToken = token.Token + } + + secrets, err := util.GetAllEnvironmentVariables(request, "") if err != nil { util.HandleError(err) } @@ -85,9 +111,19 @@ var secretsCmd = &cobra.Command{ } if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, "") + authParams := models.ExpandSecretsAuthentication{} + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + authParams.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + authParams.UniversalAuthAccessToken = token.Token + } + + secrets = util.ExpandSecrets(secrets, authParams, "") } + // Sort the secrets by key so we can create a consistent output + secrets = util.SortSecretsByKeys(secrets) + if plainOutput { for _, secret := range secrets { fmt.Println(secret.Value) @@ -180,8 +216,10 @@ var secretsSetCmd = &cobra.Command{ // decrypt workspace key plainTextEncryptionKey := crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey) + infisicalTokenEnv := os.Getenv(util.INFISICAL_TOKEN_NAME) + // pull current secrets - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, SecretsPath: secretsPath}, "") + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, SecretsPath: secretsPath, InfisicalToken: infisicalTokenEnv}, "") if err != nil { util.HandleError(err, "unable to retrieve secrets") } @@ -302,7 +340,6 @@ var secretsSetCmd = &cobra.Command{ updateSecretRequest := api.UpdateSecretByNameV3Request{ WorkspaceID: workspaceFile.WorkspaceId, Environment: environmentName, - SecretName: secret.PlainTextKey, SecretValueCiphertext: secret.SecretValueCiphertext, SecretValueIV: secret.SecretValueIV, SecretValueTag: secret.SecretValueTag, @@ -310,7 +347,7 @@ var secretsSetCmd = &cobra.Command{ SecretPath: secretsPath, } - err = api.CallUpdateSecretsV3(httpClient, updateSecretRequest) + err = api.CallUpdateSecretsV3(httpClient, updateSecretRequest, secret.PlainTextKey) if err != nil { util.HandleError(err, "Unable to process secret update request") return @@ -403,7 +440,12 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - infisicalToken, err := cmd.Flags().GetString("token") + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + shouldExpand, err := cmd.Flags().GetBool("expand") if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -413,11 +455,27 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse path flag") } + recursive, err := cmd.Flags().GetBool("recursive") + if err != nil { + util.HandleError(err, "Unable to parse recursive flag") + } + + //deprecated (showOnlyValue) in favor of --plain + showOnlyValue, err := cmd.Flags().GetBool("raw-value") + if err != nil { + util.HandleError(err, "Unable to parse path flag") + } + plainOutput, err := cmd.Flags().GetBool("plain") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -428,18 +486,35 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - shouldExpandSecrets, err := cmd.Flags().GetBool("expand") - if err != nil { - util.HandleError(err) + request := models.GetAllSecretsParameters{ + Environment: environmentName, + WorkspaceId: projectId, + TagSlugs: tagSlugs, + SecretsPath: secretsPath, + IncludeImport: includeImports, + Recursive: recursive, } - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath, IncludeImport: includeImports}, "") + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + request.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + request.UniversalAuthAccessToken = token.Token + } + + secrets, err := util.GetAllEnvironmentVariables(request, "") if err != nil { util.HandleError(err, "To fetch all secrets") } - if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, "") + if shouldExpand { + authParams := models.ExpandSecretsAuthentication{} + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + authParams.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + authParams.UniversalAuthAccessToken = token.Token + } + + secrets = util.ExpandSecrets(secrets, authParams, "") } requestedSecrets := []models.SingleEnvironmentVariable{} @@ -465,6 +540,17 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } else { visualize.PrintAllSecretDetails(requestedSecrets) } + + // deprecated (showOnlyValue) + if showOnlyValue && len(requestedSecrets) > 1 { + util.PrintErrorMessageAndExit("--raw-value only works with one secret.") + } + + if showOnlyValue { + fmt.Printf(requestedSecrets[0].Value) + } else { + visualize.PrintAllSecretDetails(requestedSecrets) + } Telemetry.CaptureEvent("cli-command:secrets get", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) } @@ -482,7 +568,12 @@ func generateExampleEnv(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } - infisicalToken, err := cmd.Flags().GetString("token") + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := cmd.Flags().GetString("projectId") if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -492,7 +583,21 @@ 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, SecretsPath: secretsPath}, "") + request := models.GetAllSecretsParameters{ + Environment: environmentName, + WorkspaceId: projectId, + TagSlugs: tagSlugs, + SecretsPath: secretsPath, + IncludeImport: true, + } + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + request.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + request.UniversalAuthAccessToken = token.Token + } + + secrets, err := util.GetAllEnvironmentVariables(request, "") if err != nil { util.HandleError(err, "To fetch all secrets") } @@ -692,20 +797,24 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod func init() { secretsGenerateExampleEnvCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") + secretsGenerateExampleEnvCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") secretsGenerateExampleEnvCmd.Flags().String("path", "/", "Fetch secrets from within a folder path") secretsCmd.AddCommand(secretsGenerateExampleEnvCmd) secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") - secretsCmd.AddCommand(secretsGetCmd) + secretsGetCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") secretsGetCmd.Flags().Bool("plain", false, "print values without formatting, one per line") + secretsGetCmd.Flags().Bool("raw-value", false, "deprecated. Returns only the value of secret, only works with one secret. Use --plain instead") secretsGetCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") secretsGetCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") - + secretsGetCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") + secretsCmd.AddCommand(secretsGetCmd) secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") + // Only supports logged in users (JWT auth) secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { util.RequireLogin() util.RequireLocalWorkspaceFile() @@ -714,6 +823,8 @@ func init() { secretsDeleteCmd.Flags().String("type", "personal", "the type of secret to delete: personal or shared (default: personal)") secretsDeleteCmd.Flags().String("path", "/", "get secrets within a folder path") secretsCmd.AddCommand(secretsDeleteCmd) + + // Only supports logged in users (JWT auth) secretsDeleteCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { util.RequireLogin() util.RequireLocalWorkspaceFile() @@ -725,6 +836,7 @@ func init() { // Add getCmd, createCmd and deleteCmd flags here getCmd.Flags().StringP("path", "p", "/", "The path from where folders should be fetched from") getCmd.Flags().String("token", "", "Fetch folders using the infisical token") + getCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") folderCmd.AddCommand(getCmd) // Add createCmd flags here @@ -742,9 +854,11 @@ func init() { // ** End of folders sub command secretsCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") + secretsCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") secretsCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") + secretsCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") secretsCmd.PersistentFlags().StringP("tags", "t", "", "filter secrets by tag slugs") secretsCmd.Flags().String("path", "/", "get secrets within a folder path") secretsCmd.Flags().Bool("plain", false, "print values without formatting, one per line") diff --git a/cli/packages/cmd/token.go b/cli/packages/cmd/token.go new file mode 100644 index 000000000..3e5d42765 --- /dev/null +++ b/cli/packages/cmd/token.go @@ -0,0 +1,63 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var tokenCmd = &cobra.Command{ + Use: "token", + Short: "Manage your access tokens", + DisableFlagsInUseLine: true, + Example: "infisical token", + Args: cobra.ExactArgs(0), + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +var tokenRenewCmd = &cobra.Command{ + Use: "renew [token]", + Short: "Used to renew your universal auth access token", + DisableFlagsInUseLine: true, + Example: "infisical token renew ", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // args[0] will be the from your command call + token := args[0] + + if strings.HasPrefix(token, "st.") { + util.PrintErrorMessageAndExit("You are trying to renew a service token. You can only renew universal auth access tokens.") + } + + renewedAccessToken, err := util.RenewUniversalAuthAccessToken(token) + + if err != nil { + util.HandleError(err, "Unable to renew token") + } + + boldGreen := color.New(color.FgGreen).Add(color.Bold) + time.Sleep(time.Second * 1) + boldGreen.Printf(">>>> Successfully renewed token!\n\n") + boldGreen.Printf("Renewed Access Token:\n%v", renewedAccessToken) + + plainBold := color.New(color.Bold) + plainBold.Println("\n\nYou can use the new access token to authenticate through other commands in the CLI.") + + }, +} + +func init() { + tokenCmd.AddCommand(tokenRenewCmd) + + rootCmd.AddCommand(tokenCmd) +} diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 165982a77..68527c469 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -1,5 +1,7 @@ package models +import "time" + type UserCredentials struct { Email string `json:"email"` PrivateKey string `json:"privateKey"` @@ -21,11 +23,12 @@ type LoggedInUser struct { } type SingleEnvironmentVariable struct { - Key string `json:"key"` - Value string `json:"value"` - Type string `json:"type"` - ID string `json:"_id"` - Tags []struct { + Key string `json:"key"` + WorkspaceId string `json:"workspace"` + Value string `json:"value"` + Type string `json:"type"` + ID string `json:"_id"` + Tags []struct { ID string `json:"_id"` Name string `json:"name"` Slug string `json:"slug"` @@ -34,17 +37,44 @@ type SingleEnvironmentVariable struct { Comment string `json:"comment"` } +type PlaintextSecretResult struct { + Secrets []SingleEnvironmentVariable + Etag string +} + +type DynamicSecret struct { + Id string `json:"id"` + DefaultTTL string `json:"defaultTTL"` + MaxTTL string `json:"maxTTL"` + Type string `json:"type"` +} + +type DynamicSecretLease struct { + Lease struct { + Id string `json:"id"` + ExpireAt time.Time `json:"expireAt"` + } `json:"lease"` + DynamicSecret DynamicSecret `json:"dynamicSecret"` + // this is a varying dict based on provider + Data map[string]interface{} `json:"data"` +} + +type TokenDetails struct { + Type string + Token string +} + type SingleFolder struct { ID string `json:"_id"` Name string `json:"name"` } type Workspace struct { - ID string `json:"_id"` - Name string `json:"name"` - Plan string `json:"plan,omitempty"` - V int `json:"__v"` - Organization string `json:"organization,omitempty"` + ID string `json:"_id"` + Name string `json:"name"` + Plan string `json:"plan,omitempty"` + V int `json:"__v"` + OrganizationId string `json:"orgId"` } type WorkspaceConfigFile struct { @@ -63,17 +93,20 @@ type GetAllSecretsParameters struct { Environment string EnvironmentPassedViaFlag bool InfisicalToken string + UniversalAuthAccessToken string TagSlugs string WorkspaceId string SecretsPath string IncludeImport bool + Recursive bool } type GetAllFoldersParameters struct { - WorkspaceId string - Environment string - FoldersPath string - InfisicalToken string + WorkspaceId string + Environment string + FoldersPath string + InfisicalToken string + UniversalAuthAccessToken string } type CreateFolderParameters struct { @@ -91,3 +124,13 @@ type DeleteFolderParameters struct { FolderPath string InfisicalToken string } + +type ExpandSecretsAuthentication struct { + InfisicalToken string + UniversalAuthAccessToken string +} + +type MachineIdentityCredentials struct { + ClientId string + ClientSecret string +} diff --git a/cli/packages/util/agent.go b/cli/packages/util/agent.go new file mode 100644 index 000000000..188ae5de2 --- /dev/null +++ b/cli/packages/util/agent.go @@ -0,0 +1,41 @@ +package util + +import ( + "fmt" + "strconv" + "time" +) + +// ConvertPollingIntervalToTime converts a string representation of a polling interval to a time.Duration +func ConvertPollingIntervalToTime(pollingInterval string) (time.Duration, error) { + length := len(pollingInterval) + if length < 2 { + return 0, fmt.Errorf("invalid format") + } + + unit := pollingInterval[length-1:] + numberPart := pollingInterval[:length-1] + + number, err := strconv.Atoi(numberPart) + if err != nil { + return 0, err + } + + switch unit { + case "s": + if number < 60 { + return 0, fmt.Errorf("polling interval should be at least 60 seconds") + } + return time.Duration(number) * time.Second, nil + case "m": + return time.Duration(number) * time.Minute, nil + case "h": + return time.Duration(number) * time.Hour, nil + case "d": + return time.Duration(number) * 24 * time.Hour, nil + case "w": + return time.Duration(number) * 7 * 24 * time.Hour, nil + default: + return 0, fmt.Errorf("invalid time unit") + } +} diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index ee2532ee8..311a4b0d9 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -1,17 +1,23 @@ package util const ( - CONFIG_FILE_NAME = "infisical-config.json" - CONFIG_FOLDER_NAME = ".infisical" - INFISICAL_DEFAULT_API_URL = "https://app.infisical.com/api" - INFISICAL_DEFAULT_URL = "https://app.infisical.com" - INFISICAL_WORKSPACE_CONFIG_FILE_NAME = ".infisical.json" - INFISICAL_TOKEN_NAME = "INFISICAL_TOKEN" - SECRET_TYPE_PERSONAL = "personal" - SECRET_TYPE_SHARED = "shared" - KEYRING_SERVICE_NAME = "infisical" - PERSONAL_SECRET_TYPE_NAME = "personal" - SHARED_SECRET_TYPE_NAME = "shared" + CONFIG_FILE_NAME = "infisical-config.json" + CONFIG_FOLDER_NAME = ".infisical" + INFISICAL_DEFAULT_API_URL = "https://app.infisical.com/api" + INFISICAL_DEFAULT_URL = "https://app.infisical.com" + INFISICAL_WORKSPACE_CONFIG_FILE_NAME = ".infisical.json" + INFISICAL_TOKEN_NAME = "INFISICAL_TOKEN" + INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID" + INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET" + INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME = "INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN" + SECRET_TYPE_PERSONAL = "personal" + SECRET_TYPE_SHARED = "shared" + KEYRING_SERVICE_NAME = "infisical" + PERSONAL_SECRET_TYPE_NAME = "personal" + SHARED_SECRET_TYPE_NAME = "shared" + + SERVICE_TOKEN_IDENTIFIER = "service-token" + UNIVERSAL_AUTH_TOKEN_IDENTIFIER = "universal-auth-token" ) var ( diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 7275653d9..18fe5c888 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -2,7 +2,6 @@ package util import ( "fmt" - "os" "strings" "github.com/Infisical/infisical-merge/packages/api" @@ -13,13 +12,11 @@ import ( func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder, error) { - if params.InfisicalToken == "" { - params.InfisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) - } - var foldersToReturn []models.SingleFolder var folderErr error - if params.InfisicalToken == "" { + if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { + RequireLogin() + RequireLocalWorkspaceFile() log.Debug().Msg("GetAllFolders: Trying to fetch folders using logged in details") @@ -44,11 +41,24 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, workspaceFile.WorkspaceId, params.Environment, params.FoldersPath) folderErr = err foldersToReturn = folders - } else { + } else if params.InfisicalToken != "" { + log.Debug().Msg("GetAllFolders: Trying to fetch folders using service token") + // get folders via service token folders, err := GetFoldersViaServiceToken(params.InfisicalToken, params.WorkspaceId, params.Environment, params.FoldersPath) folderErr = err foldersToReturn = folders + } else if params.UniversalAuthAccessToken != "" { + log.Debug().Msg("GetAllFolders: Trying to fetch folders using universal auth") + + if params.WorkspaceId == "" { + PrintErrorMessageAndExit("Project ID is required when using machine identity") + } + + // get folders via machine identity + folders, err := GetFoldersViaMachineIdentity(params.UniversalAuthAccessToken, params.WorkspaceId, params.Environment, params.FoldersPath) + folderErr = err + foldersToReturn = folders } return foldersToReturn, folderErr } @@ -132,6 +142,34 @@ func GetFoldersViaServiceToken(fullServiceToken string, workspaceId string, envi return folders, nil } +func GetFoldersViaMachineIdentity(accessToken string, workspaceId string, envSlug string, foldersPath string) ([]models.SingleFolder, error) { + httpClient := resty.New() + httpClient.SetAuthToken(accessToken). + SetHeader("Accept", "application/json") + + getFoldersRequest := api.GetFoldersV1Request{ + WorkspaceId: workspaceId, + Environment: envSlug, + FoldersPath: foldersPath, + } + + apiResponse, err := api.CallGetFoldersV1(httpClient, getFoldersRequest) + if err != nil { + return nil, err + } + + var folders []models.SingleFolder + + for _, folder := range apiResponse.Folders { + folders = append(folders, models.SingleFolder{ + Name: folder.Name, + ID: folder.ID, + }) + } + + return folders, nil +} + // CreateFolder creates a folder in Infisical func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, error) { loggedInUserDetails, err := GetCurrentLoggedInUserDetails() @@ -154,7 +192,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er WorkspaceId: params.WorkspaceId, Environment: params.Environment, FolderName: params.FolderName, - Directory: params.FolderPath, + Path: params.FolderPath, } apiResponse, err := api.CallCreateFolderV1(httpClient, createFolderRequest) diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 043db468e..9a4d960db 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -8,9 +8,14 @@ import ( "os" "os/exec" "path" + "sort" "strings" + "time" + "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/models" + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" ) type DecodedSymmetricEncryptionDetails = struct { @@ -49,6 +54,14 @@ func GetBase64DecodedSymmetricEncryptionDetails(key string, cipher string, IV st }, nil } +// Helper function to sort the secrets by key so we can create a consistent output +func SortSecretsByKeys(secrets []models.SingleEnvironmentVariable) []models.SingleEnvironmentVariable { + sort.Slice(secrets, func(i, j int) bool { + return secrets[i].Key < secrets[j].Key + }) + return secrets +} + func IsSecretEnvironmentValid(env string) bool { if env == "prod" || env == "dev" || env == "test" || env == "staging" { return true @@ -63,6 +76,72 @@ func IsSecretTypeValid(s string) bool { return false } +func GetInfisicalToken(cmd *cobra.Command) (token *models.TokenDetails, err error) { + infisicalToken, err := cmd.Flags().GetString("token") + + if err != nil { + return nil, err + } + + if infisicalToken == "" { // If no flag is passed, we first check for the universal auth access token env variable. + infisicalToken = os.Getenv(INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME) + + if infisicalToken == "" { // If it's still empty after the first env check, we check for the service token env variable. + infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) + } + } + + if infisicalToken == "" { // If it's empty, we return nothing at all. + return nil, nil + } + + if strings.HasPrefix(infisicalToken, "st.") { + return &models.TokenDetails{ + Type: SERVICE_TOKEN_IDENTIFIER, + Token: infisicalToken, + }, nil + } + + return &models.TokenDetails{ + Type: UNIVERSAL_AUTH_TOKEN_IDENTIFIER, + Token: infisicalToken, + }, nil + +} + +func UniversalAuthLogin(clientId string, clientSecret string) (api.UniversalAuthLoginResponse, error) { + httpClient := resty.New() + httpClient.SetRetryCount(10000). + SetRetryMaxWaitTime(20 * time.Second). + SetRetryWaitTime(5 * time.Second) + + tokenResponse, err := api.CallUniversalAuthLogin(httpClient, api.UniversalAuthLoginRequest{ClientId: clientId, ClientSecret: clientSecret}) + if err != nil { + return api.UniversalAuthLoginResponse{}, err + } + + return tokenResponse, nil +} + +func RenewUniversalAuthAccessToken(accessToken string) (string, error) { + + httpClient := resty.New() + httpClient.SetRetryCount(10000). + SetRetryMaxWaitTime(20 * time.Second). + SetRetryWaitTime(5 * time.Second) + + request := api.UniversalAuthRefreshRequest{ + AccessToken: accessToken, + } + + tokenResponse, err := api.CallUniversalAuthRefreshAccessToken(httpClient, request) + if err != nil { + return "", err + } + + return tokenResponse.AccessToken, nil +} + // Checks if the passed in email already exists in the users slice func ConfigContainsEmail(users []models.LoggedInUser, email string) bool { for _, value := range users { @@ -82,6 +161,11 @@ func RequireLogin() { } } +func IsLoggedIn() bool { + configFile, _ := GetConfigFile() + return configFile.LoggedInUserEmail != "" +} + func RequireServiceToken() { serviceToken := os.Getenv(INFISICAL_TOKEN_NAME) if serviceToken == "" { diff --git a/cli/packages/util/init.go b/cli/packages/util/init.go new file mode 100644 index 000000000..33350f3b7 --- /dev/null +++ b/cli/packages/util/init.go @@ -0,0 +1,45 @@ +package util + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/models" +) + +func GetOrganizationsNameList(organizationResponse api.GetOrganizationsResponse) []string { + organizations := organizationResponse.Organizations + + if len(organizations) == 0 { + message := fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", INFISICAL_DEFAULT_URL) + PrintErrorMessageAndExit(message) + } + + var organizationNames []string + for _, workspace := range organizations { + organizationNames = append(organizationNames, workspace.Name) + } + + return organizationNames +} + +func GetWorkspacesInOrganization(workspaceResponse api.GetWorkSpacesResponse, orgId string) ([]models.Workspace, []string) { + workspaces := workspaceResponse.Workspaces + + var filteredWorkspaces []models.Workspace + var workspaceNames []string + + for _, workspace := range workspaces { + if workspace.OrganizationId == orgId { + filteredWorkspaces = append(filteredWorkspaces, workspace) + workspaceNames = append(workspaceNames, workspace.Name) + } + } + + if len(filteredWorkspaces) == 0 { + message := fmt.Sprintf("You don't have any projects created in Infisical organization. You must first create a project at %s", INFISICAL_DEFAULT_URL) + PrintErrorMessageAndExit(message) + } + + return filteredWorkspaces, workspaceNames +} diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index a260aaaec..27f0636a9 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -17,7 +17,7 @@ import ( "github.com/rs/zerolog/log" ) -func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool) ([]models.SingleEnvironmentVariable, api.GetServiceTokenDetailsResponse, error) { +func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment string, secretPath string, includeImports bool, recursive bool) ([]models.SingleEnvironmentVariable, api.GetServiceTokenDetailsResponse, error) { serviceTokenParts := strings.SplitN(fullServiceToken, ".", 4) if len(serviceTokenParts) < 4 { return nil, api.GetServiceTokenDetailsResponse{}, fmt.Errorf("invalid service token entered. Please double check your service token and try again") @@ -49,6 +49,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str Environment: environment, SecretPath: secretPath, IncludeImport: includeImports, + Recursive: recursive, }) if err != nil { @@ -80,7 +81,7 @@ func GetPlainTextSecretsViaServiceToken(fullServiceToken string, environment str return plainTextSecrets, serviceTokenDetails, nil } -func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, workspaceId string, environmentName string, tagSlugs string, secretsPath string, includeImports bool) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, workspaceId string, environmentName string, tagSlugs string, secretsPath string, includeImports bool, recursive bool) ([]models.SingleEnvironmentVariable, error) { httpClient := resty.New() httpClient.SetAuthToken(JTWToken). SetHeader("Accept", "application/json") @@ -125,6 +126,7 @@ func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, work WorkspaceId: workspaceId, Environment: environmentName, IncludeImport: includeImports, + Recursive: recursive, // TagSlugs: tagSlugs, } @@ -152,15 +154,16 @@ func GetPlainTextSecretsViaJTW(JTWToken string, receiversPrivateKey string, work return plainTextSecrets, nil } -func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId string, environmentName string, secretsPath string, includeImports bool) ([]models.SingleEnvironmentVariable, error) { +func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId string, environmentName string, secretsPath string, includeImports bool, recursive bool) (models.PlaintextSecretResult, error) { httpClient := resty.New() httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") - getSecretsRequest := api.GetEncryptedSecretsV3Request{ + getSecretsRequest := api.GetRawSecretsV3Request{ WorkspaceId: workspaceId, Environment: environmentName, IncludeImport: includeImports, + Recursive: recursive, // TagSlugs: tagSlugs, } @@ -168,28 +171,57 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin getSecretsRequest.SecretPath = secretsPath } - rawSecrets, err := api.CallGetRawSecretsV3(httpClient, api.GetRawSecretsV3Request{WorkspaceId: workspaceId, SecretPath: secretsPath, Environment: environmentName}) + rawSecrets, err := api.CallGetRawSecretsV3(httpClient, getSecretsRequest) + if err != nil { - return nil, err + return models.PlaintextSecretResult{}, err } plainTextSecrets := []models.SingleEnvironmentVariable{} if err != nil { - return nil, fmt.Errorf("unable to decrypt your secrets [err=%v]", err) + return models.PlaintextSecretResult{}, fmt.Errorf("unable to decrypt your secrets [err=%v]", err) } for _, secret := range rawSecrets.Secrets { - plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue}) + plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue, Type: secret.Type, WorkspaceId: secret.Workspace}) } - // if includeImports { - // plainTextSecrets, err = InjectImportedSecret(plainTextWorkspaceKey, plainTextSecrets, encryptedSecrets.ImportedSecrets) - // if err != nil { - // return nil, err - // } - // } + if includeImports { + plainTextSecrets, err = InjectRawImportedSecret(plainTextSecrets, rawSecrets.Imports) + if err != nil { + return models.PlaintextSecretResult{}, err + } + } - return plainTextSecrets, nil + return models.PlaintextSecretResult{ + Secrets: plainTextSecrets, + Etag: rawSecrets.ETag, + }, nil +} + +func CreateDynamicSecretLease(accessToken string, projectSlug string, environmentName string, secretsPath string, slug string, ttl string) (models.DynamicSecretLease, error) { + httpClient := resty.New() + httpClient.SetAuthToken(accessToken). + SetHeader("Accept", "application/json") + + dynamicSecretRequest := api.CreateDynamicSecretLeaseV1Request{ + ProjectSlug: projectSlug, + Environment: environmentName, + SecretPath: secretsPath, + Slug: slug, + TTL: ttl, + } + + dynamicSecret, err := api.CallCreateDynamicSecretLeaseV1(httpClient, dynamicSecretRequest) + if err != nil { + return models.DynamicSecretLease{}, err + } + + return models.DynamicSecretLease{ + Lease: dynamicSecret.Lease, + Data: dynamicSecret.Data, + DynamicSecret: dynamicSecret.DynamicSecret, + }, nil } func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedSecretV3) ([]models.SingleEnvironmentVariable, error) { @@ -220,20 +252,67 @@ func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleE return secrets, nil } -func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectConfigFilePath string) ([]models.SingleEnvironmentVariable, error) { - var infisicalToken string - if params.InfisicalToken == "" { - infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) - } else { - infisicalToken = params.InfisicalToken +func InjectRawImportedSecret(secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedRawSecretV3) ([]models.SingleEnvironmentVariable, error) { + if importedSecrets == nil { + return secrets, nil } + hasOverriden := make(map[string]bool) + for _, sec := range secrets { + hasOverriden[sec.Key] = true + } + + for i := len(importedSecrets) - 1; i >= 0; i-- { + importSec := importedSecrets[i] + plainTextImportedSecrets := importSec.Secrets + + for _, sec := range plainTextImportedSecrets { + if _, ok := hasOverriden[sec.SecretKey]; !ok { + secrets = append(secrets, models.SingleEnvironmentVariable{ + Key: sec.SecretKey, + WorkspaceId: sec.Workspace, + Value: sec.SecretValue, + Type: sec.Type, + ID: sec.ID, + }) + hasOverriden[sec.SecretKey] = true + } + } + } + return secrets, nil +} + +func FilterSecretsByTag(plainTextSecrets []models.SingleEnvironmentVariable, tagSlugs string) []models.SingleEnvironmentVariable { + if tagSlugs == "" { + return plainTextSecrets + } + + tagSlugsMap := make(map[string]bool) + tagSlugsList := strings.Split(tagSlugs, ",") + for _, slug := range tagSlugsList { + tagSlugsMap[slug] = true + } + + filteredSecrets := []models.SingleEnvironmentVariable{} + for _, secret := range plainTextSecrets { + for _, tag := range secret.Tags { + if tagSlugsMap[tag.Slug] { + filteredSecrets = append(filteredSecrets, secret) + break + } + } + } + + return filteredSecrets +} + +func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectConfigFilePath string) ([]models.SingleEnvironmentVariable, error) { isConnected := CheckIsConnectedToInternet() var secretsToReturn []models.SingleEnvironmentVariable // var serviceTokenDetails api.GetServiceTokenDetailsResponse var errorToReturn error - if infisicalToken == "" { + if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { if isConnected { log.Debug().Msg("GetAllEnvironmentVariables: Connected to internet, checking logged in creds") @@ -279,14 +358,8 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo infisicalDotJson.WorkspaceId = params.WorkspaceId } - // // Verify environment - // err = ValidateEnvironmentName(params.Environment, workspaceFile.WorkspaceId, loggedInUserDetails.UserCredentials) - // if err != nil { - // return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) - // } - secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, infisicalDotJson.WorkspaceId, - params.Environment, params.TagSlugs, params.SecretsPath, params.IncludeImport) + params.Environment, params.TagSlugs, params.SecretsPath, params.IncludeImport, params.Recursive) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", errorToReturn) backupSecretsEncryptionKey := []byte(loggedInUserDetails.UserCredentials.PrivateKey)[0:32] @@ -305,91 +378,24 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } } else { - log.Debug().Msg("Trying to fetch secrets using service token") - secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(infisicalToken, params.Environment, params.SecretsPath, params.IncludeImport) - } + if params.InfisicalToken != "" { + log.Debug().Msg("Trying to fetch secrets using service token") + secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive) + } else if params.UniversalAuthAccessToken != "" { - return secretsToReturn, errorToReturn -} - -// func ValidateEnvironmentName(environmentName string, workspaceId string, userLoggedInDetails models.UserCredentials) error { -// httpClient := resty.New() -// httpClient.SetAuthToken(userLoggedInDetails.JTWToken). -// SetHeader("Accept", "application/json") - -// response, err := api.CallGetAccessibleEnvironments(httpClient, api.GetAccessibleEnvironmentsRequest{WorkspaceId: workspaceId}) -// if err != nil { -// return err -// } - -// listOfEnvSlugs := []string{} -// mapOfEnvSlugs := make(map[string]interface{}) - -// for _, environment := range response.AccessibleEnvironments { -// listOfEnvSlugs = append(listOfEnvSlugs, environment.Slug) -// mapOfEnvSlugs[environment.Slug] = environment -// } - -// _, exists := mapOfEnvSlugs[environmentName] -// if !exists { -// HandleError(fmt.Errorf("the environment [%s] does not exist in project with [id=%s]. Only [%s] are available", environmentName, workspaceId, strings.Join(listOfEnvSlugs, ","))) -// } - -// return nil - -// } - -func getExpandedEnvVariable(secrets []models.SingleEnvironmentVariable, variableWeAreLookingFor string, hashMapOfCompleteVariables map[string]string, hashMapOfSelfRefs map[string]string) string { - if value, found := hashMapOfCompleteVariables[variableWeAreLookingFor]; found { - return value - } - - for _, secret := range secrets { - if secret.Key == variableWeAreLookingFor { - regex := regexp.MustCompile(`\${([^\}]*)}`) - variablesToPopulate := regex.FindAllString(secret.Value, -1) - - // case: variable is a constant so return its value - if len(variablesToPopulate) == 0 { - return secret.Value + if params.WorkspaceId == "" { + PrintErrorMessageAndExit("Project ID is required when using machine identity") } - valueToEdit := secret.Value - for _, variableWithSign := range variablesToPopulate { - variableWithoutSign := strings.Trim(variableWithSign, "}") - variableWithoutSign = strings.Trim(variableWithoutSign, "${") + log.Debug().Msg("Trying to fetch secrets using universal auth") + res, err := GetPlainTextSecretsViaMachineIdentity(params.UniversalAuthAccessToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive) - // case: reference to self - if variableWithoutSign == secret.Key { - hashMapOfSelfRefs[variableWithoutSign] = variableWithoutSign - continue - } else { - var expandedVariableValue string - - if preComputedVariable, found := hashMapOfCompleteVariables[variableWithoutSign]; found { - expandedVariableValue = preComputedVariable - } else { - expandedVariableValue = getExpandedEnvVariable(secrets, variableWithoutSign, hashMapOfCompleteVariables, hashMapOfSelfRefs) - hashMapOfCompleteVariables[variableWithoutSign] = expandedVariableValue - } - - // If after expanding all the vars above, is the current var a self ref? if so no replacement needed for it - if _, found := hashMapOfSelfRefs[variableWithoutSign]; found { - continue - } else { - valueToEdit = strings.ReplaceAll(valueToEdit, variableWithSign, expandedVariableValue) - } - } - } - - return valueToEdit - - } else { - continue + errorToReturn = err + secretsToReturn = res.Secrets } } - return "${" + variableWeAreLookingFor + "}" + return secretsToReturn, errorToReturn } var secRefRegex = regexp.MustCompile(`\${([^\}]*)}`) @@ -401,7 +407,7 @@ func recursivelyExpandSecret(expandedSecs map[string]string, interpolatedSecs ma interpolatedVal, ok := interpolatedSecs[key] if !ok { - HandleError(fmt.Errorf("Could not find refered secret - %s", key), "Kindly check whether its provided") + HandleError(fmt.Errorf("could not find refered secret - %s", key), "Kindly check whether its provided") } refs := secRefRegex.FindAllStringSubmatch(interpolatedVal, -1) @@ -440,7 +446,7 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod return secretMapByName } -func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken string, projectConfigPathDir string) []models.SingleEnvironmentVariable { +func ExpandSecrets(secrets []models.SingleEnvironmentVariable, auth models.ExpandSecretsAuthentication, projectConfigPathDir string) []models.SingleEnvironmentVariable { expandedSecs := make(map[string]string) interpolatedSecs := make(map[string]string) // map[env.secret-path][keyname]Secret @@ -472,8 +478,20 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken st uniqKey := fmt.Sprintf("%s.%s", env, secPathDot) if crossRefSec, ok := crossEnvRefSecs[uniqKey]; !ok { + + var refSecs []models.SingleEnvironmentVariable + var err error + // if not in cross reference cache, fetch it from server - refSecs, err := GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: infisicalToken, SecretsPath: secPath}, projectConfigPathDir) + if auth.InfisicalToken != "" { + refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: auth.InfisicalToken, SecretsPath: secPath}, projectConfigPathDir) + } else if auth.UniversalAuthAccessToken != "" { + refSecs, err = GetAllEnvironmentVariables((models.GetAllSecretsParameters{Environment: env, UniversalAuthAccessToken: auth.UniversalAuthAccessToken, SecretsPath: secPath, WorkspaceId: sec.WorkspaceId}), projectConfigPathDir) + } else if IsLoggedIn() { + refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, SecretsPath: secPath}, projectConfigPathDir) + } else { + HandleError(errors.New("no authentication provided"), "Please provide authentication to fetch secrets") + } if err != nil { HandleError(err, fmt.Sprintf("Could not fetch secrets in environment: %s secret-path: %s", env, secPath), "If you are using a service token to fetch secrets, please ensure it is valid") } @@ -481,6 +499,7 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken st // save it to avoid calling api again for same environment and folder path crossEnvRefSecs[uniqKey] = refSecsByKey return refSecsByKey[secKey].Value + } else { return crossRefSec[secKey].Value } diff --git a/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithImports b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithImports new file mode 100644 index 000000000..679cd91ad --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithImports @@ -0,0 +1,5 @@ +STAGING-SECRET-1='staging-value-1' +STAGING-SECRET-2='staging-value-2' +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithoutImports b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithoutImports new file mode 100644 index 000000000..c803e591e --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithoutImports @@ -0,0 +1,3 @@ +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameRecursive b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameRecursive new file mode 100644 index 000000000..f2f26ae19 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameRecursive @@ -0,0 +1,7 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithImports b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithImports new file mode 100644 index 000000000..ff488466d --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithImports @@ -0,0 +1,7 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ STAGING-SECRET-2 β”‚ staging-value-2 β”‚ shared β”‚ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithNotFoundSecret b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithNotFoundSecret new file mode 100644 index 000000000..afe3bffa8 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithNotFoundSecret @@ -0,0 +1,8 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β”‚ DOES-NOT-EXIST β”‚ *not found* β”‚ *not found* β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestServiceToken_RunCmdRecursiveAndImports b/cli/test/.snapshots/test-TestServiceToken_RunCmdRecursiveAndImports new file mode 100644 index 000000000..4106549cd --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_RunCmdRecursiveAndImports @@ -0,0 +1,2 @@ + Injecting 6 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestServiceToken_RunCmdWithImports b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithImports new file mode 100644 index 000000000..86475d3e5 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithImports @@ -0,0 +1,2 @@ + Injecting 5 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestServiceToken_RunCmdWithoutImports b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithoutImports new file mode 100644 index 000000000..dfe56064a --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithoutImports @@ -0,0 +1,2 @@ + Injecting 3 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithImportsAndRecursiveCmd b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithImportsAndRecursiveCmd new file mode 100644 index 000000000..dd2bc317c --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithImportsAndRecursiveCmd @@ -0,0 +1,10 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β”‚ STAGING-SECRET-1 β”‚ staging-value-1 β”‚ shared β”‚ +β”‚ STAGING-SECRET-2 β”‚ staging-value-2 β”‚ shared β”‚ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ TEST-SECRET-3 β”‚ test-value-3 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd new file mode 100644 index 000000000..260607e97 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd @@ -0,0 +1,7 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ TEST-SECRET-3 β”‚ test-value-3 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithImports b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithImports new file mode 100644 index 000000000..679cd91ad --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithImports @@ -0,0 +1,5 @@ +STAGING-SECRET-1='staging-value-1' +STAGING-SECRET-2='staging-value-2' +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithoutImports b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithoutImports new file mode 100644 index 000000000..c803e591e --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithoutImports @@ -0,0 +1,3 @@ +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameRecursive b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameRecursive new file mode 100644 index 000000000..f2f26ae19 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameRecursive @@ -0,0 +1,7 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithImports b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithImports new file mode 100644 index 000000000..ff488466d --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithImports @@ -0,0 +1,7 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ STAGING-SECRET-2 β”‚ staging-value-2 β”‚ shared β”‚ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithNotFoundSecret b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithNotFoundSecret new file mode 100644 index 000000000..afe3bffa8 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithNotFoundSecret @@ -0,0 +1,8 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β”‚ DOES-NOT-EXIST β”‚ *not found* β”‚ *not found* β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_RunCmdRecursiveAndImports b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdRecursiveAndImports new file mode 100644 index 000000000..4106549cd --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdRecursiveAndImports @@ -0,0 +1,2 @@ + Injecting 6 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithImports b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithImports new file mode 100644 index 000000000..86475d3e5 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithImports @@ -0,0 +1,2 @@ + Injecting 5 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithoutImports b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithoutImports new file mode 100644 index 000000000..dfe56064a --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithoutImports @@ -0,0 +1,2 @@ + Injecting 3 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd new file mode 100644 index 000000000..dd2bc317c --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd @@ -0,0 +1,10 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ FOLDER-SECRET-1 β”‚ folder-value-1 β”‚ shared β”‚ +β”‚ STAGING-SECRET-1 β”‚ staging-value-1 β”‚ shared β”‚ +β”‚ STAGING-SECRET-2 β”‚ staging-value-2 β”‚ shared β”‚ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ TEST-SECRET-3 β”‚ test-value-3 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd new file mode 100644 index 000000000..260607e97 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd @@ -0,0 +1,7 @@ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ SECRET NAME β”‚ SECRET VALUE β”‚ SECRET TYPE β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ TEST-SECRET-1 β”‚ test-value-1 β”‚ shared β”‚ +β”‚ TEST-SECRET-2 β”‚ test-value-2 β”‚ shared β”‚ +β”‚ TEST-SECRET-3 β”‚ test-value-3 β”‚ shared β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment new file mode 100644 index 000000000..c3811bd22 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -0,0 +1,4 @@ +error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=500] [response={"statusCode":500,"error":"Internal Server Error","message":"'invalid-env' environment not found in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2"}] + + +If this issue continues, get support at https://infisical.com/slack diff --git a/cli/test/export_test.go b/cli/test/export_test.go new file mode 100644 index 000000000..9a936871d --- /dev/null +++ b/cli/test/export_test.go @@ -0,0 +1,73 @@ +package tests + +import ( + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestUniversalAuth_ExportSecretsWithImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_ExportSecretsWithImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_ExportSecretsWithoutImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_ExportSecretsWithoutImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} diff --git a/cli/test/helper.go b/cli/test/helper.go new file mode 100644 index 000000000..995367c4b --- /dev/null +++ b/cli/test/helper.go @@ -0,0 +1,64 @@ +package tests + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" +) + +const ( + CLI_NAME = "infisical-merge" +) + +var ( + FORMATTED_CLI_NAME = fmt.Sprintf("./%s", CLI_NAME) +) + +type Credentials struct { + ClientID string + ClientSecret string + UAAccessToken string + ServiceToken string + ProjectID string + EnvSlug string +} + +var creds = Credentials{ + UAAccessToken: "", + ClientID: os.Getenv("CLI_TESTS_UA_CLIENT_ID"), + ClientSecret: os.Getenv("CLI_TESTS_UA_CLIENT_SECRET"), + ServiceToken: os.Getenv("CLI_TESTS_SERVICE_TOKEN"), + ProjectID: os.Getenv("CLI_TESTS_PROJECT_ID"), + EnvSlug: os.Getenv("CLI_TESTS_ENV_SLUG"), +} + +func ExecuteCliCommand(command string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return strings.TrimSpace(string(output)), err + } + return strings.TrimSpace(string(output)), nil +} + +func SetupCli(t *testing.T) { + + if creds.ClientID == "" || creds.ClientSecret == "" || creds.ServiceToken == "" || creds.ProjectID == "" || creds.EnvSlug == "" { + panic("Missing required environment variables") + } + + // check if the CLI is already built, if not build it + alreadyBuilt := false + if _, err := os.Stat(FORMATTED_CLI_NAME); err == nil { + alreadyBuilt = true + } + + if !alreadyBuilt { + if err := exec.Command("go", "build", "../.").Run(); err != nil { + t.Fatal(err) + } + } + +} diff --git a/cli/test/login_test.go b/cli/test/login_test.go new file mode 100644 index 000000000..0f4591413 --- /dev/null +++ b/cli/test/login_test.go @@ -0,0 +1,29 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func MachineIdentityLoginCmd(t *testing.T) { + SetupCli(t) + + if creds.UAAccessToken != "" { + return + } + + jwtPattern := `^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$` + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "login", "--method=universal-auth", "--client-id", creds.ClientID, "--client-secret", creds.ClientSecret, "--plain", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + assert.Regexp(t, jwtPattern, output) + + creds.UAAccessToken = output + + // We can't use snapshot testing here because the output will be different every time +} diff --git a/cli/test/run_test.go b/cli/test/run_test.go new file mode 100644 index 000000000..808f4f14f --- /dev/null +++ b/cli/test/run_test.go @@ -0,0 +1,120 @@ +package tests + +import ( + "bytes" + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestServiceToken_RunCmdRecursiveAndImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} +func TestServiceToken_RunCmdWithImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_RunCmdRecursiveAndImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_RunCmdWithImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // remove the first few characters from the output because we don't care about the time, and it will change every time + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_RunCmdWithoutImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_RunCmdWithoutImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Remove everything before "INF" because it's not relevant to the test + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} diff --git a/cli/test/secrets_by_name_test.go b/cli/test/secrets_by_name_test.go new file mode 100644 index 000000000..440324e1a --- /dev/null +++ b/cli/test/secrets_by_name_test.go @@ -0,0 +1,106 @@ +package tests + +import ( + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestServiceToken_GetSecretsByNameRecursive(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_GetSecretsByNameWithNotFoundSecret(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "DOES-NOT-EXIST", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_GetSecretsByNameWithImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "STAGING-SECRET-2", "FOLDER-SECRET-1", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_GetSecretsByNameRecursive(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_GetSecretsByNameWithNotFoundSecret(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "DOES-NOT-EXIST", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_GetSecretsByNameWithImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "STAGING-SECRET-2", "FOLDER-SECRET-1", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} diff --git a/cli/test/secrets_test.go b/cli/test/secrets_test.go new file mode 100644 index 000000000..453666406 --- /dev/null +++ b/cli/test/secrets_test.go @@ -0,0 +1,87 @@ +package tests + +import ( + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestServiceToken_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { + SetupCli(t) + MachineIdentityLoginCmd(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing.T) { + SetupCli(t) + MachineIdentityLoginCmd(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_SecretsGetWrongEnvironment(t *testing.T) { + SetupCli(t) + MachineIdentityLoginCmd(t) + + output, _ := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", "invalid-env", "--recursive", "--silent") + + // Use cupaloy to snapshot test the output + err := cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + +} diff --git a/company/documentation/getting-started/introduction.mdx b/company/documentation/getting-started/introduction.mdx new file mode 100644 index 000000000..0f414c62a --- /dev/null +++ b/company/documentation/getting-started/introduction.mdx @@ -0,0 +1,97 @@ +--- +title: "What is Infisical?" +sidebarTitle: "What is Infisical?" +description: "An Introduction to the Infisical secret management platform." +--- + +Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. +It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database +credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure +sharing of secrets among engineers. + +Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. + + + + Get started with Infisical Cloud in just a few minutes. + + + Self-host Infisical on your own infrastructure. + + + +## Why Infisical? + +Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: +- Streamlined **local development** processes (switching .env files to [Infisical CLI](/cli/commands/run) and removing secrets from developer machines). +- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). +- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. +- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. +- **Facilitated workflows** around [secret change management](/documentation/platform/pr-workflows), [access requests](/documentation/platform/access-controls/access-requests), [temporary access provisioning](/documentation/platform/access-controls/temporary-access), and more. +- **Improved security posture** thanks to [secret scanning](/cli/scanning-overview), [granular access control policies](/documentation/platform/access-controls/overview), [automated secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview), and [dynamic secrets](/documentation/platform/dynamic-secrets/overview) capabilities. + +## How does Infisical work? + +To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. + +**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. + +As a result, the 3 main concepts that are important to understand are: +- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. +- **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). +- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, etc.). + +## How to get started with Infisical? + +Depending on your use case, it might be helpful to look into some of the resources and guides provided below. + + + + Inject secrets into any application process/environment. + + + Fetch secrets with any programming language on demand. + + + Inject secrets into Docker containers. + + + Fetch and save secrets as native Kubernetes secrets. + + + Fetch secrets via HTTP request. + + + Explore integrations for GitHub, Vercel, AWS, and more. + + diff --git a/company/favicon.png b/company/favicon.png new file mode 100644 index 000000000..45c9b868e Binary files /dev/null and b/company/favicon.png differ diff --git a/company/logo/dark.svg b/company/logo/dark.svg new file mode 100644 index 000000000..f88594746 --- /dev/null +++ b/company/logo/dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/company/logo/light.svg b/company/logo/light.svg new file mode 100644 index 000000000..16fc09e5e --- /dev/null +++ b/company/logo/light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/company/mint.json b/company/mint.json new file mode 100644 index 000000000..d867ab7a5 --- /dev/null +++ b/company/mint.json @@ -0,0 +1,80 @@ +{ + "name": "Infisical", + "openapi": "https://app.infisical.com/api/docs/json", + "logo": { + "dark": "/logo/dark.svg", + "light": "/logo/light.svg", + "href": "https://infisical.com" + }, + "favicon": "/favicon.png", + "colors": { + "primary": "#26272b", + "light": "#97b31d", + "dark": "#A1B659", + "ultraLight": "#E7F256", + "ultraDark": "#8D9F4C", + "background": { + "light": "#ffffff", + "dark": "#0D1117" + }, + "anchors": { + "from": "#000000", + "to": "#707174" + } + }, + "modeToggle": { + "default": "light", + "isHidden": true + }, + "feedback": { + "suggestEdit": true, + "raiseIssue": true, + "thumbsRating": true + }, + "api": { + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] + }, + "topbarLinks": [ + { + "name": "Log In", + "url": "https://app.infisical.com/login" + } + ], + "topbarCtaButton": { + "name": "Start for Free", + "url": "https://app.infisical.com/signup" + }, + "tabs": [ + { + "name": "Integrations", + "url": "integrations" + }, + { + "name": "CLI", + "url": "cli" + }, + { + "name": "API Reference", + "url": "api-reference" + }, + { + "name": "SDKs", + "url": "sdks" + }, + { + "name": "Changelog", + "url": "changelog" + } + ], + "navigation": [ + { + "group": "Getting Started", + "pages": [ + "documentation/getting-started/introduction" + ] + } + ], + "integrations": { + "intercom": "hsg644ru" + } +} diff --git a/company/style.css b/company/style.css new file mode 100644 index 000000000..b76d06450 --- /dev/null +++ b/company/style.css @@ -0,0 +1,142 @@ +#navbar .max-w-8xl { + max-width: 100%; + border-bottom: 1px solid #ebebeb; + background-color: #fcfcfc; +} + +.max-w-8xl { + /* background-color: #f5f5f5; */ +} + +#sidebar { + left: 0; + padding-left: 48px; + padding-right: 30px; + border-right: 1px; + border-color: #cdd64b; + background-color: #fcfcfc; + border-right: 1px solid #ebebeb; +} + +#sidebar .relative .sticky { + opacity: 0; +} + +#sidebar li > div.mt-2 { + border-radius: 0; + padding: 5px; +} + +#sidebar li > a.mt-2 { + border-radius: 0; + padding: 5px; +} + +#sidebar li > a.leading-6 { + border-radius: 0; + padding: 0px; +} + +/* #sidebar ul > div.mt-12 { + padding-top: 30px; + position: relative; +} + +#sidebar ul > div.mt-12 h5 { + position: absolute; + left: -12px; + top: -0px; +} */ + +#header { + border-left: 1px solid #26272b; + padding-left: 16px; + padding-right: 16px; + background-color: #f5f5f5; + padding-bottom: 10px; + padding-top: 10px; +} + +#content-area .mt-8 .block{ + border-radius: 0; + border-width: 1px; + border-color: #ebebeb; +} + +#content-area .mt-8 .rounded-xl{ + border-radius: 0; +} + +#content-area .mt-8 .rounded-lg{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-xl{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-lg{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-md{ + border-radius: 0; +} + +#content-area .mt-8 .rounded-md{ + border-radius: 0; +} + +#content-area div.my-4{ + border-radius: 0; + border-width: 1px; +} + +#content-area div.flex-1 { + /* text-transform: uppercase; */ + opacity: 0.8; + font-weight: 400; +} + +#content-area button { + border-radius: 0; +} + +#content-area a { + border-radius: 0; +} + +#content-area .not-prose { + border-radius: 0; +} + +/* .eyebrow { + text-transform: uppercase; + font-weight: 400; + color: red; +} */ + +#content-container { + /* background-color: #f5f5f5; */ + margin-top: 2rem; +} + +#topbar-cta-button .group .absolute { + background-color: black; + border-radius: 0px; +} + +/* #topbar-cta-button .group .absolute:hover { + background-color: white; + border-radius: 0px; +} */ + +#topbar-cta-button .group .flex { + margin-top: 5px; + margin-bottom: 5px; + font-size: medium; +} + +.flex-1 .flex .items-center { + /* background-color: #f5f5f5; */ +} \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 774cd5b02..422fe43f3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3.9" services: nginx: @@ -10,34 +10,87 @@ services: volumes: - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - - frontend - backend - networks: - - infisical-dev + - frontend - backend: - container_name: infisical-dev-backend - restart: unless-stopped + db: + image: postgres:14-alpine + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: infisical + POSTGRES_USER: infisical + POSTGRES_DB: infisical + + redis: + image: redis + container_name: infisical-dev-redis + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - 6379:6379 + volumes: + - redis_data:/data + + redis-commander: + container_name: infisical-dev-redis-commander + image: rediscommander/redis-commander + restart: always depends_on: - - mongo - - smtp-server - redis + environment: + - REDIS_HOSTS=local:redis:6379 + ports: + - "8085:8081" + + db-test: + profiles: ["test"] + image: postgres:14-alpine + ports: + - "5430:5432" + environment: + POSTGRES_PASSWORD: infisical + POSTGRES_USER: infisical + POSTGRES_DB: infisical-test + + db-migration: + container_name: infisical-db-migration + depends_on: + - db build: context: ./backend - dockerfile: Dockerfile - volumes: - - ./backend/src:/app/src - - ./backend/nodemon.json:/app/nodemon.json - - /app/node_modules - - ./backend/api-documentation.json:/app/api-documentation.json - - ./backend/swagger.ts:/app/swagger.ts - command: npm run dev + dockerfile: Dockerfile.dev env_file: .env + environment: + - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + command: npm run migration:latest + volumes: + - ./backend/src:/app/src + + backend: + container_name: infisical-dev-api + build: + context: ./backend + dockerfile: Dockerfile.dev + depends_on: + db: + condition: service_started + redis: + condition: service_started + db-migration: + condition: service_completed_successfully + env_file: + - .env + ports: + - 4000:4000 environment: - NODE_ENV=development - - MONGO_URL=mongodb://root:example@mongo:27017/?authSource=admin - networks: - - infisical-dev + - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + - TELEMETRY_ENABLED=false + volumes: + - ./backend/src:/app/src extra_hosts: - "host.docker.internal:host-gateway" @@ -55,81 +108,60 @@ services: env_file: .env environment: - NEXT_PUBLIC_ENV=development - - INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED} - networks: - - infisical-dev + - INFISICAL_TELEMETRY_ENABLED=false - mongo: - image: mongo - container_name: infisical-dev-mongo + pgadmin: + image: dpage/pgadmin4 restart: always - env_file: .env environment: - - MONGO_INITDB_ROOT_USERNAME=root - - MONGO_INITDB_ROOT_PASSWORD=example - volumes: - - mongo-data:/data/db - networks: - - infisical-dev - - mongo-express: - container_name: infisical-dev-mongo-express - image: mongo-express - restart: always - depends_on: - - mongo - env_file: .env - environment: - - ME_CONFIG_MONGODB_ADMINUSERNAME=root - - ME_CONFIG_MONGODB_ADMINPASSWORD=example - - ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/ + PGADMIN_DEFAULT_EMAIL: admin@example.com + PGADMIN_DEFAULT_PASSWORD: pass ports: - - 8081:8081 - networks: - - infisical-dev + - 5050:80 + depends_on: + - db smtp-server: container_name: infisical-dev-smtp-server image: lytrax/mailhog:latest # https://github.com/mailhog/MailHog/issues/353#issuecomment-821137362 restart: always logging: - driver: 'none' # disable saving logs + driver: "none" # disable saving logs ports: - 1025:1025 # SMTP server - 8025:8025 # Web UI - networks: - - infisical-dev - redis: - image: redis - container_name: infisical-dev-redis - environment: - - ALLOW_EMPTY_PASSWORD=yes - ports: - - 6379:6379 - volumes: - - redis_data:/data - networks: - - infisical-dev - - redis-commander: - container_name: infisical-dev-redis-commander - image: rediscommander/redis-commander + openldap: # note: more advanced configuration is available + image: osixia/openldap:1.5.0 restart: always - depends_on: - - redis environment: - - REDIS_HOSTS=local:redis:6379 + LDAP_ORGANISATION: Acme + LDAP_DOMAIN: acme.com + LDAP_ADMIN_PASSWORD: admin ports: - - "8085:8081" - networks: - - infisical-dev + - 389:389 + - 636:636 + volumes: + - ldap_data:/var/lib/ldap + - ldap_config:/etc/ldap/slapd.d + profiles: [ldap] + + phpldapadmin: # username: cn=admin,dc=acme,dc=com, pass is admin + image: osixia/phpldapadmin:latest + restart: always + environment: + - PHPLDAPADMIN_LDAP_HOSTS=openldap + - PHPLDAPADMIN_HTTPS=false + ports: + - 6433:80 + depends_on: + - openldap + profiles: [ldap] volumes: - mongo-data: + postgres-data: driver: local redis_data: driver: local - -networks: - infisical-dev: + ldap_data: + ldap_config: diff --git a/docker-compose.pg.yml b/docker-compose.pg.yml deleted file mode 100644 index a73f7ca5e..000000000 --- a/docker-compose.pg.yml +++ /dev/null @@ -1,146 +0,0 @@ -version: "3.9" - -services: - nginx: - container_name: infisical-dev-nginx - image: nginx - restart: always - ports: - - 8080:80 - volumes: - - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro - depends_on: - - backend - - frontend - - db: - image: postgres:14-alpine - ports: - - "5432:5432" - volumes: - - postgres-data:/var/lib/postgresql/data - environment: - POSTGRES_PASSWORD: infisical - POSTGRES_USER: infisical - POSTGRES_DB: infisical - - redis: - image: redis - container_name: infisical-dev-redis - environment: - - ALLOW_EMPTY_PASSWORD=yes - ports: - - 6379:6379 - volumes: - - redis_data:/data - - redis-commander: - container_name: infisical-dev-redis-commander - image: rediscommander/redis-commander - restart: always - depends_on: - - redis - environment: - - REDIS_HOSTS=local:redis:6379 - ports: - - "8085:8081" - - db-test: - profiles: ["test"] - image: postgres:14-alpine - ports: - - "5430:5432" - environment: - POSTGRES_PASSWORD: infisical - POSTGRES_USER: infisical - POSTGRES_DB: infisical-test - - backend: - container_name: infisical-dev-api - build: - context: ./backend - dockerfile: Dockerfile.dev - depends_on: - - db - - redis - env_file: - - .env - ports: - - 4000:4000 - environment: - - NODE_ENV=development - - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable - volumes: - - ./backend/src:/app/src - - frontend: - container_name: infisical-dev-frontend - restart: unless-stopped - depends_on: - - backend - build: - context: ./frontend - dockerfile: Dockerfile.dev - volumes: - - ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files - - ./frontend/public:/app/public - env_file: .env - environment: - - NEXT_PUBLIC_ENV=development - - INFISICAL_TELEMETRY_ENABLED=false - - pgadmin: - image: dpage/pgadmin4 - restart: always - environment: - PGADMIN_DEFAULT_EMAIL: admin@example.com - PGADMIN_DEFAULT_PASSWORD: pass - ports: - - 5050:80 - depends_on: - - db - - smtp-server: - container_name: infisical-dev-smtp-server - image: lytrax/mailhog:latest # https://github.com/mailhog/MailHog/issues/353#issuecomment-821137362 - restart: always - logging: - driver: "none" # disable saving logs - ports: - - 1025:1025 # SMTP server - - 8025:8025 # Web UI - - # mongo: - # image: mongo - # container_name: infisical-dev-mongo - # restart: always - # env_file: .env - # environment: - # - MONGO_INITDB_ROOT_USERNAME=root - # - MONGO_INITDB_ROOT_PASSWORD=example - # volumes: - # - mongo-data:/data/db - # ports: - # - 27017:27017 - # - # mongo-express: - # container_name: infisical-dev-mongo-express - # image: mongo-express - # restart: always - # depends_on: - # - mongo - # env_file: .env - # environment: - # - ME_CONFIG_MONGODB_ADMINUSERNAME=root - # - ME_CONFIG_MONGODB_ADMINPASSWORD=example - # - ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/ - # ports: - # - 8081:8081 - -volumes: - postgres-data: - driver: local - redis_data: - driver: local - mongo-data: - driver: local diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 000000000..86a8e4cca --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,71 @@ +version: "3" + +services: + db-migration: + container_name: infisical-db-migration + depends_on: + db: + condition: service_healthy + image: infisical/infisical:latest-postgres + env_file: .env + command: npm run migration:latest + pull_policy: always + networks: + - infisical + + backend: + container_name: infisical-backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + db-migration: + condition: service_completed_successfully + image: infisical/infisical:latest-postgres + pull_policy: always + env_file: .env + ports: + - 80:8080 + environment: + - NODE_ENV=production + networks: + - infisical + + redis: + image: redis + container_name: infisical-dev-redis + env_file: .env + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - 6379:6379 + networks: + - infisical + volumes: + - redis_data:/data + + db: + container_name: infisical-db + image: postgres:14-alpine + restart: always + env_file: .env + volumes: + - pg_data:/var/lib/postgresql/data + networks: + - infisical + healthcheck: + test: "pg_isready --username=${POSTGRES_USER} && psql --username=${POSTGRES_USER} --list" + interval: 5s + timeout: 10s + retries: 10 + +volumes: + pg_data: + driver: local + redis_data: + driver: local + +networks: + infisical: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c159a2175..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: "3" - -services: - backend: - container_name: infisical-backend - restart: unless-stopped - depends_on: - - mongo - image: infisical/infisical:latest - env_file: .env - ports: - - 80:8080 - environment: - - NODE_ENV=production - networks: - - infisical - - redis: - image: redis - container_name: infisical-dev-redis - env_file: .env - environment: - - ALLOW_EMPTY_PASSWORD=yes - ports: - - 6379:6379 - networks: - - infisical - volumes: - - redis_data:/data - - mongo: - container_name: infisical-mongo - image: mongo - restart: always - env_file: .env - environment: - - MONGO_INITDB_ROOT_USERNAME=${MONGO_USERNAME} - - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD} - volumes: - - mongo-data:/data/db - networks: - - infisical - -volumes: - mongo-data: - driver: local - redis_data: - driver: local - -networks: - infisical: diff --git a/docker-swarm/.env-example b/docker-swarm/.env-example new file mode 100644 index 000000000..03d05a08e --- /dev/null +++ b/docker-swarm/.env-example @@ -0,0 +1,59 @@ +# Keys +# Required key for platform encryption/decryption ops +# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION +ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 + +# JWT +# Required secrets to sign JWT tokens +# THIS IS A SAMPLE AUTH_SECRET KEY AND SHOULD NEVER BE USED FOR PRODUCTION +AUTH_SECRET=5lrMXKKWCVocS/uerPsl7V+TX/aaUaI7iDkgl3tSmLE= + +DB_CONNECTION_URI=postgres://infisical:infisical@haproxy:5433/infisical?sslmode=no-verify +# Redis +REDIS_URL=redis://:123456@haproxy:6379 + + +# Website URL +# Required +SITE_URL=http://localhost:8080 + +# Mail/SMTP +SMTP_HOST= +SMTP_PORT= +SMTP_NAME= +SMTP_USERNAME= +SMTP_PASSWORD= + +# Integration +# Optional only if integration is used +CLIENT_ID_HEROKU= +CLIENT_ID_VERCEL= +CLIENT_ID_NETLIFY= +CLIENT_ID_GITHUB= +CLIENT_ID_GITLAB= +CLIENT_ID_BITBUCKET= +CLIENT_SECRET_HEROKU= +CLIENT_SECRET_VERCEL= +CLIENT_SECRET_NETLIFY= +CLIENT_SECRET_GITHUB= +CLIENT_SECRET_GITLAB= +CLIENT_SECRET_BITBUCKET= +CLIENT_SLUG_VERCEL= + +# Sentry (optional) for monitoring errors +SENTRY_DSN= + +# Infisical Cloud-specific configs +# Ignore - Not applicable for self-hosted version +POSTHOG_HOST= +POSTHOG_PROJECT_API_KEY= + +# 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/docker-swarm/haproxy.cfg b/docker-swarm/haproxy.cfg new file mode 100644 index 000000000..984943c25 --- /dev/null +++ b/docker-swarm/haproxy.cfg @@ -0,0 +1,78 @@ +global + maxconn 10000 + log stdout format raw local0 + +defaults + log global + mode tcp + retries 3 + timeout client 30m + timeout connect 10s + timeout server 30m + timeout check 5s + +listen stats + mode http + bind *:7000 + stats enable + stats uri / + +resolvers hostdns + nameserver dns 127.0.0.11:53 + resolve_retries 3 + timeout resolve 1s + timeout retry 1s + hold valid 5s + +frontend postgres_master + bind *:5433 + default_backend postgres_master_backend + +frontend postgres_replicas + bind *:5434 + default_backend postgres_replica_backend + + +backend postgres_master_backend + option httpchk GET /master + http-check expect status 200 + default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions + server postgres-1 postgres-1:5432 check port 8008 resolvers hostdns + server postgres-2 postgres-2:5432 check port 8008 resolvers hostdns + server postgres-3 postgres-3:5432 check port 8008 resolvers hostdns + +backend postgres_replica_backend + option httpchk GET /replica + http-check expect status 200 + default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions + server postgres-1 postgres-1:5432 check port 8008 resolvers hostdns + server postgres-2 postgres-2:5432 check port 8008 resolvers hostdns + server postgres-3 postgres-3:5432 check port 8008 resolvers hostdns + + +frontend redis_master_frontend + bind *:6379 + default_backend redis_master_backend + +backend redis_master_backend + option tcp-check + tcp-check send AUTH\ 123456\r\n + tcp-check expect string +OK + tcp-check send PING\r\n + tcp-check expect string +PONG + tcp-check send info\ replication\r\n + tcp-check expect string role:master + tcp-check send QUIT\r\n + tcp-check expect string +OK + server redis_master redis_replica0:6379 check inter 1s + server redis_replica1 redis_replica1:6379 check inter 1s + server redis_replica2 redis_replica2:6379 check inter 1s + +frontend infisical_frontend + bind *:8080 + default_backend infisical_backend + +backend infisical_backend + option httpchk GET /api/status + http-check expect status 200 + server infisical infisical:8080 check inter 1s diff --git a/docker-swarm/stack.yaml b/docker-swarm/stack.yaml new file mode 100644 index 000000000..4087c7836 --- /dev/null +++ b/docker-swarm/stack.yaml @@ -0,0 +1,261 @@ +version: "3" + +services: + haproxy: + image: haproxy:latest + ports: + - '7001:7000' + - '5002:5433' # Postgres master + - '5003:5434' # Postgres read + - '6379:6379' + - '8080:8080' + networks: + - infisical + configs: + - source: haproxy-config + target: /usr/local/etc/haproxy/haproxy.cfg + deploy: + mode: global + + infisical: + container_name: infisical-backend + image: infisical/infisical:v0.60.1-postgres + env_file: .env + networks: + - infisical + secrets: + - env_file + deploy: + replicas: 5 + + etcd1: + image: ghcr.io/zalando/spilo-16:3.2-p2 + networks: + - infisical + environment: + ETCD_UNSUPPORTED_ARCH: arm64 + container_name: demo-etcd1 + deploy: + placement: + constraints: + - node.labels.name == node1 + hostname: etcd1 + command: | + etcd --name etcd1 + --listen-client-urls http://0.0.0.0:2379 + --listen-peer-urls=http://0.0.0.0:2380 + --advertise-client-urls http://etcd1:2379 + --initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 + --initial-advertise-peer-urls=http://etcd1:2380 + --initial-cluster-state=new + + etcd2: + image: ghcr.io/zalando/spilo-16:3.2-p2 + networks: + - infisical + environment: + ETCD_UNSUPPORTED_ARCH: arm64 + container_name: demo-etcd2 + hostname: etcd2 + deploy: + placement: + constraints: + - node.labels.name == node2 + command: | + etcd --name etcd2 + --listen-client-urls http://0.0.0.0:2379 + --listen-peer-urls=http://0.0.0.0:2380 + --advertise-client-urls http://etcd2:2379 + --initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 + --initial-advertise-peer-urls=http://etcd2:2380 + --initial-cluster-state=new + + etcd3: + image: ghcr.io/zalando/spilo-16:3.2-p2 + networks: + - infisical + environment: + ETCD_UNSUPPORTED_ARCH: arm64 + container_name: demo-etcd3 + hostname: etcd3 + deploy: + placement: + constraints: + - node.labels.name == node3 + command: | + etcd --name etcd3 + --listen-client-urls http://0.0.0.0:2379 + --listen-peer-urls=http://0.0.0.0:2380 + --advertise-client-urls http://etcd3:2379 + --initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380 + --initial-advertise-peer-urls=http://etcd3:2380 + --initial-cluster-state=new + + spolo1: + image: ghcr.io/zalando/spilo-16:3.2-p2 + container_name: postgres-1 + networks: + - infisical + hostname: postgres-1 + environment: + ETCD_HOSTS: etcd1:2379,etcd2:2379,etcd3:2379 + PGPASSWORD_SUPERUSER: "postgres" + PGUSER_SUPERUSER: "postgres" + SCOPE: infisical + volumes: + - postgres_data1:/home/postgres/pgdata + deploy: + placement: + constraints: + - node.labels.name == node1 + + spolo2: + image: ghcr.io/zalando/spilo-16:3.2-p2 + container_name: postgres-2 + networks: + - infisical + hostname: postgres-2 + environment: + ETCD_HOSTS: etcd1:2379,etcd2:2379,etcd3:2379 + PGPASSWORD_SUPERUSER: "postgres" + PGUSER_SUPERUSER: "postgres" + SCOPE: infisical + volumes: + - postgres_data2:/home/postgres/pgdata + deploy: + placement: + constraints: + - node.labels.name == node2 + + spolo3: + image: ghcr.io/zalando/spilo-16:3.2-p2 + container_name: postgres-3 + networks: + - infisical + hostname: postgres-3 + environment: + ETCD_HOSTS: etcd1:2379,etcd2:2379,etcd3:2379 + PGPASSWORD_SUPERUSER: "postgres" + PGUSER_SUPERUSER: "postgres" + SCOPE: infisical + volumes: + - postgres_data3:/home/postgres/pgdata + deploy: + placement: + constraints: + - node.labels.name == node3 + + + redis_replica0: + image: bitnami/redis:6.2.10 + environment: + - REDIS_REPLICATION_MODE=master + - REDIS_PASSWORD=123456 + networks: + - infisical + deploy: + placement: + constraints: + - node.labels.name == node1 + + redis_replica1: + image: bitnami/redis:6.2.10 + environment: + - REDIS_REPLICATION_MODE=slave + - REDIS_MASTER_HOST=redis_replica0 + - REDIS_MASTER_PORT_NUMBER=6379 + - REDIS_MASTER_PASSWORD=123456 + - REDIS_PASSWORD=123456 + networks: + - infisical + deploy: + placement: + constraints: + - node.labels.name == node2 + + redis_replica2: + image: bitnami/redis:6.2.10 + environment: + - REDIS_REPLICATION_MODE=slave + - REDIS_MASTER_HOST=redis_replica0 + - REDIS_MASTER_PORT_NUMBER=6379 + - REDIS_MASTER_PASSWORD=123456 + - REDIS_PASSWORD=123456 + networks: + - infisical + deploy: + placement: + constraints: + - node.labels.name == node3 + + redis_sentinel1: + image: bitnami/redis-sentinel:6.2.10 + environment: + - REDIS_SENTINEL_QUORUM=2 + - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=5000 + - REDIS_SENTINEL_FAILOVER_TIMEOUT=60000 + - REDIS_SENTINEL_PORT_NUMBER=26379 + - REDIS_MASTER_HOST=redis_replica1 + - REDIS_MASTER_PORT_NUMBER=6379 + - REDIS_MASTER_PASSWORD=123456 + networks: + - infisical + deploy: + placement: + constraints: + - node.labels.name == node1 + + redis_sentinel2: + image: bitnami/redis-sentinel:6.2.10 + environment: + - REDIS_SENTINEL_QUORUM=2 + - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=5000 + - REDIS_SENTINEL_FAILOVER_TIMEOUT=60000 + - REDIS_SENTINEL_PORT_NUMBER=26379 + - REDIS_MASTER_HOST=redis_replica1 + - REDIS_MASTER_PORT_NUMBER=6379 + - REDIS_MASTER_PASSWORD=123456 + networks: + - infisical + deploy: + placement: + constraints: + - node.labels.name == node2 + + redis_sentinel3: + image: bitnami/redis-sentinel:6.2.10 + environment: + - REDIS_SENTINEL_QUORUM=2 + - REDIS_SENTINEL_DOWN_AFTER_MILLISECONDS=5000 + - REDIS_SENTINEL_FAILOVER_TIMEOUT=60000 + - REDIS_SENTINEL_PORT_NUMBER=26379 + - REDIS_MASTER_HOST=redis_replica1 + - REDIS_MASTER_PORT_NUMBER=6379 + - REDIS_MASTER_PASSWORD=123456 + networks: + - infisical + deploy: + placement: + constraints: + - node.labels.name == node3 + +networks: + infisical: + + +volumes: + postgres_data1: + postgres_data2: + postgres_data3: + postgres_data4: + redis0: + redis1: + redis2: + +configs: + haproxy-config: + file: ./haproxy.cfg + +secrets: + env_file: + file: .env diff --git a/docs/api-reference/endpoints/environments/create.mdx b/docs/api-reference/endpoints/environments/create.mdx index 2527c613d..826dcce3d 100644 --- a/docs/api-reference/endpoints/environments/create.mdx +++ b/docs/api-reference/endpoints/environments/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v2/workspace/{workspaceId}/environments" +openapi: "POST /api/v1/workspace/{workspaceId}/environments" --- diff --git a/docs/api-reference/endpoints/environments/delete.mdx b/docs/api-reference/endpoints/environments/delete.mdx index 944e42961..903e58d2a 100644 --- a/docs/api-reference/endpoints/environments/delete.mdx +++ b/docs/api-reference/endpoints/environments/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v2/workspace/{workspaceId}/environments" ---- \ No newline at end of file +openapi: "DELETE /api/v1/workspace/{workspaceId}/environments/{id}" +--- diff --git a/docs/api-reference/endpoints/environments/update.mdx b/docs/api-reference/endpoints/environments/update.mdx index 291344d6c..f93968668 100644 --- a/docs/api-reference/endpoints/environments/update.mdx +++ b/docs/api-reference/endpoints/environments/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PUT /api/v2/workspace/{workspaceId}/environments" ---- \ No newline at end of file +openapi: "PATCH /api/v1/workspace/{workspaceId}/environments/{id}" +--- diff --git a/docs/api-reference/endpoints/folders/create.mdx b/docs/api-reference/endpoints/folders/create.mdx index 397f43cb5..e1ff3004a 100644 --- a/docs/api-reference/endpoints/folders/create.mdx +++ b/docs/api-reference/endpoints/folders/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/folders/" ---- \ No newline at end of file +openapi: "POST /api/v1/folders" +--- diff --git a/docs/api-reference/endpoints/folders/delete.mdx b/docs/api-reference/endpoints/folders/delete.mdx index 0aacd66e2..a106cc2eb 100644 --- a/docs/api-reference/endpoints/folders/delete.mdx +++ b/docs/api-reference/endpoints/folders/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/folders/{folderName}" ---- \ No newline at end of file +openapi: "DELETE /api/v1/folders/{folderIdOrName}" +--- diff --git a/docs/api-reference/endpoints/folders/list.mdx b/docs/api-reference/endpoints/folders/list.mdx index c467c5975..f40f93273 100644 --- a/docs/api-reference/endpoints/folders/list.mdx +++ b/docs/api-reference/endpoints/folders/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/folders/" ---- \ No newline at end of file +openapi: "GET /api/v1/folders" +--- diff --git a/docs/api-reference/endpoints/folders/update.mdx b/docs/api-reference/endpoints/folders/update.mdx index 3ceae7fb6..c54778e94 100644 --- a/docs/api-reference/endpoints/folders/update.mdx +++ b/docs/api-reference/endpoints/folders/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/folders/{folderName}" ---- \ No newline at end of file +openapi: "PATCH /api/v1/folders/{folderId}" +--- diff --git a/docs/api-reference/endpoints/identities/create.mdx b/docs/api-reference/endpoints/identities/create.mdx index 05a11521f..a6595f97a 100644 --- a/docs/api-reference/endpoints/identities/create.mdx +++ b/docs/api-reference/endpoints/identities/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/identities/" ---- \ No newline at end of file +openapi: "POST /api/v1/identities" +--- diff --git a/docs/api-reference/endpoints/identities/delete.mdx b/docs/api-reference/endpoints/identities/delete.mdx index 07e79dfe9..5b6ed220a 100644 --- a/docs/api-reference/endpoints/identities/delete.mdx +++ b/docs/api-reference/endpoints/identities/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" openapi: "DELETE /api/v1/identities/{identityId}" ---- \ No newline at end of file +--- diff --git a/docs/api-reference/endpoints/identities/update.mdx b/docs/api-reference/endpoints/identities/update.mdx index c0940467b..02d213181 100644 --- a/docs/api-reference/endpoints/identities/update.mdx +++ b/docs/api-reference/endpoints/identities/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" openapi: "PATCH /api/v1/identities/{identityId}" ---- \ No newline at end of file +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx b/docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx new file mode 100644 index 000000000..8e02c28a3 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/create-permanent.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Permanent" +openapi: "POST /api/v1/additional-privilege/identity/permanent" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx b/docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx new file mode 100644 index 000000000..808f27859 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/create-temporary.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Temporary" +openapi: "POST /api/v1/additional-privilege/identity/temporary" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/delete.mdx b/docs/api-reference/endpoints/identity-specific-privilege/delete.mdx new file mode 100644 index 000000000..430282789 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/additional-privilege/identity" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx b/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx new file mode 100644 index 000000000..a6ec27217 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/find-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Find By Privilege Slug" +openapi: "GET /api/v1/additional-privilege/identity/{privilegeSlug}" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/list.mdx b/docs/api-reference/endpoints/identity-specific-privilege/list.mdx new file mode 100644 index 000000000..4698ed838 --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/additional-privilege/identity" +--- diff --git a/docs/api-reference/endpoints/identity-specific-privilege/update.mdx b/docs/api-reference/endpoints/identity-specific-privilege/update.mdx new file mode 100644 index 000000000..987d6ac8c --- /dev/null +++ b/docs/api-reference/endpoints/identity-specific-privilege/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/additional-privilege/identity" +--- diff --git a/docs/api-reference/endpoints/integrations/create-auth.mdx b/docs/api-reference/endpoints/integrations/create-auth.mdx new file mode 100644 index 000000000..5af7a0f9c --- /dev/null +++ b/docs/api-reference/endpoints/integrations/create-auth.mdx @@ -0,0 +1,32 @@ +--- +title: "Create Auth" +openapi: "POST /api/v1/integration-auth/access-token" +--- + +## Integration Authentication Parameters + +The integration authentication endpoint is generic and can be used for all native integrations. +For specific integration parameters for a given service, please review the respective documentation below. + + + + + This value must be **aws-secret-manager**. + + + Infisical project id for the integration. + + + The AWS IAM User Access ID. + + + The AWS IAM User Access Secret Key. + + + + Coming Soon + + + Coming Soon + + diff --git a/docs/api-reference/endpoints/integrations/create.mdx b/docs/api-reference/endpoints/integrations/create.mdx new file mode 100644 index 000000000..0992e91b9 --- /dev/null +++ b/docs/api-reference/endpoints/integrations/create.mdx @@ -0,0 +1,40 @@ +--- +title: "Create" +openapi: "POST /api/v1/integration" +--- + +## Integration Parameters + +The integration creation endpoint is generic and can be used for all native integrations. +For specific integration parameters for a given service, please review the respective documentation below. + + + + + The ID of the integration auth object for authentication with AWS. + Refer [Create Integration Auth](./create-auth) for more info + + + Whether the integration should be active or inactive + + + The secret name used when saving secret in AWS SSM. Used for naming and can be arbitrary. + + + The AWS region of the SSM. Example: `us-east-1` + + + The Infisical environment slug from where secrets will be synced from. Example: `dev` + + + The Infisical folder path from where secrets will be synced from. Example: `/some/path`. The root of the environment is `/`. + + + + Coming Soon + + + Coming Soon + + + diff --git a/docs/api-reference/endpoints/integrations/delete-auth-by-id.mdx b/docs/api-reference/endpoints/integrations/delete-auth-by-id.mdx new file mode 100644 index 000000000..5884363fc --- /dev/null +++ b/docs/api-reference/endpoints/integrations/delete-auth-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Auth By ID" +openapi: "DELETE /api/v1/integration-auth/{integrationAuthId}" +--- diff --git a/docs/api-reference/endpoints/integrations/delete-auth.mdx b/docs/api-reference/endpoints/integrations/delete-auth.mdx new file mode 100644 index 000000000..93d957903 --- /dev/null +++ b/docs/api-reference/endpoints/integrations/delete-auth.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Auth" +openapi: "DELETE /api/v1/integration-auth" +--- diff --git a/docs/api-reference/endpoints/integrations/delete.mdx b/docs/api-reference/endpoints/integrations/delete.mdx new file mode 100644 index 000000000..51df56de7 --- /dev/null +++ b/docs/api-reference/endpoints/integrations/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/integration/{integrationId}" +--- diff --git a/docs/api-reference/endpoints/integrations/find-auth.mdx b/docs/api-reference/endpoints/integrations/find-auth.mdx new file mode 100644 index 000000000..439b82935 --- /dev/null +++ b/docs/api-reference/endpoints/integrations/find-auth.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Auth By ID" +openapi: "GET /api/v1/integration-auth/{integrationAuthId}" +--- diff --git a/docs/api-reference/endpoints/integrations/list-auth.mdx b/docs/api-reference/endpoints/integrations/list-auth.mdx new file mode 100644 index 000000000..3ca961d98 --- /dev/null +++ b/docs/api-reference/endpoints/integrations/list-auth.mdx @@ -0,0 +1,4 @@ +--- +title: "List Auth" +openapi: "GET /api/v1/workspace/{workspaceId}/authorizations" +--- diff --git a/docs/api-reference/endpoints/integrations/list-project-integrations.mdx b/docs/api-reference/endpoints/integrations/list-project-integrations.mdx new file mode 100644 index 000000000..24ebbf7d8 --- /dev/null +++ b/docs/api-reference/endpoints/integrations/list-project-integrations.mdx @@ -0,0 +1,4 @@ +--- +title: "List Project Integrations" +openapi: "GET /api/v1/workspace/{workspaceId}/integrations" +--- diff --git a/docs/api-reference/endpoints/integrations/update.mdx b/docs/api-reference/endpoints/integrations/update.mdx new file mode 100644 index 000000000..8567c46ae --- /dev/null +++ b/docs/api-reference/endpoints/integrations/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/integration/{integrationId}" +--- diff --git a/docs/api-reference/endpoints/organizations/list-identity-memberships.mdx b/docs/api-reference/endpoints/organizations/list-identity-memberships.mdx index 1929a4b59..5995184a5 100644 --- a/docs/api-reference/endpoints/organizations/list-identity-memberships.mdx +++ b/docs/api-reference/endpoints/organizations/list-identity-memberships.mdx @@ -1,4 +1,4 @@ --- title: "List Identity Memberships" -openapi: "GET /api/v2/organizations/{organizationId}/identity-memberships" ---- \ No newline at end of file +openapi: "GET /api/v2/organizations/{orgId}/identity-memberships" +--- diff --git a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx new file mode 100644 index 000000000..285b1d1c4 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity Membership" +openapi: "POST /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx new file mode 100644 index 000000000..e2b266626 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Identity Membership" +openapi: "DELETE /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/get-by-id.mdx b/docs/api-reference/endpoints/project-identities/get-by-id.mdx new file mode 100644 index 000000000..37f4192d7 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx new file mode 100644 index 000000000..e5162e693 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "List Identity Memberships" +openapi: "GET /api/v2/workspace/{projectId}/identity-memberships" +--- diff --git a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx new file mode 100644 index 000000000..667cf7eb3 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Identity Membership" +openapi: "PATCH /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-users/delete-membership.mdx b/docs/api-reference/endpoints/project-users/delete-membership.mdx new file mode 100644 index 000000000..1995e4726 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/delete-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete User Membership" +openapi: "DELETE /api/v1/workspace/{workspaceId}/memberships/{membershipId}" +--- diff --git a/docs/api-reference/endpoints/project-users/get-by-username.mdx b/docs/api-reference/endpoints/project-users/get-by-username.mdx new file mode 100644 index 000000000..ec69d4947 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/get-by-username.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Username" +openapi: "POST /api/v1/workspace/{workspaceId}/memberships/details" +--- diff --git a/docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx b/docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx new file mode 100644 index 000000000..28acd3336 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/invite-member-to-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Invite Member" +openapi: "POST /api/v2/workspace/{projectId}/memberships" +--- diff --git a/docs/api-reference/endpoints/project-users/memberships.mdx b/docs/api-reference/endpoints/project-users/memberships.mdx new file mode 100644 index 000000000..3c4735f94 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "Get User Memberships" +openapi: "GET /api/v1/workspace/{workspaceId}/memberships" +--- diff --git a/docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx b/docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx new file mode 100644 index 000000000..8b781a4e8 --- /dev/null +++ b/docs/api-reference/endpoints/project-users/remove-member-from-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Member" +openapi: "DELETE /api/v2/workspace/{projectId}/memberships" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/project-users/update-membership.mdx b/docs/api-reference/endpoints/project-users/update-membership.mdx new file mode 100644 index 000000000..9a7aa600f --- /dev/null +++ b/docs/api-reference/endpoints/project-users/update-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update User Membership" +openapi: "PATCH /api/v1/workspace/{workspaceId}/memberships/{membershipId}" +--- diff --git a/docs/api-reference/endpoints/secret-imports/create.mdx b/docs/api-reference/endpoints/secret-imports/create.mdx index 2c823e528..3abfb320f 100644 --- a/docs/api-reference/endpoints/secret-imports/create.mdx +++ b/docs/api-reference/endpoints/secret-imports/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/secret-imports/" ---- \ No newline at end of file +openapi: "POST /api/v1/secret-imports" +--- diff --git a/docs/api-reference/endpoints/secret-imports/delete.mdx b/docs/api-reference/endpoints/secret-imports/delete.mdx index c7da4f6d0..cfa5960b1 100644 --- a/docs/api-reference/endpoints/secret-imports/delete.mdx +++ b/docs/api-reference/endpoints/secret-imports/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/secret-imports/{id}" ---- \ No newline at end of file +openapi: "DELETE /api/v1/secret-imports/{secretImportId}" +--- diff --git a/docs/api-reference/endpoints/secret-imports/list.mdx b/docs/api-reference/endpoints/secret-imports/list.mdx index 2de41b5d7..580d4be8d 100644 --- a/docs/api-reference/endpoints/secret-imports/list.mdx +++ b/docs/api-reference/endpoints/secret-imports/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/secret-imports/" ---- \ No newline at end of file +openapi: "GET /api/v1/secret-imports" +--- diff --git a/docs/api-reference/endpoints/secret-imports/update.mdx b/docs/api-reference/endpoints/secret-imports/update.mdx index 76c8a8feb..f21133223 100644 --- a/docs/api-reference/endpoints/secret-imports/update.mdx +++ b/docs/api-reference/endpoints/secret-imports/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PUT /api/v1/secret-imports/{id}" ---- \ No newline at end of file +openapi: "PATCH /api/v1/secret-imports/{secretImportId}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/create.mdx b/docs/api-reference/endpoints/secret-tags/create.mdx new file mode 100644 index 000000000..82d0eed17 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectId}/tags" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-tags/delete.mdx b/docs/api-reference/endpoints/secret-tags/delete.mdx new file mode 100644 index 000000000..cc98f03c2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectId}/tags/{tagId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-tags/list.mdx b/docs/api-reference/endpoints/secret-tags/list.mdx new file mode 100644 index 000000000..c4a940f77 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectId}/tags" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secrets/attach-tags.mdx b/docs/api-reference/endpoints/secrets/attach-tags.mdx new file mode 100644 index 000000000..8dd0e6081 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/attach-tags.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach tags" +openapi: "POST /api/v3/secrets/tags/{secretName}" +--- diff --git a/docs/api-reference/endpoints/secrets/create-many.mdx b/docs/api-reference/endpoints/secrets/create-many.mdx new file mode 100644 index 000000000..9b0609c0a --- /dev/null +++ b/docs/api-reference/endpoints/secrets/create-many.mdx @@ -0,0 +1,8 @@ +--- +title: "Bulk Create" +openapi: "POST /api/v3/secrets/batch/raw" +--- + + + This endpoint requires you to disable end-to-end encryption. For more information, you should consult this [note](https://infisical.com/docs/api-reference/overview/examples/note). + diff --git a/docs/api-reference/endpoints/secrets/delete-many.mdx b/docs/api-reference/endpoints/secrets/delete-many.mdx new file mode 100644 index 000000000..6477b2a98 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/delete-many.mdx @@ -0,0 +1,8 @@ +--- +title: "Bulk Delete" +openapi: "DELETE /api/v3/secrets/batch/raw" +--- + + + This endpoint requires you to disable end-to-end encryption. For more information, you should consult this [note](https://infisical.com/docs/api-reference/overview/examples/note). + diff --git a/docs/api-reference/endpoints/secrets/detach-tags.mdx b/docs/api-reference/endpoints/secrets/detach-tags.mdx new file mode 100644 index 000000000..a74b1174e --- /dev/null +++ b/docs/api-reference/endpoints/secrets/detach-tags.mdx @@ -0,0 +1,4 @@ +--- +title: "Detach tags" +openapi: "DELETE /api/v3/secrets/tags/{secretName}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secrets/update-many.mdx b/docs/api-reference/endpoints/secrets/update-many.mdx new file mode 100644 index 000000000..9feaf2ca2 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/update-many.mdx @@ -0,0 +1,8 @@ +--- +title: "Bulk Update" +openapi: "PATCH /api/v3/secrets/batch/raw" +--- + + + This endpoint requires you to disable end-to-end encryption. For more information, you should consult this [note](https://infisical.com/docs/api-reference/overview/examples/note). + diff --git a/docs/api-reference/endpoints/service-tokens/get.mdx b/docs/api-reference/endpoints/service-tokens/get.mdx index 5b2604282..921e62d90 100644 --- a/docs/api-reference/endpoints/service-tokens/get.mdx +++ b/docs/api-reference/endpoints/service-tokens/get.mdx @@ -1,10 +1,10 @@ --- title: "Get" -openapi: "GET /api/v2/service-token/" +openapi: "GET /api/v2/service-token" --- - This endpoint will be deprecated in the near future with the removal of service tokens in Q1/Q2 2024. + This endpoint is deprecated and will be removed in the future. - We recommend switching to using [identities](/documentation/platform/identities/overview) if your client supports it. + We recommend switching to using [Machine Identities](/documentation/platform/identities/machine-identities). diff --git a/docs/api-reference/endpoints/universal-auth/revoke-access-token.mdx b/docs/api-reference/endpoints/universal-auth/revoke-access-token.mdx new file mode 100644 index 000000000..082a76544 --- /dev/null +++ b/docs/api-reference/endpoints/universal-auth/revoke-access-token.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke Access Token" +openapi: "POST /api/v1/auth/token/revoke" +--- diff --git a/docs/api-reference/endpoints/workspaces/create-workspace.mdx b/docs/api-reference/endpoints/workspaces/create-workspace.mdx new file mode 100644 index 000000000..a8a7d0430 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/create-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Project" +openapi: "POST /api/v2/workspace" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/delete-identity-membership.mdx b/docs/api-reference/endpoints/workspaces/delete-identity-membership.mdx deleted file mode 100644 index 4621f9b50..000000000 --- a/docs/api-reference/endpoints/workspaces/delete-identity-membership.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete Identity Membership" -openapi: "DELETE /api/v2/workspace/{workspaceId}/identity-memberships/{identityId}" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/delete-membership.mdx b/docs/api-reference/endpoints/workspaces/delete-membership.mdx deleted file mode 100644 index e93b2415b..000000000 --- a/docs/api-reference/endpoints/workspaces/delete-membership.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete User Membership" -openapi: "DELETE /api/v2/workspace/{workspaceId}/memberships/{membershipId}" ---- diff --git a/docs/api-reference/endpoints/workspaces/delete-workspace.mdx b/docs/api-reference/endpoints/workspaces/delete-workspace.mdx new file mode 100644 index 000000000..6b5675c4b --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/delete-workspace.mdx @@ -0,0 +1,8 @@ +--- +title: "Delete Project" +openapi: "DELETE /api/v1/workspace/{workspaceId}" +--- + + + This operation is irreversible. All data associated with the project will be deleted. Please use with caution. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/get-workspace.mdx b/docs/api-reference/endpoints/workspaces/get-workspace.mdx new file mode 100644 index 000000000..edd0a0276 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/get-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project" +openapi: "GET /api/v1/workspace/{workspaceId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/list-identity-memberships.mdx b/docs/api-reference/endpoints/workspaces/list-identity-memberships.mdx deleted file mode 100644 index 45297efff..000000000 --- a/docs/api-reference/endpoints/workspaces/list-identity-memberships.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Identity Memberships" -openapi: "GET /api/v2/workspace/{workspaceId}/identity-memberships" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/memberships.mdx b/docs/api-reference/endpoints/workspaces/memberships.mdx deleted file mode 100644 index 386c8a089..000000000 --- a/docs/api-reference/endpoints/workspaces/memberships.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get User Memberships" -openapi: "GET /api/v2/workspace/{workspaceId}/memberships" ---- diff --git a/docs/api-reference/endpoints/workspaces/rollback-snapshot.mdx b/docs/api-reference/endpoints/workspaces/rollback-snapshot.mdx index 8b648a400..527c861b2 100644 --- a/docs/api-reference/endpoints/workspaces/rollback-snapshot.mdx +++ b/docs/api-reference/endpoints/workspaces/rollback-snapshot.mdx @@ -1,4 +1,4 @@ --- title: "Roll Back to Snapshot" -openapi: "POST /api/v1/workspace/{workspaceId}/secret-snapshots/rollback" +openapi: "POST /api/v1/secret-snapshot/{secretSnapshotId}/rollback" --- diff --git a/docs/api-reference/endpoints/workspaces/update-identity-membership.mdx b/docs/api-reference/endpoints/workspaces/update-identity-membership.mdx deleted file mode 100644 index 398c7bc81..000000000 --- a/docs/api-reference/endpoints/workspaces/update-identity-membership.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update Identity Membership" -openapi: "PATCH /api/v2/workspace/{workspaceId}/identity-memberships/{identityId}" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/update-membership.mdx b/docs/api-reference/endpoints/workspaces/update-membership.mdx deleted file mode 100644 index f0ef15412..000000000 --- a/docs/api-reference/endpoints/workspaces/update-membership.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update User Membership" -openapi: "PATCH /api/v2/workspace/{workspaceId}/memberships/{membershipId}" ---- diff --git a/docs/api-reference/endpoints/workspaces/update-workspace.mdx b/docs/api-reference/endpoints/workspaces/update-workspace.mdx new file mode 100644 index 000000000..699e3e3af --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/update-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Project" +openapi: "PATCH /api/v1/workspace/{workspaceId}" +--- \ No newline at end of file diff --git a/docs/api-reference/overview/authentication.mdx b/docs/api-reference/overview/authentication.mdx index dcf9719ea..f2224577e 100644 --- a/docs/api-reference/overview/authentication.mdx +++ b/docs/api-reference/overview/authentication.mdx @@ -1,9 +1,9 @@ --- title: "Authentication" -description: "How to authenticate with the Infisical Public API" +description: "Learn how to authenticate with the Infisical Public API." --- -You can authenticate with the Infisical API using [Identities](/documentation/platform/identities/overview) paired with authentication modes such as [Universal Auth](/documentation/platform/identities/universal-auth). +You can authenticate with the Infisical API using [Identities](/documentation/platform/identities/machine-identities) paired with authentication modes such as [Universal Auth](/documentation/platform/identities/universal-auth). To interact with the Infisical API, you will need to obtain an access token. Follow the step by [step guide](/documentation/platform/identities/universal-auth) to get an access token via Universal Auth. diff --git a/docs/api-reference/overview/examples/e2ee-disabled.mdx b/docs/api-reference/overview/examples/e2ee-disabled.mdx deleted file mode 100644 index 1a9e57552..000000000 --- a/docs/api-reference/overview/examples/e2ee-disabled.mdx +++ /dev/null @@ -1,180 +0,0 @@ ---- -title: "E2EE Disabled" ---- - -Using Infisical's API to read/write secrets with E2EE disabled allows you to create, update, and retrieve secrets -in plaintext. Effectively, this means each such secret operation only requires 1 HTTP call. - - - - Retrieve all secrets for an Infisical project and environment. - - - ```bash - curl --location --request GET 'https://app.infisical.com/api/v3/secrets/raw?environment=environment&workspaceId=workspaceId' \ - --header 'Authorization: Bearer serviceToken' - - ``` - - - #### - - When using a [service token](../../../documentation/platform/token) with access to a single environment and path, you don't need to provide request parameters because the server will automatically scope the request to the defined environment/secrets path of the service token used. - For all other cases, request parameters are required. - - #### - - The ID of the workspace - - - The environment slug - - - Path to secrets in workspace - - - - Create a secret in Infisical. - - - - ```bash - curl --location --request POST 'https://app.infisical.com/api/v3/secrets/raw/secretName' \ - --header 'Authorization: Bearer serviceToken' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "workspaceId": "workspaceId", - "environment": "environment", - "type": "shared", - "secretValue": "secretValue", - "secretPath": "/" - }' - ``` - - - - - Name of secret to create - - - The ID of the workspace - - - The environment slug - - - Value of secret - - - Comment of secret - - - Path to secret in workspace - - - The type of the secret. Valid options are β€œshared” or β€œpersonal” - - - - Retrieve a secret from Infisical. - - - - ```bash - curl --location --request GET 'https://app.infisical.com/api/v3/secrets/raw/secretName?workspaceId=workspaceId&environment=environment' \ - --header 'Authorization: Bearer serviceToken' - ``` - - - - - Name of secret to retrieve - - - The ID of the workspace - - - The environment slug - - - Path to secrets in workspace - - - The type of the secret. Valid options are β€œshared” or β€œpersonal” - - - - Update an existing secret in Infisical. - - - - ```bash - curl --location --request PATCH 'https://app.infisical.com/api/v3/secrets/raw/secretName' \ - --header 'Authorization: Bearer serviceToken' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "workspaceId": "workspaceId", - "environment": "environment", - "type": "shared", - "secretValue": "secretValue", - "secretPath": "/" - }' - ``` - - - - - Name of secret to update - - - The ID of the workspace - - - The environment slug - - - Value of secret - - - Path to secret in workspace. - - - The type of the secret. Valid options are β€œshared” or β€œpersonal” - - - - Delete a secret in Infisical. - - - - ```bash - curl --location --request DELETE 'https://app.infisical.com/api/v3/secrets/raw/secretName' \ - --header 'Authorization: Bearer serviceToken' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "workspaceId": "workspaceId", - "environment": "environment", - "type": "shared", - "secretPath": "/" - }' - ``` - - - - - Name of secret to update - - - The ID of the workspace - - - The environment slug - - - Path to secret in workspace. - - - The type of the secret. Valid options are β€œshared” or β€œpersonal” - - - \ No newline at end of file diff --git a/docs/api-reference/overview/examples/e2ee-enabled.mdx b/docs/api-reference/overview/examples/e2ee-enabled.mdx deleted file mode 100644 index 1de9c2290..000000000 --- a/docs/api-reference/overview/examples/e2ee-enabled.mdx +++ /dev/null @@ -1,862 +0,0 @@ ---- -title: "E2EE Enabled" ---- - - - E2EE enabled mode only works with [Service Tokens](/documentation/platform/token) and cannot be used with [Identities](/documentation/platform/identities/overview). - - -Using Infisical's API to read/write secrets with E2EE enabled allows you to create, update, and retrieve secrets -but requires you to perform client-side encryption/decryption operations. For this reason, we recommend using one of the available -SDKs instead. - - - - - - Retrieve all secrets for an Infisical project and environment. -```js -const crypto = require('crypto'); -const axios = require('axios'); - -const BASE_URL = 'https://app.infisical.com'; -const ALGORITHM = 'aes-256-gcm'; - -const decrypt = ({ ciphertext, iv, tag, secret}) => { - const decipher = crypto.createDecipheriv( - ALGORITHM, - secret, - Buffer.from(iv, 'base64') - ); - decipher.setAuthTag(Buffer.from(tag, 'base64')); - - let cleartext = decipher.update(ciphertext, 'base64', 'utf8'); - cleartext += decipher.final('utf8'); - - return cleartext; -} - -const getSecrets = async () => { - const serviceToken = 'your_service_token'; - const serviceTokenSecret = serviceToken.substring(serviceToken.lastIndexOf('.') + 1); - - // 1. Get your Infisical Token data - const { data: serviceTokenData } = await axios.get( - `${BASE_URL}/api/v2/service-token`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - // 2. Get secrets for your project and environment - const { data } = await axios.get( - `${BASE_URL}/api/v3/secrets?${new URLSearchParams({ - environment: serviceTokenData.environment, - workspaceId: serviceTokenData.workspace - })}`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - const encryptedSecrets = data.secrets; - - // 3. Decrypt the (encrypted) project key with the key from your Infisical Token - const projectKey = decrypt({ - ciphertext: serviceTokenData.encryptedKey, - iv: serviceTokenData.iv, - tag: serviceTokenData.tag, - secret: serviceTokenSecret - }); - - // 4. Decrypt the (encrypted) secrets - const secrets = encryptedSecrets.map((secret) => { - const secretKey = decrypt({ - ciphertext: secret.secretKeyCiphertext, - iv: secret.secretKeyIV, - tag: secret.secretKeyTag, - secret: projectKey - }); - - const secretValue = decrypt({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - secret: projectKey - }); - - return ({ - secretKey, - secretValue - }); - }); - - console.log('secrets: ', secrets); -} - -getSecrets(); - -``` - - - -```Python -import requests -import base64 -from Cryptodome.Cipher import AES - - -BASE_URL = "http://app.infisical.com" - - -def decrypt(ciphertext, iv, tag, secret): - secret = bytes(secret, "utf-8") - iv = base64.standard_b64decode(iv) - tag = base64.standard_b64decode(tag) - ciphertext = base64.standard_b64decode(ciphertext) - - cipher = AES.new(secret, AES.MODE_GCM, iv) - cipher.update(tag) - cleartext = cipher.decrypt(ciphertext).decode("utf-8") - return cleartext - - -def get_secrets(): - service_token = "your_service_token" - service_token_secret = service_token[service_token.rindex(".") + 1 :] - - # 1. Get your Infisical Token data - service_token_data = requests.get( - f"{BASE_URL}/api/v2/service-token", - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - # 2. Get secrets for your project and environment - data = requests.get( - f"{BASE_URL}/api/v3/secrets", - params={ - "environment": service_token_data["environment"], - "workspaceId": service_token_data["workspace"], - }, - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - encrypted_secrets = data["secrets"] - - # 3. Decrypt the (encrypted) project key with the key from your Infisical Token - project_key = decrypt( - ciphertext=service_token_data["encryptedKey"], - iv=service_token_data["iv"], - tag=service_token_data["tag"], - secret=service_token_secret, - ) - - # 4. Decrypt the (encrypted) secrets - secrets = [] - for secret in encrypted_secrets: - secret_key = decrypt( - ciphertext=secret["secretKeyCiphertext"], - iv=secret["secretKeyIV"], - tag=secret["secretKeyTag"], - secret=project_key, - ) - - secret_value = decrypt( - ciphertext=secret["secretValueCiphertext"], - iv=secret["secretValueIV"], - tag=secret["secretValueTag"], - secret=project_key, - ) - - secrets.append( - { - "secret_key": secret_key, - "secret_value": secret_value, - } - ) - - print("secrets:", secrets) - - -get_secrets() - -``` - - - - - - -Create a secret in Infisical. -```js -const crypto = require('crypto'); -const axios = require('axios'); -const nacl = require('tweetnacl'); - -const BASE_URL = 'https://app.infisical.com'; -const ALGORITHM = 'aes-256-gcm'; -const BLOCK_SIZE_BYTES = 16; - -const encrypt = ({ text, secret }) => { - const iv = crypto.randomBytes(BLOCK_SIZE_BYTES); - const cipher = crypto.createCipheriv(ALGORITHM, secret, iv); - - let ciphertext = cipher.update(text, 'utf8', 'base64'); - ciphertext += cipher.final('base64'); - return { - ciphertext, - iv: iv.toString('base64'), - tag: cipher.getAuthTag().toString('base64') - }; -} - -const decrypt = ({ ciphertext, iv, tag, secret}) => { - const decipher = crypto.createDecipheriv( - ALGORITHM, - secret, - Buffer.from(iv, 'base64') - ); - decipher.setAuthTag(Buffer.from(tag, 'base64')); - - let cleartext = decipher.update(ciphertext, 'base64', 'utf8'); - cleartext += decipher.final('utf8'); - - return cleartext; -} - -const createSecrets = async () => { - const serviceToken = ''; - const serviceTokenSecret = serviceToken.substring(serviceToken.lastIndexOf('.') + 1); - - const secretType = 'shared'; // 'shared' or 'personal' - const secretKey = 'some_key'; - const secretValue = 'some_value'; - const secretComment = 'some_comment'; - - // 1. Get your Infisical Token data - const { data: serviceTokenData } = await axios.get( - `${BASE_URL}/api/v2/service-token`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - // 2. Decrypt the (encrypted) project key with the key from your Infisical Token - const projectKey = decrypt({ - ciphertext: serviceTokenData.encryptedKey, - iv: serviceTokenData.iv, - tag: serviceTokenData.tag, - secret: serviceTokenSecret - }); - - // 3. Encrypt your secret with the project key - const { - ciphertext: secretKeyCiphertext, - iv: secretKeyIV, - tag: secretKeyTag - } = encrypt({ - text: secretKey, - secret: projectKey - }); - - const { - ciphertext: secretValueCiphertext, - iv: secretValueIV, - tag: secretValueTag - } = encrypt({ - text: secretValue, - secret: projectKey - }); - - const { - ciphertext: secretCommentCiphertext, - iv: secretCommentIV, - tag: secretCommentTag - } = encrypt({ - text: secretComment, - secret: projectKey - }); - - // 4. Send (encrypted) secret to Infisical - await axios.post( - `${BASE_URL}/api/v3/secrets/${secretKey}`, - { - workspaceId: serviceTokenData.workspace, - environment: serviceTokenData.environment, - type: secretType, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag - }, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); -} - -createSecrets(); -``` - - - -```Python -import base64 -import requests -from Cryptodome.Cipher import AES -from Cryptodome.Random import get_random_bytes - - -BASE_URL = "https://app.infisical.com" -BLOCK_SIZE_BYTES = 16 - - -def encrypt(text, secret): - iv = get_random_bytes(BLOCK_SIZE_BYTES) - secret = bytes(secret, "utf-8") - cipher = AES.new(secret, AES.MODE_GCM, iv) - ciphertext, tag = cipher.encrypt_and_digest(text.encode("utf-8")) - return { - "ciphertext": base64.standard_b64encode(ciphertext).decode("utf-8"), - "tag": base64.standard_b64encode(tag).decode("utf-8"), - "iv": base64.standard_b64encode(iv).decode("utf-8"), - } - - -def decrypt(ciphertext, iv, tag, secret): - secret = bytes(secret, "utf-8") - iv = base64.standard_b64decode(iv) - tag = base64.standard_b64decode(tag) - ciphertext = base64.standard_b64decode(ciphertext) - - cipher = AES.new(secret, AES.MODE_GCM, iv) - cipher.update(tag) - cleartext = cipher.decrypt(ciphertext).decode("utf-8") - return cleartext - - -def create_secrets(): - service_token = "your_service_token" - service_token_secret = service_token[service_token.rindex(".") + 1 :] - - secret_type = "shared" # "shared or "personal" - secret_key = "some_key" - secret_value = "some_value" - secret_comment = "some_comment" - - # 1. Get your Infisical Token data - service_token_data = requests.get( - f"{BASE_URL}/api/v2/service-token", - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - # 2. Decrypt the (encrypted) project key with the key from your Infisical Token - project_key = decrypt( - ciphertext=service_token_data["encryptedKey"], - iv=service_token_data["iv"], - tag=service_token_data["tag"], - secret=service_token_secret, - ) - - # 3. Encrypt your secret with the project key - encrypted_key_data = encrypt(text=secret_key, secret=project_key) - encrypted_value_data = encrypt(text=secret_value, secret=project_key) - encrypted_comment_data = encrypt(text=secret_comment, secret=project_key) - - # 4. Send (encrypted) secret to Infisical - requests.post( - f"{BASE_URL}/api/v3/secrets/{secret_key}", - json={ - "workspaceId": service_token_data["workspace"], - "environment": service_token_data["environment"], - "type": secret_type, - "secretKeyCiphertext": encrypted_key_data["ciphertext"], - "secretKeyIV": encrypted_key_data["iv"], - "secretKeyTag": encrypted_key_data["tag"], - "secretValueCiphertext": encrypted_value_data["ciphertext"], - "secretValueIV": encrypted_value_data["iv"], - "secretValueTag": encrypted_value_data["tag"], - "secretCommentCiphertext": encrypted_comment_data["ciphertext"], - "secretCommentIV": encrypted_comment_data["iv"], - "secretCommentTag": encrypted_comment_data["tag"] - }, - headers={"Authorization": f"Bearer {service_token}"}, - ) - - -create_secrets() - -``` - - - - - - - Retrieve a secret from Infisical. -```js -const crypto = require('crypto'); -const axios = require('axios'); - -const BASE_URL = 'https://app.infisical.com'; -const ALGORITHM = 'aes-256-gcm'; - -const decrypt = ({ ciphertext, iv, tag, secret}) => { - const decipher = crypto.createDecipheriv( - ALGORITHM, - secret, - Buffer.from(iv, 'base64') - ); - decipher.setAuthTag(Buffer.from(tag, 'base64')); - - let cleartext = decipher.update(ciphertext, 'base64', 'utf8'); - cleartext += decipher.final('utf8'); - - return cleartext; -} - -const getSecret = async () => { - const serviceToken = 'your_service_token'; - const serviceTokenSecret = serviceToken.substring(serviceToken.lastIndexOf('.') + 1); - - const secretType = 'shared' // 'shared' or 'personal' - const secretKey = 'some_key'; - - // 1. Get your Infisical Token data - const { data: serviceTokenData } = await axios.get( - `${BASE_URL}/api/v2/service-token`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - // 2. Get the secret from your project and environment - const { data } = await axios.get( - `${BASE_URL}/api/v3/secrets/${secretKey}?${new URLSearchParams({ - environment: serviceTokenData.environment, - workspaceId: serviceTokenData.workspace, - type: secretType // optional, defaults to 'shared' - })}`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - const encryptedSecret = data.secret; - - // 3. Decrypt the (encrypted) project key with the key from your Infisical Token - const projectKey = decrypt({ - ciphertext: serviceTokenData.encryptedKey, - iv: serviceTokenData.iv, - tag: serviceTokenData.tag, - secret: serviceTokenSecret - }); - - // 4. Decrypt the (encrypted) secret value - - const secretValue = decrypt({ - ciphertext: encryptedSecret.secretValueCiphertext, - iv: encryptedSecret.secretValueIV, - tag: encryptedSecret.secretValueTag, - secret: projectKey - }); - - console.log('secret: ', ({ - secretKey, - secretValue - })); -} - -getSecret(); - -``` - - - -```Python -import requests -import base64 -from Cryptodome.Cipher import AES - - -BASE_URL = "http://app.infisical.com" - - -def decrypt(ciphertext, iv, tag, secret): - secret = bytes(secret, "utf-8") - iv = base64.standard_b64decode(iv) - tag = base64.standard_b64decode(tag) - ciphertext = base64.standard_b64decode(ciphertext) - - cipher = AES.new(secret, AES.MODE_GCM, iv) - cipher.update(tag) - cleartext = cipher.decrypt(ciphertext).decode("utf-8") - return cleartext - - -def get_secret(): - service_token = "your_service_token" - service_token_secret = service_token[service_token.rindex(".") + 1 :] - - secret_type = "shared" # "shared" or "personal" - secret_key = "some_key" - - # 1. Get your Infisical Token data - service_token_data = requests.get( - f"{BASE_URL}/api/v2/service-token", - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - # 2. Get secret from your project and environment - data = requests.get( - f"{BASE_URL}/api/v3/secrets/{secret_key}", - params={ - "environment": service_token_data["environment"], - "workspaceId": service_token_data["workspace"], - "type": secret_type # optional, defaults to "shared" - }, - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - encrypted_secret = data["secret"] - - # 3. Decrypt the (encrypted) project key with the key from your Infisical Token - project_key = decrypt( - ciphertext=service_token_data["encryptedKey"], - iv=service_token_data["iv"], - tag=service_token_data["tag"], - secret=service_token_secret, - ) - - # 4. Decrypt the (encrypted) secret value - secret_value = decrypt( - ciphertext=encrypted_secret["secretValueCiphertext"], - iv=encrypted_secret["secretValueIV"], - tag=encrypted_secret["secretValueTag"], - secret=project_key, - ) - - print("secret: ", { - "secret_key": secret_key, - "secret_value": secret_value - }) - - -get_secret() - -``` - - - - - - -Update an existing secret in Infisical. -```js -const crypto = require('crypto'); -const axios = require('axios'); - -const BASE_URL = 'https://app.infisical.com'; -const ALGORITHM = 'aes-256-gcm'; -const BLOCK_SIZE_BYTES = 16; - -const encrypt = ({ text, secret }) => { - const iv = crypto.randomBytes(BLOCK_SIZE_BYTES); - const cipher = crypto.createCipheriv(ALGORITHM, secret, iv); - - let ciphertext = cipher.update(text, 'utf8', 'base64'); - ciphertext += cipher.final('base64'); - return { - ciphertext, - iv: iv.toString('base64'), - tag: cipher.getAuthTag().toString('base64') - }; -} - -const decrypt = ({ ciphertext, iv, tag, secret}) => { - const decipher = crypto.createDecipheriv( - ALGORITHM, - secret, - Buffer.from(iv, 'base64') - ); - decipher.setAuthTag(Buffer.from(tag, 'base64')); - - let cleartext = decipher.update(ciphertext, 'base64', 'utf8'); - cleartext += decipher.final('utf8'); - - return cleartext; -} - -const updateSecrets = async () => { - const serviceToken = 'your_service_token'; - const serviceTokenSecret = serviceToken.substring(serviceToken.lastIndexOf('.') + 1); - - const secretType = 'shared' // 'shared' or 'personal' - const secretKey = 'some_key'; - const secretValue = 'updated_value'; - const secretComment = 'updated_comment'; - - // 1. Get your Infisical Token data - const { data: serviceTokenData } = await axios.get( - `${BASE_URL}/api/v2/service-token`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - // 2. Decrypt the (encrypted) project key with the key from your Infisical Token - const projectKey = decrypt({ - ciphertext: serviceTokenData.encryptedKey, - iv: serviceTokenData.iv, - tag: serviceTokenData.tag, - secret: serviceTokenSecret - }); - - // 3. Encrypt your updated secret with the project key - const { - ciphertext: secretKeyCiphertext, - iv: secretKeyIV, - tag: secretKeyTag - } = encrypt({ - text: secretKey, - secret: projectKey - }); - - const { - ciphertext: secretValueCiphertext, - iv: secretValueIV, - tag: secretValueTag - } = encrypt({ - text: secretValue, - secret: projectKey - }); - - const { - ciphertext: secretCommentCiphertext, - iv: secretCommentIV, - tag: secretCommentTag - } = encrypt({ - text: secretComment, - secret: projectKey - }); - - // 4. Send (encrypted) updated secret to Infisical - await axios.patch( - `${BASE_URL}/api/v3/secrets/${secretKey}`, - { - workspaceId: serviceTokenData.workspace, - environment: serviceTokenData.environment, - type: secretType, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag - }, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); -} - -updateSecrets(); -``` - - - -```Python -import base64 -import requests -from Cryptodome.Cipher import AES -from Cryptodome.Random import get_random_bytes - - -BASE_URL = "https://app.infisical.com" -BLOCK_SIZE_BYTES = 16 - - -def encrypt(text, secret): - iv = get_random_bytes(BLOCK_SIZE_BYTES) - secret = bytes(secret, "utf-8") - cipher = AES.new(secret, AES.MODE_GCM, iv) - ciphertext, tag = cipher.encrypt_and_digest(text.encode("utf-8")) - return { - "ciphertext": base64.standard_b64encode(ciphertext).decode("utf-8"), - "tag": base64.standard_b64encode(tag).decode("utf-8"), - "iv": base64.standard_b64encode(iv).decode("utf-8"), - } - - -def decrypt(ciphertext, iv, tag, secret): - secret = bytes(secret, "utf-8") - iv = base64.standard_b64decode(iv) - tag = base64.standard_b64decode(tag) - ciphertext = base64.standard_b64decode(ciphertext) - - cipher = AES.new(secret, AES.MODE_GCM, iv) - cipher.update(tag) - cleartext = cipher.decrypt(ciphertext).decode("utf-8") - return cleartext - - -def update_secret(): - service_token = "your_service_token" - service_token_secret = service_token[service_token.rindex(".") + 1 :] - - secret_type = "shared" # "shared" or "personal" - secret_key = "some_key" - secret_value = "updated_value" - secret_comment = "updated_comment" - - # 1. Get your Infisical Token data - service_token_data = requests.get( - f"{BASE_URL}/api/v2/service-token", - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - # 2. Decrypt the (encrypted) project key with the key from your Infisical Token - project_key = decrypt( - ciphertext=service_token_data["encryptedKey"], - iv=service_token_data["iv"], - tag=service_token_data["tag"], - secret=service_token_secret, - ) - - # 3. Encrypt your updated secret with the project key - encrypted_key_data = encrypt(text=secret_key, secret=project_key) - encrypted_value_data = encrypt(text=secret_value, secret=project_key) - encrypted_comment_data = encrypt(text=secret_comment, secret=project_key) - - # 4. Send (encrypted) updated secret to Infisical - requests.patch( - f"{BASE_URL}/api/v3/secrets/{secret_key}", - json={ - "workspaceId": service_token_data["workspace"], - "environment": service_token_data["environment"], - "type": secret_type, - "secretKeyCiphertext": encrypted_key_data["ciphertext"], - "secretKeyIV": encrypted_key_data["iv"], - "secretKeyTag": encrypted_key_data["tag"], - "secretValueCiphertext": encrypted_value_data["ciphertext"], - "secretValueIV": encrypted_value_data["iv"], - "secretValueTag": encrypted_value_data["tag"], - "secretCommentCiphertext": encrypted_comment_data["ciphertext"], - "secretCommentIV": encrypted_comment_data["iv"], - "secretCommentTag": encrypted_comment_data["tag"] - }, - headers={"Authorization": f"Bearer {service_token}"}, - ) - - -update_secret() - -``` - - - - - - - Delete a secret in Infisical. -```js -const axios = require('axios'); -const BASE_URL = 'https://app.infisical.com'; - -const deleteSecrets = async () => { - const serviceToken = 'your_service_token'; - const secretType = 'shared' // 'shared' or 'personal' - const secretKey = 'some_key' - - // 1. Get your Infisical Token data - const { data: serviceTokenData } = await axios.get( - `${BASE_URL}/api/v2/service-token`, - { - headers: { - Authorization: `Bearer ${serviceToken}` - } - } - ); - - // 2. Delete secret from Infisical - await axios.delete( - `${BASE_URL}/api/v3/secrets/${secretKey}`, - { - workspaceId: serviceTokenData.workspace, - environment: serviceTokenData.environment, - type: secretType - }, - { - headers: { - Authorization: `Bearer ${serviceToken}` - }, - } - ); -}; - -deleteSecrets(); -``` - - - -```Python -import requests - -BASE_URL = "https://app.infisical.com" - - -def delete_secrets(): - service_token = "" - secret_type = "shared" # "shared" or "personal" - secret_key = "some_key" - - # 1. Get your Infisical Token data - service_token_data = requests.get( - f"{BASE_URL}/api/v2/service-token", - headers={"Authorization": f"Bearer {service_token}"}, - ).json() - - # 2. Delete secret from Infisical - requests.delete( - f"{BASE_URL}/api/v2/secrets/{secret_key}", - json={ - "workspaceId": service_token_data["workspace"], - "environment": service_token_data["environment"], - "type": secret_type - }, - headers={"Authorization": f"Bearer {service_token}"}, - ) - - -delete_secrets() - -``` - - - - If using an `API_KEY` to authenticate with the Infisical API, then you should include it in the `X_API_KEY` header. - - - - \ No newline at end of file diff --git a/docs/api-reference/overview/examples/integration.mdx b/docs/api-reference/overview/examples/integration.mdx new file mode 100644 index 000000000..71f5b6de4 --- /dev/null +++ b/docs/api-reference/overview/examples/integration.mdx @@ -0,0 +1,90 @@ +--- +title: "Configure native integrations via API" +description: "How to use Infisical API to sync secrets to external secret managers" +--- + +The Infisical API allows you to create programmatic integrations that connect with third-party secret managers to synchronize secrets from Infisical. + +This guide will primarily demonstrate the process using AWS Secret Store Manager (AWS SSM), but the steps are generally applicable to other secret management integrations. + + + For details on setting up AWS SSM synchronization and understanding its prerequisites, refer to the [AWS SSM integration setup documentation](../../../integrations/cloud/aws-secret-manager). + + + + + Authentication is required for all integrations. Use the [Integration Auth API](../../endpoints/integrations/create-auth) with the following parameters to authenticate. + + + Set this parameter to **aws-secret-manager**. + + + The Infisical project ID for the integration. + + + The AWS IAM User Access ID. + + + The AWS IAM User Access Secret Key. + + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/integration-auth/access-token \ + --header 'Authorization: ' \ + --header 'Content-Type: application/json' \ + --data '{ + "workspaceId": "", + "integration": "aws-secret-manager", + "accessId": "", + "accessToken": "" + }' + ``` + + + + Once authentication between AWS SSM and Infisical is established, you can configure the synchronization behavior. + This involves specifying the source (environment and secret path in Infisical) and the destination in SSM to which the secrets will be synchronized. + + Use the [integration API](../../endpoints/integrations/create) with the following parameters to configure the sync source and destination. + + + The ID of the integration authentication object used with AWS, obtained from the previous API response. + + + Indicates whether the integration should be active or inactive. + + + The secret name for saving in AWS SSM, which can be arbitrarily chosen. + + + The AWS region where the SSM is located, e.g., `us-east-1`. + + + The Infisical environment slug from which secrets will be synchronized, e.g., `dev`. + + + The Infisical folder path from which secrets will be synchronized, e.g., `/some/path`. The root path is `/`. + + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/integration \ + --header 'Authorization: ' \ + --header 'Content-Type: application/json' \ + --data '{ + "integrationAuthId": "", + "sourceEnvironment": "", + "secretPath": "", + "app": "", + "region": "" + }' + ``` + + + + + +Congratulations! You have successfully set up an integration to synchronize secrets from Infisical with AWS SSM. +For more information, [view the integration API reference](../../endpoints/integrations). + \ No newline at end of file diff --git a/docs/api-reference/overview/examples/note.mdx b/docs/api-reference/overview/examples/note.mdx deleted file mode 100644 index 8491dfaae..000000000 --- a/docs/api-reference/overview/examples/note.mdx +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: "Note on E2EE" ---- - -Each project in Infisical can have **End-to-End Encryption (E2EE)** enabled or disabled. - -By default, all projects have **E2EE** enabled which means the server is not able to decrypt any values because all secret encryption/decryption operations occur on the client-side; this can be (optionally) disabled. However, this has limitations around functionality and ease-of-use: - -- You cannot make HTTP calls to Infisical to read/write secrets in plaintext. -- You cannot leverage non-E2EE features like native integrations and in-platform automations like dynamic secrets and secret rotation. - - - - Example read/write secrets without client-side encryption/decryption - - - Example read/write secrets with client-side encryption/decryption - - - -## FAQ - - - - We recommend starting with having **E2EE** enabled and disabling it if: - - - You're self-hosting Infisical, so having your instance of Infisical be able to read your secrets isn't an issue. - - You want an easier way to read/write secrets with Infisical. - - You need more power out of non-E2EE features such as secret rotation, dynamic secrets, etc. - - - - You can enable/disable E2EE for your project in Infisical in the Project Settings. - - - It is secure and in fact how most vendors in our industry are able to offer features like secret rotation. In this mode, secrets are encrypted at rest by - a series of keys, secured ultimately by a top-level `ROOT_ENCRYPTION_KEY` located on the server. - - If you're concerned about Infisical Cloud's ability to read your secrets, then you may wish to - use it with **E2EE** enabled or self-host Infisical on your own infrastructure and disable E2EE there. - - As an organization, we do not read any customer secrets without explicit permission; access to the `ROOT_ENCRYPTION_KEY` is restricted to one individual in the organization. - - \ No newline at end of file diff --git a/docs/api-reference/overview/introduction.mdx b/docs/api-reference/overview/introduction.mdx index 06ee491b5..6d577e15b 100644 --- a/docs/api-reference/overview/introduction.mdx +++ b/docs/api-reference/overview/introduction.mdx @@ -1,5 +1,6 @@ --- -title: "Introduction" +title: "API Reference" +sidebarTitle: "Introduction" --- Infisical's Public (REST) API provides users an alternative way to programmatically access and manage diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index ca62fe94b..d73c0bb14 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,7 +4,22 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. -## January 2024 +## Feb 2024 +- Added org-scoped authentication enforcement for SAML +- Added support for [SCIM](https://infisical.com/docs/documentation/platform/scim/overview) along with instructions for setting it up with [Okta](https://infisical.com/docs/documentation/platform/scim/okta), [Azure](https://infisical.com/docs/documentation/platform/scim/azure), and [JumpCloud](https://infisical.com/docs/documentation/platform/scim/jumpcloud). +- Pushed out project update for non-E2EE w/ new endpoints like for project creation and member invitation. +- Added API Integration testing for new backend. +- Added capability to create projects in Terraform. +- Added slug-based capabilities to both organizations and projects to gradually make the API more developer-friendly moving forward. +- Fixed + improved various analytics/telemetry-related items. +- Fixed various issues associated with the Python SDK: build during installation on Mac OS, Rust dependency. +- Updated self-hosting documentation to reflect [new backend](https://infisical.com/docs/self-hosting/overview). +- Released [Postgres-based Infisical helm chart](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/infisical-standalone/). +- Added checks to ensure that breaking API changes don't get released. +- Automated API reference documentation to be inline with latest releases of Infisical. + +## Jan 2024 +- Completed Postgres migration initiative with restructed Fastify-based backend. - Reduced size of Infisical Node.js SDK by β‰ˆ90%. - Added secret fallback support to all SDK's. - Added Machine Identity support to [Terraform Provider](https://github.com/Infisical/terraform-provider-infisical). @@ -12,21 +27,21 @@ The changelog below reflects new product developments and updates on a monthly b - Added symmetric encryption support to all SDK's. - Fixed secret reminders bug, where reminders were not being updated correctly. -## December 2023 +## Dec 2023 - Released [(machine) identities](https://infisical.com/docs/documentation/platform/identities/overview) and [universal auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) features. - Created new cross-language SDKs for [Python](https://infisical.com/docs/sdks/languages/python), [Node](https://infisical.com/docs/sdks/languages/node), and [Java](https://infisical.com/docs/sdks/languages/java). - Released first version of the [Infisical Agent](https://infisical.com/docs/infisical-agent/overview) - Added ability to [manage folders via CLI](https://infisical.com/docs/cli/commands/secrets). -## November 2023 +## Nov 2023 - Replaced internal [Winston](https://github.com/winstonjs/winston) with [Pino](https://github.com/pinojs/pino) logging library with external logging to AWS CloudWatch - Added admin panel to self-hosting experience. - Released [secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview) feature with preliminary support for rotating [SendGrid](https://infisical.com/docs/documentation/platform/secret-rotation/sendgrid), [PostgreSQL/CockroachDB](https://infisical.com/docs/documentation/platform/secret-rotation/postgres), and [MySQL/MariaDB](https://infisical.com/docs/documentation/platform/secret-rotation/mysql) credentials. - Released secret reminders feature. -## October 2023 +## Oct 2023 - Added support for [GitLab SSO](https://infisical.com/docs/documentation/platform/sso/gitlab). - Became SOC 2 (Type II) certified. @@ -35,7 +50,7 @@ The changelog below reflects new product developments and updates on a monthly b - Added native [Hasura Cloud integration](https://infisical.com/docs/integrations/cloud/hasura-cloud). - Updated resource deletion logic for user, organization, and project deletion. -## September 2023 +## Sep 2023 - Released [secret approvals](https://infisical.com/docs/documentation/platform/pr-workflows) feature. - Released an update to access controls; every user role now clearly defines and enforces a certain set of conditions across Infisical. @@ -43,7 +58,7 @@ The changelog below reflects new product developments and updates on a monthly b - Added a native integration with [Qovery](https://infisical.com/docs/integrations/cloud/qovery). - Added service token generation capability for the CLI. -## August 2023 +## Aug 2023 - Release Audit Logs V2. - Add support for [GitHub SSO](https://infisical.com/docs/documentation/platform/sso/github). @@ -171,7 +186,7 @@ The changelog below reflects new product developments and updates on a monthly b - Added sorting capability to sort keys by name alphabetically in dashboard. - Added downloading secrets back as `.env` file capability. -## August 2022 +## Aug 2022 - Released first version of the Infisical platform with push/pull capability and end-to-end encryption. - Improved security handling of authentication tokens by storing refresh tokens in HttpOnly cookies. diff --git a/docs/cli/commands/export.mdx b/docs/cli/commands/export.mdx index 49446e734..16c226084 100644 --- a/docs/cli/commands/export.mdx +++ b/docs/cli/commands/export.mdx @@ -16,33 +16,48 @@ Export environment variables from the platform into a file format. Use this command to export environment variables from the platform into a raw file formats - ```bash - $ infisical export +```bash +$ infisical export - # Export variables to a .env file - infisical export > .env +# Export variables to a .env file +infisical export > .env - # Export variables to a .env file (with export keyword) - infisical export --format=dotenv-export > .env +# Export variables to a .env file (with export keyword) +infisical export --format=dotenv-export > .env - # Export variables to a CSV file - infisical export --format=csv > secrets.csv +# Export variables to a CSV file +infisical export --format=csv > secrets.csv - # Export variables to a JSON file - infisical export --format=json > secrets.json +# Export variables to a JSON file +infisical export --format=json > secrets.json - # Export variables to a YAML file - infisical export --format=yaml > secrets.yaml - ``` +# Export variables to a YAML file +infisical export --format=yaml > secrets.yaml + +# Render secrets using a custom template file +infisical export --template= +``` + +### Environment variables - ### Environment variables - Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. + Used to fetch secrets via a [machine identities](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec + # Example + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` + + + Alternatively, you may use service tokens. + + Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash + # Example + export INFISICAL_TOKEN= + ``` + + @@ -51,23 +66,47 @@ Export environment variables from the platform into a file format. To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### flags - - Used to set the environment that secrets are pulled from. +### flags + + + The `--template` flag specifies the path to the template file used for rendering secrets. When using templates, you can omit the other format flags. + + ```text my-template-file + {{$secrets := secret "" "" ""}} + {{$length := len $secrets}} + {{- "{"}} + {{- with $secrets }} + {{- range $index, $secret := . }} + "{{ $secret.Key }}": "{{ $secret.Value }}"{{if lt $index (minus $length 1)}},{{end}} + {{- end }} + {{- end }} + {{ "}" -}} + ``` ```bash - # Example - infisical export --env=prod + # Example + infisical export --template="/path/to/template/file" + ``` + + + + Used to set the environment that secrets are pulled from. + + ```bash + # Example + infisical export --env=prod ``` Note: this flag only accepts environment slug names not the fully qualified name. To view the slug name of an environment, visit the project settings page. default value: `dev` + @@ -75,28 +114,38 @@ Export environment variables from the platform into a file format. This flag allows you to override this behavior by explicitly defining the project to fetch your secrets from. ```bash - # Example - + # Example + infisical export --projectId=XXXXXXXXXXXXXX ``` + Parse shell parameter expansions in your secrets (e.g., `${DOMAIN}`) + Default value: `true` + + + + + By default imported secrets are available, you can disable it by setting this option to false. + Default value: `true` - Format of the output file. Accepted values: `dotenv`, `dotenv-export`, `csv`, `json` and `yaml` + Format of the output file. Accepted values: `dotenv`, `dotenv-export`, `csv`, `json` and `yaml` Default value: `dotenv` + Prioritizes personal secrets with the same name over shared secrets Default value: `true` + @@ -106,19 +155,21 @@ Export environment variables from the platform into a file format. # Example infisical export --path="/path/to/folder" --env=dev ``` + When working with tags, you can use this flag to filter and retrieve only secrets that are associated with a specific tag(s). ```bash - # Example + # Example infisical run --tags=tag1,tag2,tag3 -- npm run dev ``` Note: you must reference the tag by its slug name not its fully qualified name. Go to project settings to view all tag slugs. By default, all secrets are fetched + diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index 3028ec110..2758ced00 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -12,4 +12,53 @@ The CLI uses authentication to verify your identity. When you enter the correct To change where the login credentials are stored, visit the [vaults command](./vault). -If you have added multiple users, you can switch between the users by using the [user command](./user). \ No newline at end of file +If you have added multiple users, you can switch between the users by using the [user command](./user). + + +### Flags + + ```bash + infisical login --method= # Optional, will default to 'user'. + ``` + + #### Valid values for the `method` flag are: + - `user`: Login using email and password. + - `universal-auth`: Login using a universal auth client ID and client secret. + + + When `method` is set to `universal-auth`, the `client-id` and `client-secret` flags are required. Optionally you can set the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` and `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variables instead of using the flags. + + When you authenticate with universal auth, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable. + + Use flag `--plain` along with `--silent` to print only the token in plain text when using the `universal-auth` method. + + + + + + ```bash + infisical login --client-id= # Optional, required if --method=universal-auth. + ``` + + #### Description + The client ID of the universal auth client. This is required if the `--method` flag is set to `universal-auth`. + + + The `client-id` flag can be substituted with the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` environment variable. + + + + ```bash + infisical login --client-secret= # Optional, required if --method=universal-auth. + ``` + #### Description + The client secret of the universal auth client. This is required if the `--method` flag is set to `universal-auth`. + + + The `client-secret` flag can be substituted with the `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variable. + + + + + + \ No newline at end of file diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index 8078342a5..74aa84947 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -11,6 +11,7 @@ description: "The command that injects your secrets into local environment" # Example infisical run [options] -- npm run dev ``` + @@ -20,6 +21,7 @@ description: "The command that injects your secrets into local environment" # Example infisical run [options] --command "npm run bootstrap && npm run dev start; other-bash-command" ``` + @@ -27,27 +29,38 @@ description: "The command that injects your secrets into local environment" Inject secrets from Infisical into your application process. - ## Subcommands & flags Use this command to inject secrets into your applications process - ```bash - $ infisical run -- +```bash +$ infisical run -- - # Example - $ infisical run -- npm run dev - ``` +# Example +$ infisical run -- npm run dev +``` + +### Environment variables - ### Environment variables - Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. + Used to fetch secrets via a [machine identity](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec + # Example + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` + + + Alternatively, you may use service tokens. + + Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash + # Example + export INFISICAL_TOKEN= + ``` + + @@ -56,71 +69,96 @@ Inject secrets from Infisical into your application process. To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### Flags - +### Flags + - Explicitly set the directory where the .infisical.json resides. This is useful for some monorepo setups. + Explicitly set the directory where the .infisical.json resides. This is useful for some monorepo setups. ```bash - # Example + # Example infisical run --project-config-dir=/some-dir -- printenv ``` + Pass secrets into multiple commands at once ```bash - # Example + # Example infisical run --command="npm run build && npm run dev; more-commands..." ``` + + + + + The project ID to fetch secrets from. This is required when using a machine identity to authenticate. + + ```bash + # Example + infisical run --projectId= -- npm run dev + ``` + - If you are using a [service token](/documentation/platform/token) to authenticate, you can pass the token as a flag + If you are using a [machine identity](/documentation/platform/identities/machine-identities) to authenticate, you can pass the token as a flag ```bash - # Example - infisical run --token="st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec" -- npm run start + # Example + infisical run --token="" --projectId= -- npm run start ``` - You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the run command. This will have the same effect as setting the token with `--token` flag + You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the run command. This will have the same effect as setting the token with `--token` flag + Turn on or off the shell parameter expansion in your secrets. If you have used shell parameters in your secret(s), activating this feature will populate them before injecting them into your application process. Default value: `true` + - - This is used to specify the environment from which secrets should be retrieved. The accepted values are the environment slugs defined for your project, such as `dev`, `staging`, `test`, and `prod`. - - Default value: `dev` + + By default imported secrets are available, you can disable it by setting this option to false. + + Default value: `true` +{" "} + + + This is used to specify the environment from which secrets should be + retrieved. The accepted values are the environment slugs defined for your + project, such as `dev`, `staging`, `test`, and `prod`. Default value: `dev` + + Prioritizes personal secrets with the same name over shared secrets Default value: `true` + When working with tags, you can use this flag to filter and retrieve only secrets that are associated with a specific tag(s). ```bash - # Example + # Example infisical run --tags=tag1,tag2,tag3 -- npm run dev ``` Note: you must reference the tag by its slug name not its fully qualified name. Go to project settings to view all tag slugs. By default, all secrets are fetched + diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 08e858e9d..81fa4de4c 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -8,24 +8,38 @@ infisical secrets ``` ## Description + This command enables you to perform CRUD (create, read, update, delete) operations on secrets within your Infisical project. With it, you can view, create, update, and delete secrets in your environment. -### Sub-commands +### Sub-commands + Use this command to print out all of the secrets in your project - ```bash - $ infisical secrets - ``` +```bash +$ infisical secrets +``` + +### Environment variables - ### Environment variables - Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. + Used to fetch secrets via a [machine identity](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec + # Example + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` + + + Alternatively, you may use service tokens. + + Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash + # Example + export INFISICAL_TOKEN= + ``` + + @@ -34,22 +48,36 @@ This command enables you to perform CRUD (create, read, update, delete) operatio To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### Flags +### Flags + Parse shell parameter expansions in your secrets Default value: `true` + + + + + The project ID to fetch secrets from. This is required when using a machine identity to authenticate. + + ```bash + # Example + infisical secrets --projectId= + ``` + Used to select the environment name on which actions should be taken on Default value: `dev` + The `--path` flag indicates which project folder secrets will be injected from. @@ -58,6 +86,7 @@ This command enables you to perform CRUD (create, read, update, delete) operatio # Example infisical secrets --path="/" --env=dev ``` + The `--plain` flag will output all your secret values without formatting, one per line. @@ -66,6 +95,7 @@ This command enables you to perform CRUD (create, read, update, delete) operatio # Example infisical secrets --plain ``` + @@ -73,21 +103,24 @@ This command enables you to perform CRUD (create, read, update, delete) operatio This command allows you selectively print the requested secrets by name - ```bash - $ infisical secrets get ... +```bash +$ infisical secrets get ... # Example $ infisical secrets get DOMAIN $ infisical secrets get DOMAIN PORT - ``` +``` + +### Flags - ### Flags Used to select the environment name on which actions should be taken on Default value: `dev` + + The `--plain` flag will output all your requested secret values without formatting, one per line. @@ -98,25 +131,46 @@ $ infisical secrets get DOMAIN PORT # Fetch a single value and assign it to a variable API_KEY=$(infisical secrets get FOO --plain) ``` + + + When running in CI/CD environments or in a script, set `INFISICAL_DISABLE_UPDATE_CHECK` env to `true`. This will help hide any CLI update messages and only show the secret value. + + + + + Used to print the plain value of a single requested secret without any table style. + + Default value: `false` + + Example: `infisical secrets get DOMAIN --raw-value` + + + When running in CI/CD environments or in a script, set `INFISICAL_DISABLE_UPDATE_CHECK` env to `true`. This will help hide any CLI update messages and only show the secret value. + + + + -This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. +This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. If the secret key does not exist, a new secret will be created using both the key and value provided. ```bash $ infisical secrets set ... -## Example +## Example $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe ``` - ### Flags +### Flags + Used to select the environment name on which actions should be taken on Default value: `dev` + Used to select the project folder in which the secrets will be set. This is useful when creating new secrets under a particular path. @@ -125,43 +179,48 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb # Example infisical secrets set DOMAIN=example.com --path="common/backend" ``` + This command allows you to delete secrets by their name(s). - ```bash - $ infisical secrets delete ... +```bash +$ infisical secrets delete ... - ## Example - $ infisical secrets delete STRIPE_API_KEY DOMAIN HASH - ``` +## Example +$ infisical secrets delete STRIPE_API_KEY DOMAIN HASH +``` + +### Flags - ### Flags Used to select the environment name on which actions should be taken on Default value: `dev` + - The `--path` flag indicates which project folder secrets will be injected from. + The `--path` flag indicates which project folder secrets will be injected from. ```bash # Example infisical secrets delete ... --path="/" ``` + This command allows you to fetch, create and delete folders from within a path from a given project. - ```bash - $ infisical secrets folders - ``` +```bash +$ infisical secrets folders +``` + +### sub commands - ### sub commands Used to fetch all folders within a path in a given project ``` @@ -175,7 +234,7 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb - Fetch folders using the Infisical service token + Fetch folders using a [machine identity](/documentation/platform/identities/machine-identities) access token. Default value: `` @@ -199,6 +258,7 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb Default value: `` + @@ -214,10 +274,11 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb - Name of the folder to be deleted within selected `--path` + Name of the folder to be deleted within selected `--path` Default value: `` + @@ -230,14 +291,16 @@ To place default values in your example .env file, you can simply include the sy ```bash $ infisical secrets generate-example-env -## Example +## Example $ infisical secrets generate-example-env > .example-env ``` - ### Flags +### Flags + Used to select the environment name on which actions should be taken on Default value: `dev` + diff --git a/docs/cli/commands/service-token.mdx b/docs/cli/commands/service-token.mdx index 007f845dc..4971b3db0 100644 --- a/docs/cli/commands/service-token.mdx +++ b/docs/cli/commands/service-token.mdx @@ -3,37 +3,47 @@ title: "infisical service-token" description: "Manage Infisical service tokens" --- -```bash + + This command is deprecated and will be removed in the near future. Please + switch to using [Machine + Identities](/documentation/platform/identities/machine-identities) for + authenticating with Infisical. + + +```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. + +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 - ``` +```bash +$ infisical service-token create --scope=dev:/backend/** --access-level=read --access-level=write +``` + +### Flags - ### 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 `:`. + + 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 + @@ -41,8 +51,9 @@ With this command, you can create, view, and delete service tokens. infisical service-token create --scope=dev:/global --access-level=read --projectId=63cefb15c8d3175601cfa989 ``` - The project ID you'd like to create the service token for. + 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 @@ -52,6 +63,7 @@ With this command, you can create, view, and delete service tokens. Service token name Default: `Service token generated via CLI` + ```bash @@ -61,6 +73,7 @@ With this command, you can create, view, and delete service tokens. Set the service token's expiration time in seconds from now. To never expire set to zero. Default: `1 day` + ```bash @@ -68,6 +81,7 @@ With this command, you can create, view, and delete service tokens. ``` The type of access the service token should have. Can be `read` and or `write` + ```bash @@ -77,5 +91,6 @@ With this command, you can create, view, and delete service tokens. When true, only the service token will be printed Default: `false` + diff --git a/docs/cli/commands/token.mdx b/docs/cli/commands/token.mdx new file mode 100644 index 000000000..5b0d4ad5c --- /dev/null +++ b/docs/cli/commands/token.mdx @@ -0,0 +1,21 @@ +--- +title: "infisical token" +description: "Manage your Infisical identity access tokens" +--- + +```bash +infisical service-token renew +``` + +## Description +The Infisical `token` command allows you to manage your universal auth access tokens. +With this command, you can renew your access tokens. In the future more subcommands will be added to better help you manage your tokens through the CLI. + + + Use this command to renew your access token. This command will renew your access token and output a renewed access token to the console. + + ```bash + $ infisical token renew + ``` + + diff --git a/docs/cli/faq.mdx b/docs/cli/faq.mdx index cf95457c9..47e89a48f 100644 --- a/docs/cli/faq.mdx +++ b/docs/cli/faq.mdx @@ -13,6 +13,7 @@ If none of the available stores work for you, you can try using the `file` store If you are still experiencing trouble, please seek support. [Learn more about vault command](./commands/vault) + diff --git a/docs/cli/scanning-overview.mdx b/docs/cli/scanning-overview.mdx index 0163d5dca..748c9d4e1 100644 --- a/docs/cli/scanning-overview.mdx +++ b/docs/cli/scanning-overview.mdx @@ -34,7 +34,7 @@ In addition to scanning for past leaks, this new addition also actively aids in infisical scan git-changes # Display the full secret findings - infisical git-changes --verbose + infisical scan git-changes --verbose ``` Scanning for secrets before you commit your changes is great way to prevent leaks. Infisical makes this easy with the sub command `git-changes`. diff --git a/docs/cli/token.mdx b/docs/cli/token.mdx deleted file mode 100644 index b5a8dc6a9..000000000 --- a/docs/cli/token.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Infisical Token" -description: "How to use Infisical service token within the CLI." ---- - -Prerequisite: [Infisical Token and How to Generate One](/documentation/platform/token). - -It's possible to use the CLI to sync environment variables without manually entering login credentials by using a service token in the prerequisite link above. - -## Feeding Infisical Token to the CLI - -The CLI looks out for an environment variable called the `INFISICAL_TOKEN` which you can set depending on where you run the CLI. If `INFISICAL_TOKEN` is detected by the CLI, it will authenticate and retrieve the environment variables which the token is authorized for. - -A common use-case is to use the Infisical Token to fetch environment variables with Docker. More specifically, a token can be passed to a container as an environment variable for the CLI to authenticate and pull its corresponding secrets. Check out the integration guides for that: - -- [Docker](../../integrations/platforms/docker) -- [Docker Compose](../../integrations/platforms/docker-compose) - - - Once the token is expired, the CLI using it will no longer be able to make - requests with it. - diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index 2ea7b6425..e372bd8cf 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -1,137 +1,227 @@ --- -title: "Quick usage" +title: "Quickstart" description: "Manage secrets with Infisical CLI" --- -The CLI is designed for a variety of applications, ranging from local secret management to CI/CD and production scenarios. -The distinguishing factor, however, is the authentication method used. +The CLI is designed for a variety of secret management applications ranging from local development to CI/CD and production scenarios. - - To use the Infisical CLI in your local development environment, simply run the command below and follow the interactive guide. + + In the following steps, we explore how to use the Infisical CLI to fetch back environment variables from Infisical + and inject them into your local development process. + + + + Start by running the `infisical login` command to authenticate with Infisical. + + ```bash + infisical login + ``` + + If you are in a containerized environment such as WSL 2 or Codespaces, run `infisical login -i` to avoid browser based login + + + + Next, navigate to your project and initialize Infisical. + + ```bash + # navigate to your project + cd /path/to/project - ```bash - infisical login - ``` + # initialize infisical + infisical init + ``` - - If you are in a containerized environment such as WSL 2 or Codespaces, run `infisical login -i` to avoid browser based login - + The `infisical init` command creates a `.infisical.json` file, containing [local project settings](./project-config), at the location where the command is executed. - ## Initialize Infisical for your project + + The `.infisical.json` file does not contain any sensitive data, so you may commit it to your git repository. + + + + Finally, pass environment variables from Infisical into your application. - ```bash - # navigate to your project - cd /path/to/project + + + ```bash + infisical run --env=dev --path=/apps/firefly -- [your application start command] # e.g. npm run dev - # initialize infisical - infisical init - ``` + # example with node (nodemon) + infisical run --env=staging --path=/apps/spotify -- nodemon index.js + + # example with flask + infisical run --env=prod --path=/apps/backend -- flask run + + # example with spring boot - maven + infisical run --env=dev --path=/apps/ -- ./mvnw spring-boot:run --quiet + ``` + + + + Custom aliases can utilize secrets from Infisical. Suppose there is a custom alias `yd` in `custom.sh` that runs `yarn dev` and needs the secrets provided by Infisical. + ```bash + #!/bin/sh + + yd() { + yarn dev + } + ``` + + To make the secrets available from Infisical to `yd`, you can run the following command: + + ```bash + infisical run --env=prod --path=/apps/reddit --command="source custom.sh && yd" + ``` + + + + View all available options for `run` command [here](./commands/run) + + - This will create `.infisical.json` file at the location the command was executed. This file contains your [local project settings](./project-config). It does not contain any sensitive data. - - - To use Infisical for non local development scenarios, please create a [service token](../documentation/platform/token). The service token will allow you to authenticate and interact with Infisical. - Once you have created a service token with the required permissions, you'll need to feed the token to the CLI. + + In the following steps, we explore how to use the Infisical CLI in a non-local development scenario + to fetch back environment variables and export them to a file. + + + Follow the steps listed [here](/documentation/platform/identities/universal-auth) to create a machine identity and obtain a **client ID** and **client secret** for it. + + + Run the following command to authenticate with Infisical using the **client ID** and **client secret** credentials from step 1 and set the `INFISICAL_TOKEN` environment variable to the retrieved access token. + + ```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. + ``` - #### Pass as flag - You may use the --token flag to set the token + The CLI is configured to look out for the `INFISICAL_TOKEN` environment variable, so going forward any command used will be authenticated. - ``` - infisical export --token=<> - infisical secrets --token=<> - infisical run --token=<> -- npm run dev - ``` + Alternatively, assuming you have an access token on hand, you can also pass it directly to the CLI using the `--token` flag in conjunction with other CLI commands. - #### Pass via shell environment variable - The CLI is configured to look for an environment variable named `INFISICAL_TOKEN`. If set, it'll attempt to use it for authentication. + + Keep in mind that the machine identity access token has a limited lifetime. It is recommended to use it only for the duration of the task at hand. + You can [refresh the token](./commands/token) if needed. + + + + Finally, export the environment variables from Infisical to a file of choice. - ``` - export INFISICAL_TOKEN=<> - ``` - + ```bash + # export variables to a .env file (with export keyword) + infisical export --format=dotenv-export > .env + + # export variables to a YAML file + infisical export --format=yaml > secrets.yaml + ``` + + +## History -## Inject environment variables - - - ```bash - infisical run --env=dev --path=/apps/firefly -- [your application start command] +Your terminal keeps a history with the commands you run. When you create Infisical secrets directly from your terminal, they'll stay there for a while. - # example with node (nodemon) - infisical run --env=staging --path=/apps/spotify -- nodemon index.js +For security and privacy concerns, we recommend you to configure your terminal to ignore those specific Infisical commands. - # example with flask - infisical run --env=prod --path=/apps/backend -- flask run + + + + + `$HOME/.profile` is pretty common but, you could place it under `$HOME/.profile.d/infisical.sh` or any profile file run at login + - # example with spring boot - maven - infisical run --env=dev --path=/apps/ -- ./mvnw spring-boot:run --quiet - ``` - - - Custom aliases can utilize secrets from Infisical. Suppose there is a custom alias `yd` in `custom.sh` that runs `yarn dev` and needs the secrets provided by Infisical. - ```bash - #!/bin/sh + ```bash + cat <> $HOME/.profile && source $HOME/.profile - yd() { - yarn dev - } - ``` + # Ignoring specific Infisical CLI commands + DEFAULT_HISTIGNORE=$HISTIGNORE + export HISTIGNORE="*infisical secrets set*:$DEFAULT_HISTIGNORE" + EOF + ``` - To make the secrets available from Infisical to `yd`, you can run the following command: + + + If you're on WSL, then you can use the Unix/Linux method. - ```bash - infisical run --env=prod --path=/apps/reddit --command="source custom.sh && yd" - ``` - - + + Here's some [documentation](https://superuser.com/a/1658331) about how to clear the terminal history, in PowerShell and CMD + -View all available options for `run` command [here](./commands/run) + -## Connect CLI to self hosted Infisical + + - -The CLI is set to connect to Infisical Cloud by default, but if you're running your own instance of Infisical, you can direct the CLI to it using one of the methods provided below. +## FAQ -#### Method 1: Use the updated CLI -Beginning with CLI version V0.4.0, it is now possible to choose between logging in through the Infisical cloud or your own self-hosted instance. Simply execute the `infisical login` command and follow the on-screen instructions. + + + Yes. The CLI is set to connect to Infisical Cloud by default, but if you're running your own instance of Infisical, you can direct the CLI to it using one of the methods provided below. -#### Method 2: Export environment variable -You can point the CLI to the self hosted Infisical instance by exporting the environment variable `INFISICAL_API_URL` in your terminal. + #### Method 1: Use the updated CLI - - - ```bash - # Set backend host - export INFISICAL_API_URL="https://your-self-hosted-infisical.com/api" + Beginning with CLI version V0.4.0, it is now possible to choose between logging in through the Infisical cloud or your own self-hosted instance. Simply execute the `infisical login` command and follow the on-screen instructions. - # Remove backend host - unset INFISICAL_API_URL - ``` - - - ```bash - # Set backend host - setx INFISICAL_API_URL "https://your-self-hosted-infisical.com/api" + #### Method 2: Export environment variable - # Remove backend host - setx INFISICAL_API_URL "" + You can point the CLI to the self hosted Infisical instance by exporting the environment variable `INFISICAL_API_URL` in your terminal. - # NOTE: Once set or removed, please restart powershell for the change to take effect - ``` - - + + + ```bash + # set backend host + export INFISICAL_API_URL="https://your-self-hosted-infisical.com/api" + + # remove backend host + unset INFISICAL_API_URL + ``` + + + + ```bash + # set backend host + setx INFISICAL_API_URL "https://your-self-hosted-infisical.com/api" + + # remove backend host + setx INFISICAL_API_URL "" + + # NOTE: Once set or removed, please restart powershell for the change to take effect + ``` + + + + #### Method 3: Set manually on every command + Another option to point the CLI to your self hosted Infisical instance is to set it via a flag on every command you run. -```bash +```bash # Example infisical --domain="https://your-self-hosted-infisical.com/api" ``` - + + + + Yes. Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + To use Infisical for non local development scenarios, please create a service token. The service token will allow you to authenticate and interact with Infisical. Once you have created a service token with the required permissions, you’ll need to feed the token to the CLI. + + ```bash + infisical export --token= + infisical secrets --token= + infisical run --token= -- npm run dev + ``` + + #### Pass via shell environment variable + The CLI is configured to look for an environment variable named `INFISICAL_TOKEN`. If set, it’ll attempt to use it for authentication. + + ```bash + export INFISICAL_TOKEN= + ``` + + + diff --git a/docs/contributing/platform/backend/folder-structure.mdx b/docs/contributing/platform/backend/folder-structure.mdx new file mode 100644 index 000000000..abfe0f69d --- /dev/null +++ b/docs/contributing/platform/backend/folder-structure.mdx @@ -0,0 +1,82 @@ +--- +title: 'Backend folder structure' +--- + +``` +β”œβ”€β”€ scripts +β”œβ”€β”€ e2e-test +└── src/ + β”œβ”€β”€ @types/ + β”‚ β”œβ”€β”€ knex.d.ts + β”‚ └── fastify.d.ts + β”œβ”€β”€ db/ + β”‚ β”œβ”€β”€ migrations + β”‚ β”œβ”€β”€ schemas + β”‚ └── seed + β”œβ”€β”€ lib/ + β”‚ β”œβ”€β”€ fn + β”‚ β”œβ”€β”€ date + β”‚ └── config + β”œβ”€β”€ queue + β”œβ”€β”€ server/ + β”‚ β”œβ”€β”€ routes/ + β”‚ β”‚ β”œβ”€β”€ v1 + β”‚ β”‚ └── v2 + β”‚ β”œβ”€β”€ plugins + β”‚ └── config + β”œβ”€β”€ services/ + β”‚ β”œβ”€β”€ auth + β”‚ β”œβ”€β”€ org + β”‚ └── project/ + β”‚ β”œβ”€β”€ project-service.ts + β”‚ β”œβ”€β”€ project-types.ts + β”‚ └── project-dal.ts + └── ee/ + β”œβ”€β”€ routes + └── services +``` + +### `backend/scripts` +Contains reusable scripts for backend automation, like running migrations and generating SQL schemas. + +### `backend/e2e-test` +Integration tests for the APIs. + +### `backend/src` +The source code of the backend. + +- `@types`: Type definitions for libraries like Fastify and Knex. +- `db`: Knex.js configuration for the database, including migration, seed files, and SQL type schemas. +- `lib`: Stateless, reusable functions used across the codebase. +- `queue`: Infisical's queue system based on BullMQ. + +### `src/server` + +- Scope anything related to Fastify/service here. +- Includes routes, Fastify plugins, and server configurations. +- The routes folder contains various versions of routes separated into v1, v2, etc. + +### `src/services` + +- Handles the core business logic for all operations. +- Follows the co-location principle: related components should be kept together. +- Each service component typically contains: + + 1. **dal**: Database Access Layer functions for database operations + 2. **service**: The service layer containing business logic. + 3. **type**: Type definitions used within the service component. + 4. **fns**: An optional component for sharing reusable functions related to the service. + 5. **queue**: An optional component for queue-specific logic, like `secret-queue.ts`. + +### `src/ee` + +Follows the same pattern as above, with the exception of a license change from MIT to Infisical Proprietary License. + +### Guidelines and Best Practices + +- All services are interconnected at `/src/server/routes/index.ts`, following the principle of simple dependency injection. +- Files should be named in dash-case. +- Avoid using classes in the codebase; opt for simple functions instead. +- All committed code must be properly linted using `npm run lint:fix` and type-checked with `npm run type:check`. +- Minimize shared logic between services as much as possible. +- Controllers within a router component should ideally call only one service layer, with exceptions for services like `audit-log` that require access to request object data. \ No newline at end of file diff --git a/docs/contributing/platform/backend/how-to-create-a-feature.mdx b/docs/contributing/platform/backend/how-to-create-a-feature.mdx new file mode 100644 index 000000000..e77313dab --- /dev/null +++ b/docs/contributing/platform/backend/how-to-create-a-feature.mdx @@ -0,0 +1,56 @@ +--- +title: "Backend development guide" +--- + +Suppose you're interested in implementing a new feature in Infisical's backend, let's call it "feature-x." Here are the general steps you should follow. + +## Database schema migration +In order to run [schema migrations](https://en.wikipedia.org/wiki/Schema_migration#:~:text=A%20schema%20migration%20is%20performed,some%20newer%20or%20older%20version) you need to expose your database connection string. Create a `.env.migration` file to set the database connection URI for migration scripts, or alternatively, export the `DB_CONNECTION_URI` environment variable. + +## Creating new database model +If your feature involves a change in the database, you need to first address this by generating the necessary database schemas. + +1. If you're adding a new table, update the `TableName` enum in `/src/db/schemas/models.ts` to include the new table name. +2. Create a new migration file by running `npm run migration:new` and give it a relevant name, such as `feature-x`. +3. Navigate to `/src/db/migrations/_.ts`. +4. Modify both the `up` and `down` functions to create or alter Postgres fields on migration up and to revert these changes on migration down, ensuring idempotency as outlined [here](https://github.com/graphile/migrate/blob/main/docs/idempotent-examples.md). + +### Generating TS Schemas + +While typically you would need to manually write TS types for Knex type-sense, we have automated this process: + +1. Start the server. +2. Run `npm run migration:latest` to apply all database changes. +3. Execute `npm run generate:schema` to automatically generate types and schemas using [zod](https://github.com/colinhacks/zod) in the `/src/db/schemas` folder. +4. Update the barrel export in `schema/index` and include the new tables in `/src/@types/knex.d.ts` to enable type-sensing in Knex.js. + +## Business Logic + +Once the database changes are in place, it's time to create the APIs for `feature-x`: + +1. Execute `npm run generate:component`. +2. Choose option 1 for the service component. +3. Name the service in dash-case, like `feature-x`. This will create a `feature-x` folder in `/src/services` containing three files. + 1. `feature-x-dal`: The Database Access Layer functions. + 2. `feature-x-service`: The service layer where all the business logic is handled. + 3. `feature-x-type`: The types used by `feature-x`. + +For reusable shared functions, set up a file named `feature-x-fns`. + +Use the custom Infisical function `ormify` in `src/lib/knex` for simple database operations within the DAL. + +## Connecting the Service Layer to the Server Layer + +Server-related logic is handled in `/src/server`. To connect the service layer to the server layer, we use Fastify plugins for dependency injection: + +1. Add the service type in the `fastify.d.ts` file under the `service` namespace of a FastifyServerInstance type. +2. In `/src/server/routes/index.ts`, instantiate the required dependencies for `feature-x`, such as the DAL and service layers, and then pass them to `fastify.register("service,{...dependencies})`. +3. This makes the service layer accessible within all routes under the Fastify service instance, accessed via `server.services..`. + +## Writing API Routes + +1. To create a route component, run `npm generate:component`. +2. Select option 3, type the router name in dash-case, and provide the version number. This will generate a router file in `src/server/routes/v/` + 1. Implement your logic to connect with the service layer as needed. + 2. Import the router component in the version folder's index.ts. For instance, if it's in v1, import it in `v1/index.ts`. + 3. Finally, register it under the appropriate prefix for access. \ No newline at end of file diff --git a/docs/contributing/platform/developing.mdx b/docs/contributing/platform/developing.mdx index 7a43c8250..a6675b6f6 100644 --- a/docs/contributing/platform/developing.mdx +++ b/docs/contributing/platform/developing.mdx @@ -16,49 +16,7 @@ git checkout -b MY_BRANCH_NAME ## Set up environment variables -Start by creating a .env file at the root of the Infisical directory then copy the contents of the file below into the .env file. - - - ```env - # Keys - # Required key for platform encryption/decryption ops - 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 - - # MongoDB - # Backend will connect to the MongoDB instance at connection string MONGO_URL which can either be a ref - # to the MongoDB container instance or Mongo Cloud - # Required - MONGO_URL=mongodb://root:example@mongo:27017/?authSource=admin - - # Optional credentials for MongoDB container instance and Mongo-Express - MONGO_USERNAME=root - MONGO_PASSWORD=example - - # Website URL - # Required - SITE_URL=http://localhost:8080 - - # Mail/SMTP - SMTP_HOST='smtp-server' - SMTP_PORT='1025' - SMTP_NAME='local' - SMTP_USERNAME='team@infisical.com' - SMTP_PASSWORD= - ``` - - - - The pre-populated environment variable values above are meant to be used in development only. They should never be used in production. - - -View all available [environment variables](https://infisical.com/docs/self-hosting/configuration/envars) and guidance for each. +Start by creating a .env file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.example). View all available [environment variables](https://infisical.com/docs/self-hosting/configuration/envars) and guidance for each. ## Starting Infisical for development @@ -72,10 +30,7 @@ docker-compose -f docker-compose.dev.yml up --build --force-recreate ``` #### Access local server -Once all the services have spun up, browse to http://localhost:8080. To sign in, you may use the default credentials listed below. - -Email: `test@localhost.local` -Password: `testInfisical1` +Once all the services have spun up, browse to http://localhost:8080. #### Shutdown local server diff --git a/docs/documentation/getting-started/introduction-new.mdx b/docs/documentation/getting-started/introduction-new.mdx new file mode 100644 index 000000000..c8eee8739 --- /dev/null +++ b/docs/documentation/getting-started/introduction-new.mdx @@ -0,0 +1,107 @@ +--- +mode: 'custom' +--- + +export function openSearch() { + document.getElementById('search-bar-entry').click(); +} + +
+
+ +
+
+
+ Infisical Documentation +
+

+ What can we help you build? +

+ +
+
+ +
+ +
+ Choose a topic below or simply{' '} + get started +
+ + + + Practical guides and best practices to get you up and running quickly. + + + Comprehensive details about the Infisical API. + + + Learn more about Infisical's architecture and underlying security. + + + Read self-hosting instruction for Infisical. + + + Infisical's growing number of third-party integrations. + + + News about features and changes in Pinecone and related tools. + + + +
\ No newline at end of file diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 84d34e871..06455092d 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -1,107 +1,109 @@ --- -title: "Introduction" +title: "What is Infisical?" +sidebarTitle: "What is Infisical?" +description: "An Introduction to the Infisical secret management 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 for storing, managing, and syncing -application configuration and secrets like API keys, database credentials, and environment variables across applications and infrastructure. +Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. +It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database +credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure +sharing of secrets among engineers. -Start syncing environment variables with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. - -## Learn about Infisical - - - Store secrets like API keys, database credentials, environment variables with Infisical - - -## Access secrets +Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. - - Inject secrets into any application process/environment + + Get started with Infisical Cloud in just a few minutes. + + + Self-host Infisical on your own infrastructure. + + + +## Why Infisical? + +Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: + +- Streamlined **local development** processes (switching .env files to [Infisical CLI](/cli/commands/run) and removing secrets from developer machines). +- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). +- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. +- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. +- **Facilitated workflows** around [secret change management](/documentation/platform/pr-workflows), [access requests](/documentation/platform/access-controls/access-requests), [temporary access provisioning](/documentation/platform/access-controls/temporary-access), and more. +- **Improved security posture** thanks to [secret scanning](/cli/scanning-overview), [granular access control policies](/documentation/platform/access-controls/overview), [automated secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview), and [dynamic secrets](/documentation/platform/dynamic-secrets/overview) capabilities. + +## How does Infisical work? + +To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. + +**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. + +As a result, the 3 main concepts that are important to understand are: + +- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. +- **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). +- **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, AWS Auth etc.). + +## How to get started with Infisical? + +Depending on your use case, it might be helpful to look into some of the resources and guides provided below. + + + + Inject secrets into any application process/environment. - Fetch secrets with any programming language on demand + Fetch secrets with any programming language on demand. - - Inject secrets into Docker containers + + Inject secrets into Docker containers. - Fetch and save secrets as native Kubernetes secrets + Fetch and save secrets as native Kubernetes secrets. - Fetch secrets via HTTP request - - - -## Resources - - - - Learn how to configure and deploy Infisical - - - Explore guides for every language and stack + Fetch secrets via HTTP request. - Explore integrations for GitHub, Vercel, Netlify, and more - - - Explore integrations for Next.js, Express, Django, and more - - - Scan and prevent 140+ secret type leaks in your codebase - - - Questions? Need help setting up? Book a 1x1 meeting with us + Explore integrations for GitHub, Vercel, AWS, and more. diff --git a/docs/documentation/getting-started/kubernetes.mdx b/docs/documentation/getting-started/kubernetes.mdx deleted file mode 100644 index 4585e7ce1..000000000 --- a/docs/documentation/getting-started/kubernetes.mdx +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Kubernetes" ---- - -The Infisical Secrets Operator fetches secrets from Infisical and saves them as Kubernetes secrets using the custom `InfisicalSecret` resource to define authentication and storage methods. -The operator updates secrets continuously and can reload dependent deployments automatically on secret changes. - -Prerequisites: - -- Connected to your cluster via kubectl -- 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 - -Follow the instructions for either [Helm](https://helm.sh/) or [kubectl](https://github.com/kubernetes/kubectl) to install the Infisical Secrets Operator. - - - - Install the Infisical Helm repository - - ```console - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - - helm repo update - ``` - - Install the Helm chart - ```console - helm install --generate-name infisical-helm-charts/secrets-operator - ``` - - - - The operator will be installed in `infisical-operator-system` namespace - ``` - kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical/main/k8-operator/kubectl-install/install-secrets-operator.yaml - ``` - - - - -## Usage - -**Step 1: Create Kubernetes secret containing service token** - -Once you have generated the service token, create a Kubernetes secret containing the service token you generated by running the command below. - -``` bash -kubectl create secret generic service-token --from-literal=infisicalToken= -``` - -**Step 2: Fill out the InfisicalSecrets CRD and apply it to your cluster** - -```yaml infisical-secrets-config.yaml -apiVersion: secrets.infisical.com/v1alpha1 -kind: InfisicalSecret -metadata: - # Name of of this InfisicalSecret resource - name: infisicalsecret-sample -spec: - # The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used - hostAPI: https://app.infisical.com/api - resyncInterval: - authentication: - serviceToken: - serviceTokenSecretReference: - secretName: service-token - secretNamespace: option - secretsScope: - envSlug: dev - secretsPath: "/" - managedSecretReference: - secretName: managed-secret # <-- the name of kubernetes secret that will be created - secretNamespace: default # <-- where the kubernetes secret should be created -``` - -``` -kubectl apply -f infisical-secrets-config.yaml -``` - -You should now see a new kubernetes secret automatically created in the namespace you defined in the `managedSecretReference` property above. - -See also: - -- [Documentation for the Infisical Kubernetes Operator](../../integrations/platforms/kubernetes) - diff --git a/docs/documentation/getting-started/platform.mdx b/docs/documentation/getting-started/platform.mdx index 429524161..1a1164a40 100644 --- a/docs/documentation/getting-started/platform.mdx +++ b/docs/documentation/getting-started/platform.mdx @@ -21,7 +21,7 @@ Here, you can also create a new project. 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) +![organization members](../../images/organization/platform/organization-members.png) ## Managing your Projects diff --git a/docs/documentation/getting-started/sdks.mdx b/docs/documentation/getting-started/sdks.mdx index aef15294a..b3e8a3925 100644 --- a/docs/documentation/getting-started/sdks.mdx +++ b/docs/documentation/getting-started/sdks.mdx @@ -18,4 +18,4 @@ Follow the instructions for your language use the SDK for it: - [Java SDK](https://infisical.com/docs/sdks/languages/java) - [.NET SDK](https://infisical.com/docs/sdks/languages/csharp) -Missing a language? [Throw in a request](https://github.com/Infisical/infisical/issues). \ No newline at end of file +Missing a language? [Throw in a request here](https://github.com/Infisical/infisical/issues). diff --git a/docs/documentation/guides/local-development.mdx b/docs/documentation/guides/local-development.mdx new file mode 100644 index 000000000..c2651cb58 --- /dev/null +++ b/docs/documentation/guides/local-development.mdx @@ -0,0 +1,34 @@ +--- +title: "Secret Management in Development Environments" +sidebarTitle: "Local Development" +description: "Learn how to manage secrets in local development environments." +--- + +## Problem at hand + +There is a number of issues that arise with secret management in local development environment: +1. **Getting secrets onto local machines**. When new developers join or a new project is created, the process of getting the development set of secrets onto local machines is often unclear. As a result, developers end up spending a lot of time onboarding and risk potentially following insecure practices when sharing secrets from one developer to another. +2. **Syncing secrets with teammates**. One of the problems with .env files is that they become unsynced when one of the developers updates a secret or configuration. Even if the rest of the team is notified, developers don't make all the right changes immediately, and later on end up spending a lot of time debugging an issue due to missing environment variables. This leads to a lot of inefficiencies and lost time. +3. **Accidentally leaking secrets**. When developing locally, it's common for developers to accidentally leak a hardcoded as part of a commit. As soon as the secret is part of the git history, it becomes hard to get it removed and create a security vulnerability. + +## Solution + +One of the main benefits of Infisical is the facilitation of secret management workflows in local development use cases. In particular, Infisical heavily follows the "Security Shift Left" principle to enable developers to effotlessly follow secure practices when coding. + +### CLI + +[Infisical CLI](/cli/overview) is the most frequently used Infisical tool for secret management in local development environments. It makes it easy to inject secrets right into the local application environments based on the permissions given to corresponsing developers. + +### Dashboard + +On top of that, Infisical provides a great [Web Dashboard](https://app.infisical.com/signup) that can be used to making quick secret updates. + +![project dashboard](../../images/dashboard.png) + +### Personal Overrides + +By default, all the secrets in the Infisical environments are shared among project members who have the permission to access those environment. At the same time, when doing local development, it is often desirable to change the value of a certain secret only for a particular self. For such use cases, Infisical supports the functionality of **Personal Overrides** – which allow developers to override values of any secrets without affecting the workflows of the rest of the team. Personal Overrides can be created both in the dashboard or via [Infisical CLI](/cli/overview). + +### Secret Scanning + +In addition, Infisical also provides a set of tools to automatically prevent secret leaks to git history. This functionlality can be set up on the level of [Infisical CLI using pre-commit hooks](/cli/scanning-overview#automatically-scan-changes-before-you-commit) or through a direct integration with platforms like GitHub. \ No newline at end of file diff --git a/docs/documentation/guides/microsoft-power-apps.mdx b/docs/documentation/guides/microsoft-power-apps.mdx new file mode 100644 index 000000000..64647c8e6 --- /dev/null +++ b/docs/documentation/guides/microsoft-power-apps.mdx @@ -0,0 +1,114 @@ +--- +title: "Microsoft Power Apps" +description: "Learn how to manage secrets in Microsoft Power Apps with Infisical." +--- +In recent years, there has been a shift towards so-called low-code and no-code platforms. These platforms are particularly appealing to businesses without internal development capabilities, yet teams often discover that some coding is necessary to fully satisfy their business needs. + +Low-code platforms have become increasingly sophisticated and useful, leading to a rise in their adoption by businesses. A prime example is Microsoft Power Apps, which offers a range of data sources and service integrations right out of the box. However, even with advanced tools, you might not always find a ready-made solution for every challenge. This means that low-code doesn't equate to no-code, as some coding and customization are still required to cater to specific needs. + +Consider the need for data integrations where an HTTP-based call to a web service might be necessary, typically requiring authentication through an API key or another type of secret. + +Importantly, it's crucial to avoid hardcoding these secrets, as they would then be accessible to anyone with collaboration rights to the code. This underscores the importance of using a secret management solution like Infisical. + +In this article, we'll demonstrate how to retrieve app secrets from Infisical for use in a Power Apps application. We'll create a simple application with a dedicated data connector to illustrate the ease of integrating Infisical with Power Apps. This tutorial assumes some prior programming experience in C#. + +Prerequisites: +- Created Microsoft Power App. + + + + First, let’s create a new Azure Function using the Azure Management Portal. Get the [Function App](https://azuremarketplace.microsoft.com/en-us/marketplace/apps/Microsoft.FunctionApp?tab=Overview) from the [Azure Marketplace](https://azuremarketplace.microsoft.com/en-us/). + ![function app](../../images/guides/microsoft-power-apps/function-app.png) + + Place it in a subscription using any resource group. The name of the function is arbitrary. We'll use .NET as a runtime stack, but you can use whatever you're most comfortable with. The OS choice is also up to you. While Linux may look like a lightweight solution, Windows actually has more Azure Functions support. For instance, you cannot edit a Linux-based Azure Function within the Azure management portal. + + By using a consumption plan, we'll only pay for the resources we use when they are requested. This is the classic β€œserverless” approach, where you do not pay for running servers, only for interactivity. + + Once the new Azure Functions instance is ready, we add a function. In this case, we can do that already from the Azure Management Portal. Use the β€œHTTP trigger” template and choose the β€œfunction” authorization level. + + The code for the first function can be as simple as: + + ``` + using System.Net; + + public static async Task Run(HttpRequestMessage req, TraceWriter log) + { + log.Info("C# HTTP trigger function processed a request."); + return req.CreateResponse(HttpStatusCode.OK, "Hello World"); + } + ``` + + + The code above is written for the older runtime. As a result, you may need to change the runtime version to 1 for the Azure Power Apps integration to work. If we start at a newer version (for example, 3) this triggers a warning before the migration. + + + Finally, we also need to publish the Swagger (or API) definitions and enable cross-origin resource sharing (CORS). While the API definitions are rather easy to set up, the correct CORS value may be tricky. For now, we can use the wildcard option to allow all hosts. + + + + + Once we set all this up, it’s time to create the custom connector. + + You can create the custom connector via the data pane. When we use β€œCreate from Azure Service (Preview)”, this yields a dialog similar to the following: + + ![custom-connector](../../images/guides/microsoft-power-apps/custom-connector.png) + + We can now fill out the fields using the information for our created function. The combination boxes are automatically filled in order. Once we select one of the reachable subscriptions (tied to the same account we’ve used to log in to create a Power App), the available services are displayed. Once we select our Azure Functions service, we select the function for retrieving the secret. + + + + + You can add Infisical in an Azure Function quite easily using the [Infisical SDK for .NET](https://infisical.com/docs/sdks/languages/csharp) (or other languages). This enables the function to communicate with Infisical to obtain secrets, among other things. + + In short, we can simply bring all the necessary classes over and start using the Client class. Essentially, this enables us to write code like this: + + ``` + var settings = new ClientSettings + { + ClientId = "CLIENT_ID", + ClientSecret = "CLIENT_SECRET", + // SiteUrl = "http://localhost:8080", <-- This line can be omitted if you're using Infisical Cloud. + }; + var infisical = new InfisicalClient(settings); + + var options = new GetSecretOptions + { + SecretName = "TEST", + ProjectId = "PROJECT_ID", + Environment = "dev", + }; + var secret = infisical.GetSecret(options); + ``` + + Knowing the URL of Infisical as well as the Client Id and Client Secret, we can now access the desired values. + + Now it’s time to actually use the secret within a Power App. There are two ways to request a desired target service with a secret retrieved from the function: + + 1. Call the function first, retrieve the secret, then call the target service, for example, via another custom connector with the secret as input. + + 2. Perform the final API request within the function call β€” not returning a secret at all, just the response from invoking the target service. + + While the first option is more flexible (and presumably cheaper!), the second option is definitely easier. In the end, you should mostly decide based on whether the function should be reused for other purposes. If the single Power App is the only consumer of the function, it may make more sense to go with the second option. Otherwise, you should use the first option. + + For our simple example, we don’t need to reuse the function. We also don’t want the additional complexity of maintaining two different custom connectors, where we only use one to pass data to the other one. + + Based on the previous snippet, we create the following code (for proxying a GET request from an API accessible via the URL specified in the apiEndpoint variable). + + ``` + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders + .Accept + .Add(new MediaTypeWithQualityHeaderValue("application/json")); + + client.DefaultRequestHeaders.Add("X-API-KEY", secret); + + var result = await client.GetAsync(apiEndpoint); + var resultContent = await result.Content.ReadAsStringAsync(); + req.CreateResponse(HttpStatusCode.OK, resultContent); + } + ``` + This creates a request to the resource protected by an API key that is retrieved from Infisical. + + + \ No newline at end of file diff --git a/docs/documentation/guides/nextjs-vercel.mdx b/docs/documentation/guides/nextjs-vercel.mdx index 2e8805cd0..5aeadc752 100644 --- a/docs/documentation/guides/nextjs-vercel.mdx +++ b/docs/documentation/guides/nextjs-vercel.mdx @@ -193,7 +193,7 @@ Next, navigate to your project's integrations tab in Infisical and press on the ![integrations](../../images/integrations.png) -![integrations vercel authorization](../../images/integrations-vercel-auth.png) +![integrations vercel authorization](../../images/integrations/vercel/integrations-vercel-auth.png) Opting in for the Infisical-Vercel integration will break end-to-end encryption since Infisical will be able to read @@ -205,8 +205,8 @@ Next, navigate to your project's integrations tab in Infisical and press on the Now select **Production** for (the source) **Environment** and sync it to the **Production Environment** of the (target) application in Vercel. Lastly, press create integration to start syncing secrets to Vercel. -![integrations vercel](../../images/integrations-vercel-create.png) -![integrations vercel](../../images/integrations-vercel.png) +![integrations vercel](../../images/integrations/vercel/integrations-vercel-create.png) +![integrations vercel](../../images/integrations/vercel/integrations-vercel.png) You should now see your secret from Infisical appear as production environment variables in your Vercel project. diff --git a/docs/documentation/guides/node.mdx b/docs/documentation/guides/node.mdx index 1e00aed99..8b78cde5e 100644 --- a/docs/documentation/guides/node.mdx +++ b/docs/documentation/guides/node.mdx @@ -75,7 +75,7 @@ app.get("/", async (req, res) => { app.listen(PORT, async () => { // initialize client - console.log(`App listening on port ${port}`); + console.log(`App listening on port ${PORT}`); }); ``` diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx new file mode 100644 index 000000000..45c155ab4 --- /dev/null +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -0,0 +1,22 @@ +--- +title: "Access Requests" +description: "Learn how to request access to sensitive resources in Infisical." +--- + +In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. + +This functionality works in the following way: +1. A project administrator sets up a policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. +![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) +![Access Request Policies](/images/platform/access-controls/access-request-policies.png) + +2. When a developer requests access to one of such sensitive resources, the request is visible in the dashboard, and the corresponding eligible approvers get an email notification about it. +![Access Request Create](/images/platform/access-controls/request-access.png) +![Access Request Dashboard](/images/platform/access-controls/access-requests-pending.png) + +3. An eligible approver can approve or reject the access request. +![Access Request Review](/images/platform/access-controls/review-access-request.png) + +4. As soon as the request is approved, developer is able to access the sought resources. +![Access Request Dashboard](/images/platform/access-controls/access-requests-completed.png) + diff --git a/docs/documentation/platform/access-controls/additional-privileges.mdx b/docs/documentation/platform/access-controls/additional-privileges.mdx new file mode 100644 index 000000000..8f29d1d6e --- /dev/null +++ b/docs/documentation/platform/access-controls/additional-privileges.mdx @@ -0,0 +1,22 @@ +--- +title: "Additional Privileges" +description: "Learn how to add specific privileges on top of predefined roles." +--- + +Even though Infisical supports full-fledged [role-base access controls](./role-based-access-controls) with ability to set predefined permissions for user and machine identities, it is sometimes desired to set additional privileges for specific user or machine identities on top of their roles. + +Infisical **Additional Privileges** functionality enables specific permissions with access to sensitive secrets/folders by identities within certain projects. It is possible to set up additional privileges through Web UI or API. + +To provision specific privileges through Web UI: +1. Click on the `Edit` button next to the set of roles for user or identities. +![Edit User Role](/images/platform/access-controls/edit-role.png) + +2. Click `Add Additional Privileges` in the corresponding section of the permission management modal. +![Add Specific Privilege](/images/platform/access-controls/add-additional-privileges.png) + +3. Fill out the necessary parameters in the privilege entry that appears. It is possible to specify the `Environment` and `Secret Path` to which you want to enable access. +It is also possible to define the range of permissions (`View`, `Create`, `Modify`, `Delete`) as well as how long the access should last (e.g., permanent or timed). +![Additional privileges](/images/platform/access-controls/additional-privileges.png) + +4. Click the `Save` button to enable the additional privilege. +![Confirm Specific Privilege](/images/platform/access-controls/confirm-additional-privileges.png) \ No newline at end of file diff --git a/docs/documentation/platform/access-controls/overview.mdx b/docs/documentation/platform/access-controls/overview.mdx new file mode 100644 index 000000000..54fc8ff25 --- /dev/null +++ b/docs/documentation/platform/access-controls/overview.mdx @@ -0,0 +1,58 @@ +--- +title: "Access Controls" +sidebarTitle: "Overview" +description: "Learn about Infisical's access control toolset." +--- + +To make sure that users and machine identities are only accessing the resources and performing actions they are authorized to, Infisical supports a wide range of access control tools. + + + + Manage user and machine identitity permissions through predefined roles. + + + Add specific privileges to users and machines on top of their roles. + + + Grant timed access to roles and specific privileges. + + + Enable users to request (temporary) access to sensitive resources. + + + Set up review policies for secret changes in sensitive environments. + + + Track every action performed by user and machine identities in Infisical. + + diff --git a/docs/documentation/platform/access-controls/role-based-access-controls.mdx b/docs/documentation/platform/access-controls/role-based-access-controls.mdx new file mode 100644 index 000000000..98a2e4659 --- /dev/null +++ b/docs/documentation/platform/access-controls/role-based-access-controls.mdx @@ -0,0 +1,44 @@ +--- +title: "Role-based Access Controls" +description: "Learn how to use RBAC to manage user permissions." +--- + +Infisical's Role-based Access Controls (RBAC) enable the usage of predefined and custom roles that imply a set of permissions for user and machine identities. Such roles male it possible to restrict access to resources and the range of actions that can be performed. + +In general, access controls can be split up across [projects](/documentation/platform/project) and [organizations](/documentation/platform/organization). + +## Organization-level access controls + +By default, every user and machine identity in a organization is either an **admin** or a **member**. + +**Admins** are able to perform every action with the organization, including adding and removing organization members, managing access controls, setting up security settings, and creating new projects. + +**Members**, on the other hand, are restricted from removing organization members, modifying billing information, updating access controls, and performing a number of other actions. + +Overall, organization-level access controls are significantly of administrative nature. Access to projects, secrets and other sensitive data is specified on the project level. + +![Org member role](/images/platform/rbac/org-member-role.png) + +## Project-level access controls + +By default, every user in a project is either a **viewer**, **developer**, or an **admin**. Each of these roles comes with a varying access to different features and resources inside projects. + +As such: +- **Admin**: This role enables identities to have access to all environments, folders, secrets, and actions within the project. +- **Developers**: This role restricts identities from performing project control actions, updating Approval Workflow policies, managing roles/members, and more. +- **Viewer**: The most limiting bulit-in role on the project level – it forbids user and machine identities to perform any action and rather shows them in the read-only mode. + +![Project member role](/images/platform/access-controls/rbac.png) + +## Creating custom roles + +By creating custom roles, you are able to adjust permissions to the needs of your organization. This can be useful for: +- Creating superadmin roles, roles specific to SRE engineers, etc. +- Restricting access of users to specific secrets, folders, and environments. +- Embedding these specific roles into [Approval Workflow policies](/documentation/platform/pr-workflows). + + +It is worth noting that users are able to assume multiple built-in and custom roles. A user will gain access to all actions within the roles assigned to them, not just the actions those roles share in common. + + +![project member custom role](/images/platform/rbac/project-member-custom-role.png) diff --git a/docs/documentation/platform/access-controls/temporary-access.mdx b/docs/documentation/platform/access-controls/temporary-access.mdx new file mode 100644 index 000000000..c914c96f5 --- /dev/null +++ b/docs/documentation/platform/access-controls/temporary-access.mdx @@ -0,0 +1,26 @@ +--- +title: "Temporary Access" +description: "Learn how to set up timed access to sensitive resources for user and machine identities." +--- + +Certain environments and secrets are so sensitive that it is recommended to not give any user permanent access to those. For such use cases, Infisical supports the functionality of **Temporary Access** provisioning. + + +To provision temporary access through Web UI: +1. Click on the `Edit` button next to the set of roles for user or identities. +![Edit User Role](/images/platform/access-controls/edit-role.png) + +2. Click `Permanent` next to the role or specific privilege that you want to make temporary. + +3. Specify the duration of remporary access (e.g., `1m`, `2h`, `3d`). +![Configure temp access](/images/platform/access-controls/configure-temporary-access.png) + +4. Click `Grant`. + +5. Click the corresponding `Save` button to enable remporary access. +![Temporary Access](/images/platform/access-controls/temporary-access.png) + + +Every user and machine identity should always have at least one permanent role attached to it. + + diff --git a/docs/documentation/platform/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams.mdx new file mode 100644 index 000000000..2a69780bc --- /dev/null +++ b/docs/documentation/platform/audit-log-streams.mdx @@ -0,0 +1,82 @@ +--- +title: "Audit Log Streams" +description: "Learn how to stream Infisical Audit Logs to external logging providers." +--- + + + Audit log streams is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. + +The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing. + + +## Overview + + + + + ![stream create](../../images/platform/audit-log-streams/stream-create.png) + + + ![stream create](../../images/platform/audit-log-streams/stream-inputs.png) + + Provide the following values + + The HTTPS endpoint URL of the logging provider that collects the JSON stream. + + + The HTTP headers for the logging provider for identification and authentication. + + + + +![stream listt](../../images/platform/audit-log-streams/stream-list.png) +Your Audit Logs are now ready to be streamed. + +## Example Providers + +### Better Stack + + + + ![better stack connect source](../../images/platform/audit-log-streams/betterstack-create-source.png) + + + + ![better stack connect](../../images/platform/audit-log-streams/betterstack-source-details.png) + + 1. Copy the **endpoint** from Better Stack to the **Endpoint URL** field. + 3. Create a new header with key **Authorization** and set the value as **Bearer \**. + + + +### Datadog + + + + ![api key create](../../images/platform/audit-log-streams/datadog-api-sidebar.png) + + + ![api key form](../../images/platform/audit-log-streams/data-create-api-key.png) + ![api key form](../../images/platform/audit-log-streams/data-dog-api-key.png) + + + ![datadog url](../../images/platform/audit-log-streams/datadog-logging-endpoint.png) + + 1. Navigate to the [Datadog Send Logs API documentation](https://docs.datadoghq.com/api/latest/logs/?code-lang=curl&site=us5#send-logs). + 2. Pick your Datadog account region. + 3. Obtain your Datadog logging endpoint URL. + + + ![datadog api key details](../../images/platform/audit-log-streams/datadog-source-details.png) + + 1. Copy the **logging endpoint** from Datadog to the **Endpoint URL** field. + 2. Copy the **API Key** from previous step + 3. Create a new header with key **DD-API-KEY** and set the value as **API Key**. + + diff --git a/docs/documentation/platform/audit-logs.mdx b/docs/documentation/platform/audit-logs.mdx index b5a47df50..be2381da2 100644 --- a/docs/documentation/platform/audit-logs.mdx +++ b/docs/documentation/platform/audit-logs.mdx @@ -1,27 +1,28 @@ --- title: "Audit Logs" -description: "See which events are triggered within your Infisical project." +description: "Track evert event action performed within Infisical projects." --- Note that Audit Logs is a paid feature. - If you're using Infisical Cloud, then it is available under the **Team Tier**, **Pro Tier**, + If you're using Infisical Cloud, then it is available under the **Pro**, 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. + then you should contact sales@infisical.com to purchase an enterprise license to use it. 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. +With the Audit Log functionality, teams can: +- **Track** 40+ different events; +- **Filter** audit logs by event, actor, source, date or any combination of these filters; +- **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 occurred. -- 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. +- **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 occurred. +- **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. diff --git a/docs/documentation/platform/auth-methods/email-password.mdx b/docs/documentation/platform/auth-methods/email-password.mdx new file mode 100644 index 000000000..db23026b8 --- /dev/null +++ b/docs/documentation/platform/auth-methods/email-password.mdx @@ -0,0 +1,14 @@ +--- +title: "Email and Password" +description: "Learn how to authenticate into Infisical with email and password." +--- + +**Email and Password** is the most common authentication method that can be used by user identities for authentication into Web Dashboard and Infisical CLI. It is recommended to utilize [Multi-factor Authentication](/documentation/platform/mfa) in addition to it. + +It is currently possible to use the **Email and Password** auth method to authenticate into the Web Dashboard and Infisical CLI. + +Every **Email and Password** is accompanied by an emergency kit given to users during signup. If the password is lost or forgotten, emergency kit is only way to retrieve the access to your account. It is possible to generate a new emergency kit with the following steps: +1. Open the `Personal Settings` menu. +![open personal settings](../../../images/auth-methods/access-personal-settings.png) +2. Scroll down to the `Emergency Kit` section. +3. Enter your current password and click `Save`. diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx new file mode 100644 index 000000000..6ec5b48b9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -0,0 +1,151 @@ +--- +title: "AWS IAM" +description: "How to dynamically generate AWS IAM Users." +--- + +The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on configured AWS policy. + +## Prerequisite + +Infisical needs an initial AWS IAM user with the required permissions to create sub IAM users. This IAM user will be responsible for managing the lifecycle of new IAM users. + + + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:AttachUserPolicy", + "iam:CreateAccessKey", + "iam:CreateUser", + "iam:DeleteAccessKey", + "iam:DeleteUser", + "iam:DeleteUserPolicy", + "iam:DetachUserPolicy", + "iam:GetUser", + "iam:ListAccessKeys", + "iam:ListAttachedUserPolicies", + "iam:ListGroupsForUser", + "iam:ListUserPolicies", + "iam:PutUserPolicy", + "iam:AddUserToGroup", + "iam:RemoveUserFromGroup" + ], + "Resource": ["*"] + } + ] +} +``` + +To minimize managing user access you can attach a resource in format + +> arn:aws:iam::\:user/\ + +Replace **\** with your AWS account id and **\** with a path to minimize managing user access. + + + +## Set up Dynamic Secrets with AWS IAM + + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + The managing AWS IAM User Access Key + + + + The managing AWS IAM User Secret Key + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + + + The AWS data center region. + + + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + + + + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas + + + + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas + + + + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the lease details and delete the lease ahead of its expiration time. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/dynamic-secrets/cassandra.mdx b/docs/documentation/platform/dynamic-secrets/cassandra.mdx new file mode 100644 index 000000000..78e03e011 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/cassandra.mdx @@ -0,0 +1,129 @@ +--- +title: "Cassandra" +description: "How to dynamically generate Cassandra database users." +--- + +The Infisical Cassandra dynamic secret allows you to generate Cassandra database credentials on demand based on configured role. + +## Prerequisite + +Infisical requires a Cassandra user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. +Ensure the user possesses privileges for creating, dropping, and granting permissions to roles for it to be able to create dynamic secrets. + + +In your Cassandra configuration file `cassandra.yaml`, make sure you have the following settings: + +```yaml +authenticator: PasswordAuthenticator +authorizer: CassandraAuthorizer +``` + + +The above configuration allows user creation and granting permissions. + +## Set up Dynamic Secrets with Cassandra + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + Cassandra Host. You can specify multiple Cassandra hosts by separating them with commas. + + + + Cassandra port + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + + Specify the local data center in Cassandra that you want to use. This choice should align with your Cassandra cluster setup. + + + + Keyspace name where you want to create dynamic secrets. This ensures that the user is limited to that keyspace. + + + + A CA may be required if your cassandra requires it for incoming connections. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png) + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the CQL statement to your needs. This is useful if you want to only give access to a specific key-space(s). + + ![Modify CQL Statements Modal](../../../images/platform/dynamic-secrets/modify-cql-statements.png) + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certficate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the lease details and delete the lease ahead of its expiration time. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx new file mode 100644 index 000000000..c64edab63 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -0,0 +1,115 @@ +--- +title: "MySQL" +description: "Learn how to dynamically generate MySQL Database user passwords." +--- + +The Infisical MySQL dynamic secret allows you to generate MySQL Database credentials on demand based on configured role. + +## Prerequisite +Create a user with the required permission in your SQL instance. This user will be used to create new accounts on-demand. + + +## Set up Dynamic Secrets with MySQL + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + Choose the service you want to generate dynamic secrets for. This must be selected as **MySQL**. + + + + Database host + + + + Database port + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + + Name of the database for which you want to create dynamic secrets + + + + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). + + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + + ![Modify SQL Statements Modal](/images/platform/dynamic-secrets/modify-sql-statement-mysql.png) + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certificate. + + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx new file mode 100644 index 000000000..05b832c4f --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -0,0 +1,115 @@ +--- +title: "Oracle" +description: "Learn how to dynamically generate Oracle Database user passwords." +--- + +The Infisical Oracle dynamic secret allows you to generate Oracle Database credentials on demand based on configured role. + +## Prerequisite +Create a user with the required permission in your SQL instance. This user will be used to create new accounts on-demand. + + +## Set up Dynamic Secrets with Oracle + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + Choose the service you want to generate dynamic secrets for. This must be selected as **Oracle**. + + + + Database host + + + + Database port + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + + Name of the database for which you want to create dynamic secrets + + + + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png) + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certficate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/overview.mdx b/docs/documentation/platform/dynamic-secrets/overview.mdx new file mode 100644 index 000000000..81ab42656 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/overview.mdx @@ -0,0 +1,35 @@ +--- +title: "Dynamic Secrets" +sidebarTitle: "Overview" +description: "Learn how to generate secrets dynamically on-demand." +--- + +## Introduction + +Contrary to static key-value secrets, which require manual input of data into the secure Infisical storage, **dynamic secrets are generated on-demand upon access**. + +**Dynamic secrets are unique to every identity using them**. Such secrets come are generated only at the moment they are retrieved, eliminating the possibility of theft or reuse by another identity. Thanks to Infisical's integrated revocation capabilities, dynamic secrets can be promptly invalidated post-use, significantly reducing their lifespan. + +## Benefits of Dynamic Secrets + +This approach offers several advantages in terms of security and management: + +- **Enhanced Security**: By frequently changing secrets, dynamic secrets minimize the risk associated with secret compromise. Even if an attacker manages to obtain a secret, it would likely be invalid by the time they attempt to use it. + +- **Reduced Secret Lifetime**: The limited validity period of dynamic secrets means that they are less valuable targets for attackers. This inherently reduces the time window during which a secret can be exploited. + +- **Automated Management**: Dynamic secrets enable automated systems to handle the generation, distribution, revocation, and rotation of secrets without human intervention, thus reducing the risk of human error. + +- **Auditing and Traceability**: The generation of dynamic secrets can be tightly controlled and monitored. This allows for detailed auditing of who accessed what secret and when, improving overall security posture and compliance with regulatory standards. + +- **Scalability**: Dynamic secret management systems can scale more effectively to handle a large number of services and applications, as they automate much of the overhead associated with manual secret management. + +Dynamic secrets are particularly useful in environments with stringent security requirements, such as cloud environments, distributed systems, and microservices architectures, where they help to manage database credentials, API keys, tokens, and other types of secrets. + +## Infisical Dynamic Secret Templates + +1. [PostgreSQL](./postgresql) +2. [MySQL](./mysql) +3. [Cassandra](./cassandra) +4. [Oracle](./oracle) +5. [AWS IAM](./aws-iam) diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx new file mode 100644 index 000000000..13adfc750 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -0,0 +1,118 @@ +--- +title: "PostgreSQL" +description: "How to dynamically generate PostgreSQL database users." +--- + +The Infisical PostgreSQL dynamic secret allows you to generate PostgreSQL database credentials on demand based on configured role. + +## Prerequisite + +Create a user with the required permission in your SQL instance. This user will be used to create new accounts on-demand. + + +## Set up Dynamic Secrets with PostgreSQL + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + Choose the service you want to generate dynamic secrets for. This must be selected as **PostgreSQL**. + + + + Database host + + + + Database port + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + + Name of the database for which you want to create dynamic secrets + + + + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal.png) + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements.png) + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certficate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete the lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/folder.mdx b/docs/documentation/platform/folder.mdx index 162cabfb9..a3636a2ea 100644 --- a/docs/documentation/platform/folder.mdx +++ b/docs/documentation/platform/folder.mdx @@ -1,11 +1,12 @@ --- title: "Folders" -description: "Organize your secrets with folders" +description: "Learn how to organize secrets with folders." --- -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) +Infisical Folders enable users to organize secrets using custom structures dependent on the intended use case (also known as **path-based secret storage**). + +It is great for organizing secrets around hierarchies with multiple services or types of secrets involved at large quantities. +Infisical Folders can be infinitely nested to mirror your application architecture – whether it's microservices, monorepos, or any logical grouping that best suits your needs. Consider the following structure for a microservice architecture: @@ -25,9 +26,7 @@ In this example, we store environment variables for each microservice under each 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. -## Folders - -### Managing folders +## 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. diff --git a/docs/documentation/platform/groups.mdx b/docs/documentation/platform/groups.mdx new file mode 100644 index 000000000..18bfe6fa5 --- /dev/null +++ b/docs/documentation/platform/groups.mdx @@ -0,0 +1,67 @@ +--- +title: "User Groups" +description: "Manage user groups in Infisical." +--- + + + User Groups is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +## Concept + +A (user) group is a collection of users that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple users together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization. + +User groups have the following properties: + +- If a group is added to a project under specific role(s), all users in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all users in the group will lose access to the project. +- If a user is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if a user is removed from a group, they will lose access to project(s) that the group has access to. +- If a user was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the user will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the user will not lose access to the project as they were previously added to the project separately. +- A user can be part of multiple groups. If a user is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of. + +## Workflow + +In the following steps, we explore how to create and use user groups to provision user access to projects in Infisical. + + + + To create a group, head to your Organization Settings > Access Control > Groups and press **Create group**. + + ![groups org](/images/platform/groups/groups-org.png) + + When creating a group, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![groups org create](/images/platform/groups/groups-org-create.png) + + Now input a few details for your new group. Here’s some guidance for each field: + - Name (required): A friendly name for the group like `Engineering`. + - Slug (required): A unique identifier for the group like `engineering`. + - Role (required): A role from the Organization Roles tab for the group to assume. The organization role assigned will determine what organization level resources this group can have access to. + + + Next, you'll want to assign users to the group. To do this, press on the users icon on the group and start assigning users to the group. + + ![groups org users](/images/platform/groups/groups-org-users.png) + + In this example, we're assigning **Alan Turing** and **Ada Lovelace** to the group **Engineering**. + + ![groups org assign users](/images/platform/groups/groups-org-users-assign.png) + + + To enable the group to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add group**. + + ![groups project](/images/platform/groups/groups-project.png) + + Next, select the group you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this group can have access to. + + ![groups project add](/images/platform/groups/groups-project-create.png) + + That's it! + + The users of the group now have access to the project under the role you assigned to the group. + + \ No newline at end of file diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx new file mode 100644 index 000000000..505d5a8dd --- /dev/null +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -0,0 +1,311 @@ +--- +title: AWS Auth +description: "Learn how to authenticate with Infisical for EC2 instances, Lambda functions, and other IAM principals." +--- + +**AWS Auth** is an AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to access Infisical. + +## Diagram + +The following sequence digram illustrates the AWS Auth workflow for authenticating AWS IAM principals with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + participant AWS as AWS STS + + Note over Client,Client: Step 1: Sign GetCallerIdentityQuery + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed query details /api/v1/auth/aws-auth/login + + Note over Infis,AWS: Step 3: Query verification + Infis->>AWS: Forward signed GetCallerIdentity query + AWS-->>Infis: Return IAM user/role details + + Note over Infis: Step 4: Identity Property Validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an IAM principal by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed IAM principal ARN) at the `/api/v1/auth/aws-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client IAM principal signs a `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html); this is done using the credentials from the AWS environment where the IAM principal is running. +2. The client sends the signed query data to Infisical including the request method, request body, and request headers at the `/api/v1/auth/aws-auth/login` endpoint. +3. Infisical reconstructs the query and sends it to AWS STS API via the [sts:GetCallerIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html) method for verification and obtains the identity associated with the IAM principal. +4. Infisical checks the identity's properties against set criteria such **Allowed Principal ARNs**. +5. If all is well, Infisical returns a short-lived access token that the IAM principal can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using AWS Auth as they handle the +authentication process including the signed `GetCallerIdentity` query +construction for you. + +Also, note that Infisical needs network-level access to send requests to the AWS STS API +as part of the AWS Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on AWS to +access the Infisical API using the AWS Auth authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **AWS Auth**. + + ![identities create aws auth method](/images/platform/identities/identities-org-create-aws-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Principal ARNs: A comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical. The values should take one of three forms: `arn:aws:iam::123456789012:user/MyUserName`, `arn:aws:iam::123456789012:role/MyRoleName`, or `arn:aws:iam::123456789012:*`. Using a wildcard in this case allows any IAM principal in the account `123456789012` to authenticate with Infisical under the identity. + - Allowed Account IDs: A comma-separated list of trusted AWS account IDs that are allowed to authenticate with Infisical. + - STS Endpoint (default is `https://sts.amazonaws.com/`): The endpoint URL for the AWS STS API. This value should be adjusted based on the AWS region you are operating in (e.g. `https://sts.us-east-1.amazonaws.com/`); refer to the list of regional STS endpoints [here](https://docs.aws.amazon.com/general/latest/gr/sts.html). + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to construct a signed `GetCallerIdentity` query using the [AWS Signature v4 algorithm](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html) and make a request to the `/api/v1/auth/aws-auth/login` endpoint containing the query data + in exchange for an access token. + + We provide a few code examples below of how you can authenticate with Infisical from inside a Lambda function, EC2 instance, etc. and obtain an access token to access the [Infisical API](/api-reference/overview/introduction). + + + + The following query construction is an example of how you can authenticate with Infisical from inside a Lambda function. + + The shown example uses Node.js but you can use other languages supported by AWS Lambda. + + ```javascript + import AWS from "aws-sdk"; + import axios from "axios"; + + export const handler = async (event, context) => { + try { + const region = process.env.AWS_REGION; + AWS.config.update({ region }); + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com`, + }; + + // Create the request + const request = new AWS.HttpRequest(iamRequestURL, region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date + .iso8601(new Date()) + .replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = + Buffer.byteLength(iamRequestBody).toString(); + + // Sign the request + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `${infisicalUrl}/api/v1/auth/aws-auth/login`, + { + identityId, + iamHttpRequestMethod: "POST", + iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), + iamRequestBody: Buffer.from(iamRequestBody).toString("base64"), + iamRequestHeaders: Buffer.from( + JSON.stringify(iamRequestHeaders) + ).toString("base64"), + } + ); + + console.log("result data: ", data); // access token here + } catch (err) { + console.error(err); + } + }; + ```` + + + The following query construction is an example of how you can authenticate with Infisical from inside a EC2 instance. + + The shown example uses Node.js but you can use other language you wish. + + ```javascript + import AWS from "aws-sdk"; + import axios from "axios"; + + const main = async () => { + try { + // obtain region from EC2 instance metadata + const tokenResponse = await axios.put("http://169.254.169.254/latest/api/token", null, { + headers: { + "X-aws-ec2-metadata-token-ttl-seconds": "21600" + } + }); + + const url = "http://169.254.169.254/latest/dynamic/instance-identity/document"; + const response = await axios.get(url, { + headers: { + "X-aws-ec2-metadata-token": tokenResponse.data + } + }); + + const region = response.data.region; + + AWS.config.update({ + region + }); + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new AWS.HttpRequest(new AWS.Endpoint(iamRequestURL), AWS.config.region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date.iso8601(new Date()).replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = Buffer.byteLength(iamRequestBody); + + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post(`${infisicalUrl}/api/v1/auth/aws-auth/login`, { + identityId, + iamHttpRequestMethod: "POST", + iamRequestUrl: Buffer.from(iamRequestURL).toString("base64"), + iamRequestBody: Buffer.from(iamRequestBody).toString("base64"), + iamRequestHeaders: Buffer.from(JSON.stringify(iamRequestHeaders)).toString("base64") + }); + + console.log("result data: ", data); // access token here + } catch (err) { + console.error(err); + } + } + + main(); + ```` + + + The following query construction provides a generic example of how you can construct a signed `GetCallerIdentity` query and obtain the required payload components. + + The shown example uses Node.js but you can use any language you wish. + + ```javascript + const AWS = require("aws-sdk"); + + const region = ""; + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new AWS.HttpRequest(new AWS.Endpoint(iamRequestURL), region); + request.method = "POST"; + request.headers = iamRequestHeaders; + request.headers["X-Amz-Date"] = AWS.util.date.iso8601(new Date()).replace(/[:-]|\.\d{3}/g, ""); + request.body = iamRequestBody; + request.headers["Content-Length"] = Buffer.byteLength(iamRequestBody); + + const signer = new AWS.Signers.V4(request, "sts"); + signer.addAuthorization(AWS.config.credentials, new Date()); + ```` + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/aws-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'iamHttpRequestMethod=...' \ + --data-urlencode 'iamRequestBody=...' \ + --data-urlencode 'iamRequestHeaders=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using AWS Auth as they handle the authentication process including the signed `GetCallerIdentity` query construction for you. + + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx new file mode 100644 index 000000000..c836a946d --- /dev/null +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -0,0 +1,351 @@ +--- +title: GCP Auth +description: "Learn how to authenticate with Infisical for services on Google Cloud Platform" +--- + +**GCP Auth** is a GCP-native authentication method for GCP resources to access Infisical. It consists of two sub-methods/approaches: + +- GCP ID Token Auth: For GCP services including [Compute Engine](https://cloud.google.com/compute/docs/instances/verifying-instance-identity#request_signature), [App Engine standard environment](https://cloud.google.com/appengine/docs/standard/python3/runtime#metadata_server), [App Engine flexible environment](https://cloud.google.com/appengine/docs/flexible/python/runtime#metadata_server), [Cloud Functions](https://cloud.google.com/functions/docs/securing/function-identity#using_the_metadata_server_to_acquire_tokens), [Cloud Run](https://cloud.google.com/run/docs/container-contract#metadata-server), [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#instance_metadata), and [Cloud Build](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#instance_metadata) to authenticate with Infisical. +- GCP IAM Auth: For Google Cloud Platform (GCP) service accounts to authenticate with Infisical. + + + + + ## Diagram + + The following sequence digram illustrates the GCP ID Token Auth workflow for authenticating GCP resources with Infisical. + +```mermaid +sequenceDiagram + participant GCE as GCP Service + participant Infis as Infisical + participant Google as OAuth2 API + + Note over GCE,Google: Step 1: Instance Identity Token Retrieval + GCE->>Google: Request instance identity metadata token + Google-->>GCE: Return JWT token with RS256 signature + + Note over GCE,Infis: Step 2: Identity Token Login Operation + GCE->>Infis: Send JWT token to /api/v1/auth/gcp-auth/login + Infis->>Google: Request OAuth2 certificates + Google-->>Infis: Return certificates + + Note over Infis: Step 3: Identity Token Verification + Note over Infis: Step 4: Identity Property Validation + Infis->>GCE: Return short-lived access token + + Note over GCE,Infis: Step 4: Access Infisical API with Token + GCE->>Infis: Make authenticated requests using the short-lived access token +``` + + ## Concept + +At a high-level, Infisical authenticates a GCP resource by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed GCE instance) at the `/api/v1/auth/gcp-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client running on a GCP service obtains an [ID token](https://cloud.google.com/docs/authentication/get-id-token) constituting the identity for a GCP resource such as a GCE instance or Cloud Function; this is a unique JWT token that includes details about the instance as well as Google's [RS256 signature](https://datatracker.ietf.org/doc/html/rfc7518#section-3.3). +2. The client sends the ID token to Infisical at the `/api/v1/auth/gcp-auth/login` endpoint. +3. Infisical verifies the token against Google's [public OAuth2 certificates](https://www.googleapis.com/oauth2/v3/certs). +4. Infisical checks if the entity behind the ID token is allowed to authenticate with Infisical based on set criteria such as **Allowed Service Account Emails**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using GCP ID Token Auth as they handle the +authentication process including generating the instance ID token for you. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the GCP Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on GCP to +access the Infisical API using the GCP ID Token authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **GCP Auth** and set the **Type** to **GCP ID Token Auth**. + + ![identities create gcp auth method](/images/platform/identities/identities-org-create-gcp-gce-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Service Account Emails: A comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical; this could be something like `test@project.iam.gserviceaccount.com`, `12345-compute@developer.gserviceaccount.com`, etc. + - Allowed Projects: A comma-separated list of trusted GCP projects that the GCE instance must belong to authenticate with Infisical. Note that this validation property will only work for GCE instances. + - Allowed Zones: A comma-separated list of trusted zones that the GCE instances must belong to authenticate with Infisical; this should be the fully-qualified zone name in the format `-`like `us-central1-a`, `us-west1-b`, etc. Note that this validation property will only work for GCE instances. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate an [ID token](https://cloud.google.com/docs/authentication/get-id-token) constituting the identity of the present GCE instance and make a request to the `/api/v1/auth/gcp-auth/login` endpoint containing the token in exchange for an access token. + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + Start by making a request from the GCE instance to obtain the ID token. + For more examples of how to obtain the token in Java, Go, Node.js, etc. refer to the [official documentation](https://cloud.google.com/docs/authentication/get-id-token#curl). + + #### Sample request + + ```bash curl + curl -H "Metadata-Flavor: Google" \ + 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=&format=full' + ``` + + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + Next use send the obtained JWT token along to authenticate with Infisical and obtain an access token. + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + + + + + ## Diagram + + The following sequence digram illustrates the GCP IAM Auth workflow for authenticating GCP IAM service accounts with Infisical. + +```mermaid +sequenceDiagram + participant GCE as Client + participant Infis as Infisical + participant Google as Cloud IAM + + Note over GCE,Google: Step 1: Signed JWT Token Generation + GCE->>Google: Request to generate signed JWT token + Google-->>GCE: Return signed JWT token + + Note over GCE,Infis: Step 2: JWT Token Login Operation + GCE->>Infis: Send signed JWT token to /api/v1/auth/gcp-auth/login + Infis->>Google: Request public key + Google-->>Infis: Return public key + + Note over Infis: Step 3: JWT Token Verification + Note over Infis: Step 4: JWT Property Validation + Infis->>GCE: Return short-lived access token + + Note over GCE,Infis: Step 5: Access Infisical API with Token + GCE->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an IAM service account by verifying its identity and checking that it meets specific requirements (e.g. it is an allowed service account) at the `/api/v1/auth/gcp-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client generates a signed JWT token using the `projects.serviceAccounts.signJwt` [API method](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt); this is done using the service account credentials associated with the client. +2. The client sends the signed JWT token to Infisical at the `/api/v1/auth/gcp-auth/login` endpoint. +3. Infisical verifies the signed JWT token. +4. Infisical checks if the service account behind the JWT token is allowed to authenticate with Infisical based **Allowed Service Account Emails**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using GCP IAM Auth as they handle the +authentication process including generating the signed JWT token. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the GCP Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on GCP to +access the Infisical API using the GCP IAM authentication method. + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **GCP IAM Auth** and set the **Type** to **GCP IAM Auth**. + + ![identities create gcp auth method](/images/platform/identities/identities-org-create-gcp-iam-auth-method.png) + + Here's some more guidance on each field: + + - Allowed Service Account Emails: A comma-separated list of trusted IAM service account emails that are allowed to authenticate with Infisical; this could be something like `test@project.iam.gserviceaccount.com`, `12345-compute@developer.gserviceaccount.com`, etc. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + To access the Infisical API as the identity, you need to generate a signed JWT token using the `projects.serviceAccounts.signJwt` [API method](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signJwt) and make a request to the `/api/v1/auth/gcp-auth/login` endpoint containing the signed JWT token in exchange for an access token. + + + Make sure that the service account has the `iam.serviceAccounts.signJwt` permission or the `roles/iam.serviceAccountTokenCreator` role. + + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + The following code provides a generic example of how you can generate a signed JWT token against the `projects.serviceAccounts.signJwt` API method. + + The shown example uses Node.js and the official [google-auth-library](https://github.com/googleapis/google-auth-library-nodejs#readme) package but you can use any language you wish. + + + ```javascript + const { GoogleAuth } = require("google-auth-library"); + + const auth = new GoogleAuth({ + scopes: "https://www.googleapis.com/auth/cloud-platform", + }); + + const credentials = await auth.getCredentials(); + + const identityId = ""; + + const jwtPayload = { + sub: credentials.client_email, + aud: identityId, + }; + + const { data } = await client.request({ + url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${credentials.client_email}:signJwt`, + method: "POST", + data: { payload: JSON.stringify(jwtPayload) }, + }); + + const jwt = data.signedJwt // send this jwt to Infisical in the next step + ``` + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using GCP IAM Auth as they handle the authentication process including generating the signed JWT token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + + + diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx new file mode 100644 index 000000000..b154f36f6 --- /dev/null +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -0,0 +1,247 @@ +--- +title: Kubernetes Auth +description: "Learn how to authenticate with Infisical in Kubernetes" +--- + +**Kubernetes Auth** is a Kubernetes-native authentication method for applications (e.g. pods) to access Infisical. + +## Diagram + + The following sequence digram illustrates the Kubernetes Auth workflow for authenticating applications running in pods with Infisical. + +```mermaid +sequenceDiagram + participant Pod as Pod + participant Infis as Infisical + participant KubernetesServer as K8s API Server + + Note over Pod: Step 1: Service Account JWT Token Retrieval + + Note over Pod,Infis: Step 2: JWT Token Login Operation + Pod->>Infis: Send JWT token to /api/v1/auth/kubernetes-auth/login + Infis->>KubernetesServer: Forward JWT token for validation + KubernetesServer-->>Infis: Return identity info for JWT + + Note over Infis: Step 3: Identity Property Verification + Infis->>Pod: Return short-lived access token + + Note over Pod,Infis: Step 4: Access Infisical API with Token + Pod->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an application in Kubernetes by verifying its identity and checking that it meets specific requirements (e.g. it is bound to an allowed service account) at the `/api/v1/auth/kubernetes-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The application deployed on Kubernetes retrieves its [service account credential](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#opt-out-of-api-credential-automounting) that is a JWT token at the `/var/run/secrets/kubernetes.io/serviceaccount/token` pod path. +2. The application sends the JWT token to Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint after which Infisical forwards the JWT token to the Kubernetes API Server at the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) for verification and to obtain the service account information associated with the JWT token. Infisical is able to authenticate and interact with the TokenReview API by using a long-lived service account JWT token itself (referred to onward as the token reviewer JWT token). +3. Infisical checks the service account properties against set criteria such **Allowed Service Account Names** and **Allowed Namespaces**. +4. If all is well, Infisical returns a short-lived access token that the application can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using Kubernetes Auth as they handle the +authentication process including service account credential retrieval for you. + + +## Guide + +In the following steps, we explore how to create and use identities for your applications in Kubernetes to access the Infisical API using the Kubernetes Auth authentication method. + + + + 1.1. Start by creating a service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. + + ```yaml infisical-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-auth + namespace: default + + ``` + + ``` + kubectl apply -f infisical-service-account.yaml + ``` + + 1.2. Bind the service account to the `system:auth-delegator` cluster role. As described [here](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#other-component-roles), this role allows delegated authentication and authorization checks, specifically for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/). You can apply the following configuration file: + + ```yaml cluster-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: role-tokenreview-binding + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-auth + namespace: default + ``` + + ``` + kubectl apply -f cluster-role-binding.yaml + ``` + + 1.3. Next, create a long-lived service account JWT token (i.e. the token reviewer JWT token) for the service account using this configuration file for a new `Secret` resource: + + ```yaml service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-auth-token + annotations: + kubernetes.io/service-account.name: "infisical-auth" + ``` + + + ``` + kubectl apply -f service-account-token.yaml + ``` + + 1.4. Link the secret in step 1.3 to the service account in step 1.1: + + ```bash + kubectl patch serviceaccount infisical-auth -p '{"secrets": [{"name": "infisical-auth-token"}]}' -n default + ``` + + 1.5. Finally, retrieve the token reviewer JWT token from the secret. + + ```bash + kubectl get secret infisical-auth-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **Kubernetes Auth**. + + ![identities organization create auth method](/images/platform/identities/identities-org-create-kubernetes-auth-method.png) + + Here's some more guidance on each field: + + - Kubernetes Host / Base Kubernetes API URL: The host string, host:port pair, or URL to the base of the Kubernetes API server. This can usually be obtained by running `kubectl cluster-info`. + - Token Reviewer JWT: A long-lived service account JWT token for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/) to validate other service account JWT tokens submitted by applications/pods. This is the JWT token obtained from step 1.5. + - Allowed Service Account Names: A comma-separated list of trusted service account names that are allowed to authenticate with Infisical. + - Allowed Namespaces: A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical. + - Allowed Audience: An optional audience claim that the service account JWT token must have to authenticate with Infisical. + - CA Certificate: The PEM-encoded CA cert for the Kubernetes API server. This is used by the TLS client for secure communication with the Kubernetes API server. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + + To access the Infisical API as the identity, you should first make sure that the pod running your application is bound to a service account specified in the **Allowed Service Account Names** field of the identity's Kubernetes Auth authentication method configuration in step 2. + + Once bound, the pod will receive automatically mounted service account credentials that is a JWT token at the `/var/run/secrets/kubernetes.io/serviceaccount/token` path. This token should be used to authenticate with Infisical at the `/api/v1/auth/kubernetes-auth/login` endpoint. + + For information on how to configure sevice accounts for pods, refer to the guide [here](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/). + + We provide a code example below of how you might retrieve the JWT token and use it to authenticate with Infisical to gain access to the [Infisical API](/api-reference/overview/introduction). + + The shown example uses Node.js but you can use any other language to retrieve the service account JWT token and use it to authenticate with Infisical. + + ```javascript + const fs = require("fs"); + try { + const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + const jwtToken = fs.readFileSync(tokenPath, "utf8"); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + const { data } = await axios.post( + `{infisicalUrl}/api/v1/auth/kubernetes-auth/login`, + { + identityId, + jwt, + } + ); + + console.log("result data: ", data); // access token here + } catch(err) { + console.error(err); + } + ``` + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using Kubernetes Auth as they handle the authentication process including service account credential retrieval for you. + + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + + If an identity access token exceeds its max ttl, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + + +**FAQ** + + + + There are a few reasons for why this might happen: + - The Kubernetes Auth authentication method configuration is invalid. + - The service account JWT token has expired is malformed or invalid. + - The service account associated with the JWT token does not meet the criteria set forth in the Kubernetes Auth authentication method configuration such as **Allowed Service Account Names** and **Allowed Namespaces**. + + + There are a few reasons for why this might happen: + + - The access token has expired. + - The identity is insufficently permissioned to interact with the resources you wish to access. + - The client access token is being used from an untrusted IP. + + + A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. + + In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. + +A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. +Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation + + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx new file mode 100644 index 000000000..f189a3d20 --- /dev/null +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -0,0 +1,71 @@ +--- +title: Machine Identities +description: "Learn how to use Machine Identities to programmatically interact with Infisical." +--- + +## Concept + +An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). + +Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), [AWS Auth](/documentation/platform/identities/aws-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. + +![organization identities](/images/platform/organization/organization-machine-identities.png) + +Key Features: + +- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. +- Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API. + +## Workflow + +A typical workflow for using identities consists of four steps: + +1. Creating the identity with a name and [role](/documentation/platform/role-based-access-controls) in Organization Access Control > Machine Identities. + This step also involves configuring an authentication method for it. +2. Adding the identity to the project(s) you want it to have access to. +3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. +4. Authenticating subsequent requests with the Infisical API using the short-lived access token. + + + Currently, identities can only be used to make authenticated requests to the Infisical API, SDKs, Terraform, Kubernetes Operator, and Infisical Agent. They do not work with clients such as CLI, Ansible look up plugin, etc. + +Machine Identity support for the rest of the clients is planned to be released in the current quarter. + + + +## Authentication Methods + +To interact with various resources in Infisical, Machine Identities are able to authenticate using: + +- [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. +- [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth): A Kubernetes-native authentication method for applications (e.g. pods) to authenticate with Infisical. +- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. +- [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.) to authenticate with Infisical. + +IAM service accounts and GCE instances to authenticate with Infisical. + +## FAQ + + + + +Yes - Identities can be used with the CLI. + +You can learn more about how to do this in the CLI quickstart [here](/cli/usage). + + + + + A service token is a project-level authentication method that is being deprecated in favor of identities. The service token method will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + Amongst many differences, identities provide broader access over the Infisical API, utilizes the same + permission system as user identities, and come with a significantly larger number of configurable authentication and security features. + + + There are a few reasons for why this might happen: + + - You have insufficient organization permissions to create, read, update, delete identities. + - The identity you are trying to read, update, or delete is more privileged than yourself. + - The role you are trying to create an identity for or update an identity to is more privileged than yours. + + diff --git a/docs/documentation/platform/identities/overview.mdx b/docs/documentation/platform/identities/overview.mdx index d6366211f..18c173766 100644 --- a/docs/documentation/platform/identities/overview.mdx +++ b/docs/documentation/platform/identities/overview.mdx @@ -1,53 +1,26 @@ --- -title: Identities -description: "Programmatically interact with Infisical" +title: "User and Machine Identities" +sidebarTitle: "Overview" +description: "Learn more about identities to interact with resources in Infisical." --- - - Currently, identities can only be used to make authenticated requests to the Infisical API and SDKs. They do not work with clients such as CLI, K8s Operator, Terraform Provider, etc. +To interact with secrets and resource with Infisical, it is important to undrestand the concept of identities. +Identities can be of two types: +- **People** (e.g., developers, platform engineers, administrators) +- **Machines** (e.g., machine entities for managing secrets in CI/CD pipelines, production applications, and more) - We will be releasing compatibility with it across clients in the coming quarter. - +Both people and machines are able to utilize corresponding clients (e.g., Dashboard UI, CLI, SDKs, API, Kubernetes Operator) together with allowed authentication methods (e.g., email & password, SAML SSO, LDAP, OIDC, Universal Auth). -## Concept - -A (machine) identity is an entity that you can create in an Infisical organization to represent a workload or application that requires access to the Infisical API. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). - -Each identity must authenticate with the API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth) to get back a short-lived access token to be used in subsequent requests. - -Key Features: - -- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. -- Auth/Token Configuration: Identities must be configured with auth methods and access token properties to securely interact with the Infisical API. - -## Workflow - -A typical workflow for using identities consists of four steps: - -1. Creating the identity with a name and [role](/documentation/platform/role-based-access-controls) in Organization Access Control > Machine Identities. -This step also involves configuring an authentication method for it such as [Universal Auth](/documentation/platform/identities/universal-auth). -2. Adding the identity to the project(s) you want it to have access to. -3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. -4. Authenticating subsequent requests with the Infisical API using the short-lived access token. - -Check out the following authentication method-specific guides for step-by-step instruction on how to use identities to access Infisical: - -- [Universal Auth](/documentation/platform/identities/universal-auth) - -**FAQ** - - - - A service token is a project-level authentication method that is being phased out in favor of identities. - - Amongst many differences, identities provide broader access over the Infisical API, utilizes the same role-based - permission system used by users, and comes with ample more configurable authentication and security features. - - - There are a few reasons for why this might happen: - - - You have insufficient organization permissions to create, read, update, delete identities. - - The identity you are trying to read, update, or delete is more privileged than yourself. - - The role you are trying to create an identity for or update an identity to is more privileged than yours. - - \ No newline at end of file + + + Learn more about the concept on user identities in Infisical. + + + Understand the concept of machine identities in Infisical. + + diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index e60ae1e33..09ed1cb7b 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -1,21 +1,41 @@ --- title: Universal Auth -description: "Authenticate with Infisical from any platform/environment" +description: "Learn how to authenticate to Infisical from any platform or environment." --- -**Universal Auth** is the most versatile authentication method that can be configured on an identity from any platform/environment to access Infisical. +**Universal Auth** is a platform-agnostic authentication method that can be configured for a [machine identity](/documentation/platform/identities/machine-identities) suitable to authenticate from any platform/environment. -In this method, each identity is given a **Client ID** for which you can generate one or more **Client Secret(s)**. Together, a **Client ID** and **Client Secret** can be exchanged for an access token to authenticate with the Infisical API. +## Diagram -## Properties +The following sequence digram illustrates the Universal Auth workflow for authenticating clients with Infisical. -Universal Auth supports many settings that can be beneficial for tightening your workflow security configuration: +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical -- Support for restrictions on the number of times that the **Client Secret(s)** and access token(s) can be used. -- Support for expiration, so, if specified, the **Client Secret** of the identity will automatically be defunct after a period of time. -- Support for IP allowlisting; this means you can restrict the usage of **Client Secret(s)** and access token to a specific IP or CIDR range. + Note over Client,Infis: Step 1: Login Operation + Client->>Infis: Send Client ID and Client Secret -## Workflow + Note over Infis: Step 2: Client ID and Client Secret validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 3: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +In this method, Infisical authenticates a client by verifying the credentials issued for it at the `/api/v1/auth/universal-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client submits a **Client ID** and **Client Secret** to Infisical at the `/api/v1/auth/universal-auth/login` endpoint. +2. Infisical verifies the credential pair. +3. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + +## Guide In the following steps, we explore how to create and use identities for your workloads and applications to access the Infisical API using the Universal Auth authentication method. @@ -27,18 +47,18 @@ using the Universal Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - + ![identities organization create](/images/platform/identities/identities-org-create.png) Now input a few details for your new identity. Here's some guidance for each field: - Name (required): A friendly name for the identity. - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. - + Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. - + ![identities organization create auth method](/images/platform/identities/identities-org-create-auth-method.png) - + Here's some more guidance on each field: - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. @@ -50,7 +70,7 @@ using the Universal Auth authentication method. Restricting **Client Secret** and access 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. + If you’re using Infisical Cloud, then it is available under the Pro Tier. If you’re self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. @@ -78,8 +98,9 @@ using the Universal Auth authentication method. Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. ![identities project](/images/platform/identities/identities-project.png) - + ![identities project create](/images/platform/identities/identities-project-create.png) + To access the Infisical API as the identity, you should first perform a login operation @@ -88,16 +109,16 @@ using the Universal Auth authentication method. #### Sample request - ``` + ```bash Request curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/login' \ --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'clientSecret=...' \ - --data-urlencode 'clientId=...' + --data-urlencode 'clientId=...' \ + --data-urlencode 'clientSecret=...' ``` - + #### Sample response - - ``` + + ```bash Response { "accessToken": "...", "expiresIn": 7200, @@ -107,7 +128,7 @@ using the Universal Auth authentication method. ``` Next, you can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) - + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; the default TTL is `7200` seconds which can be adjusted. @@ -115,6 +136,7 @@ using the Universal Auth authentication method. If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, a new access token should be obtained by performing another login operation. + @@ -130,11 +152,12 @@ using the Universal Auth authentication method. - The client secret/access token is being used from an untrusted IP. - A identity access token can have a time-to-live (TTL) or incremental lifetime afterwhich it expires. + A identity access token can have a time-to-live (TTL) or incremental lifetime after which it expires. In certain cases, you may want to extend the lifespan of an access token; to do so, you must set a max TTL parameter. - A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. - Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation +A token can be renewed any number of time and each call to renew it will extend the toke life by increments of access token TTL. +Regardless of how frequently an access token is renewed, its lifespan remains bound to the maximum TTL determined at its creation + - \ No newline at end of file + diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx new file mode 100644 index 000000000..bcb470a3e --- /dev/null +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -0,0 +1,22 @@ +--- +title: User Identities +description: "Read more about the concept of user identities in Infisical." +--- + +## Concept + +A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. + +Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). + +Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. + +![organization members](../../../images/platform/organization/organization-members.png) + +## Authentication methods + +To interact with various resources in Infisical, users are able to utilize a number of authentication methods: +- **Email & Password**: the most common authentication method that is used for authentication into Web Dashboard and Infisical CLI. It is recommended to utilize [Multi-factor Authentication](/documentation/platform/mfa) in addition to it. +- **SSO**: Infisical natively integrates with a number of SSO identity providers like [Google](/documentation/platform/sso/google), [GitHub](/documentation/platform/sso/github), and [GitLab](/documentation/platform/sso/gitlab). +- **SAML SSO**: It is also possible to set up SAML SSO integration with identity providers like [Okta](/documentation/platform/sso/okta), [Microsoft Entra ID](/documentation/platform/sso/azure) (formerly known as Azure AD), [JumpCloud](/documentation/platform/sso/jumpcloud), [Google](/documentation/platform/sso/google-saml), and more. +- **LDAP**: For organizations with more advanced needs, Infisical also provides user authentication with [LDAP](/documentation/platform/ldap/overview) that includes a number of LDAP providers. diff --git a/docs/documentation/platform/ip-allowlisting.mdx b/docs/documentation/platform/ip-allowlisting.mdx deleted file mode 100644 index f0844e685..000000000 --- a/docs/documentation/platform/ip-allowlisting.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -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. - -By default, each project is initialized with the `0.0.0.0/0` entry, representing all possible IPv4 addresses. -For enhanced security, we strongly recommend replacing the default entry with your client IPs to tighten access to your secrets. - - - You must be a project `admin` to manage your project's IP whitelist. - - -![IP whitelist](../../images/platform/ip-allowlisting/ip-allowlisting-table.png) - -## Creating a trusted IP entry - -To create a trusted IP entry, head over to the **IP Whitelist** tab in your project. When creating an entry, -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/platform/ip-allowlisting/ip-allowlisting-modal.png) diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx new file mode 100644 index 000000000..5e4253a34 --- /dev/null +++ b/docs/documentation/platform/ldap/general.mdx @@ -0,0 +1,78 @@ +--- +title: "General LDAP" +description: "Learn how to log in to Infisical with LDAP." +--- + + + LDAP is a paid feature. If you're using Infisical Cloud, then it is available + under the **Enterprise Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. + + +You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol) + +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + + + + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. + + Next, input your LDAP server settings. + + ![LDAP configuration](/images/platform/ldap/ldap-config.png) + + Here's some guidance for each field: + + - URL: The LDAP server to connect to such as `ldap://ldap.your-org.com`, `ldaps://ldap.myorg.com:636` (for connection over SSL/TLS), etc. + - Bind DN: The distinguished name of object to bind when performing the user search such as `cn=infisical,ou=Users,dc=acme,dc=com`. + - Bind Pass: The password to use along with `Bind DN` when performing the user search. + - User Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=acme,dc=com`. + - User Search Filter (optional): Template used to construct the LDAP user search filter such as `(uid={{username}})`; use literal `{{username}}` to have the given username used in the search. The default is `(uid={{username}})` which is compatible with several common directory schemas. + - Group Search Base / Group DN (optional): LDAP search base to use for group membership search such as `ou=Groups,dc=acme,dc=com`. + - Group Filter (optional): Template used when constructing the group membership query such as `(&(objectClass=posixGroup)(memberUid={{.Username}}))`. The template can access the following context variables: [`UserDN`, `UserName`]. The default is `(|(memberUid={{.Username}})(member={{.UserDN}})(uniqueMember={{.UserDN}}))` which is compatible with several common directory schemas. + - CA Certificate: The CA certificate to use when verifying the LDAP server certificate. + + + The **Group Search Base / Group DN** and **Group Filter** fields are both required if you wish to sync LDAP groups to Infisical. + + + + + Once you've filled out the LDAP configuration, you can test that part of the configuration is correct by pressing the **Test Connection** button. + + Infisical will attempt to bind to the LDAP server using the provided **URL**, **Bind DN**, and **Bind Pass**. If the operation is successful, then Infisical will display a success message; if not, then Infisical will display an error message and provide a fuller error in the server logs. + + ![LDAP test connection](/images/platform/ldap/ldap-test-connection.png) + + + + In order to sync LDAP groups to Infisical, head to the **LDAP Group Mappings** section to define mappings from LDAP groups to groups in Infisical. + + ![LDAP group mappings section](/images/platform/ldap/ldap-group-mappings-section.png) + + Group mappings ensure that users who log into Infisical via LDAP are added to or removed from the Infisical group(s) that corresponds to the LDAP group(s) they are a member of. + + ![LDAP group mappings table](/images/platform/ldap/ldap-group-mappings-table.png) + + Each group mapping consists of two parts: + - LDAP Group CN: The common name of the LDAP group to map. + - Infisical Group: The Infisical group to map the LDAP group to. + + For example, suppose you want to automatically add a user who is part of the LDAP group with CN `Engineers` to the Infisical group `Engineers` when the user sets up their account with Infisical. + + In this case, you would specify a mapping from the LDAP group with CN `Engineers` to the Infisical group `Engineers`. + Now when the user logs into Infisical via LDAP, Infisical will check the LDAP groups that the user is a part of whilst referencing the group mappings you created earlier. Since the user is a member of the LDAP group with CN `Engineers`, they will be added to the Infisical group `Engineers`. + In the future, if the user is no longer part of the LDAP group with CN `Engineers`, they will be removed from the Infisical group `Engineers` upon their next login. + + Prior to defining any group mappings, ensure that you've created the Infisical groups that you want to map the LDAP groups to. + You can read more about creating (user) groups in Infisical [here](/documentation/platform/groups). + + + + Enabling LDAP allows members in your organization to log into Infisical via LDAP. + ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) + + diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx new file mode 100644 index 000000000..b92b52bb9 --- /dev/null +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -0,0 +1,94 @@ +--- +title: "JumpCloud LDAP" +description: "Learn how to configure JumpCloud LDAP for authenticating into Infisical." +--- + + + LDAP is a paid feature. If you're using Infisical Cloud, then it is available + under the **Enterprise Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase an enterprise license to use + it. + + +Prerequisites: + +- You must have an email address to use LDAP, regardless of whether or not you use that email address to sign in. + + + + In JumpCloud, head to USER MANAGEMENT > Users and create a new user via the **Manual user entry** option. This user + will be used as a privileged service account to facilitate Infisical's ability to bind/search the LDAP directory. + + When creating the user, input their **First Name**, **Last Name**, **Username** (required), **Company Email** (required), and **Description**. + Also, create a password for the user. + + Next, under User Security Settings and Permissions > Permission Settings, check the box next to **Enable as LDAP Bind DN**. + + ![LDAP JumpCloud](/images/platform/ldap/jumpcloud/ldap-jumpcloud-enable-bind-dn.png) + + + In Infisical, head to your Organization Settings > Security > LDAP and select **Manage**. + + Next, input your JumpCloud LDAP server settings. + + ![LDAP configuration](/images/platform/ldap/ldap-config.png) + + Here's some guidance for each field: + + - URL: The LDAP server to connect to (`ldaps://ldap.jumpcloud.com:636`). + - Bind DN: The distinguished name of object to bind when performing the user search (`uid=,ou=Users,o=,dc=jumpcloud,dc=com`). + - Bind Pass: The password to use along with `Bind DN` when performing the user search. + - User Search Base / User DN: Base DN under which to perform user search (`ou=Users,o=,dc=jumpcloud,dc=com`). + - User Search Filter (optional): Template used to construct the LDAP user search filter (`(uid={{username}})`). + - Group Search Base / Group DN (optional): LDAP search base to use for group membership search (`ou=Users,o=,dc=jumpcloud,dc=com`). + - Group Filter (optional): Template used when constructing the group membership query (`(&(objectClass=groupOfNames)(member=uid={{.Username}},ou=Users,o=,dc=jumpcloud,dc=com))`) + - CA Certificate: The CA certificate to use when verifying the LDAP server certificate (instructions to obtain the certificate for JumpCloud [here](https://jumpcloud.com/support/connect-to-ldap-with-tls-ssl)). + + + When filling out the **Bind DN** and **Bind Pass** fields, refer to the username and password of the user created in Step 1. + + Also, for the **Bind DN** and **Search Base / User DN** fields, you'll want to use the organization ID that appears + in your LDAP instance **ORG DN**. + + + + Once you've filled out the LDAP configuration, you can test that part of the configuration is correct by pressing the **Test Connection** button. + + Infisical will attempt to bind to the LDAP server using the provided **URL**, **Bind DN**, and **Bind Pass**. If the operation is successful, then Infisical will display a success message; if not, then Infisical will display an error message and provide a fuller error in the server logs. + + ![LDAP test connection](/images/platform/ldap/ldap-test-connection.png) + + + In order to sync LDAP groups to Infisical, head to the **LDAP Group Mappings** section to define mappings from LDAP groups to groups in Infisical. + + ![LDAP group mappings section](/images/platform/ldap/ldap-group-mappings-section.png) + + Group mappings ensure that users who log into Infisical via LDAP are added to or removed from the Infisical group(s) that corresponds to the LDAP group(s) they are a member of. + + ![LDAP group mappings table](/images/platform/ldap/ldap-group-mappings-table.png) + + Each group mapping consists of two parts: + - LDAP Group CN: The common name of the LDAP group to map. + - Infisical Group: The Infisical group to map the LDAP group to. + + For example, suppose you want to automatically add a user who is part of the LDAP group with CN `Engineers` to the Infisical group `Engineers` when the user sets up their account with Infisical. + + In this case, you would specify a mapping from the LDAP group with CN `Engineers` to the Infisical group `Engineers`. + Now when the user logs into Infisical via LDAP, Infisical will check the LDAP groups that the user is a part of whilst referencing the group mappings you created earlier. Since the user is a member of the LDAP group with CN `Engineers`, they will be added to the Infisical group `Engineers`. + In the future, if the user is no longer part of the LDAP group with CN `Engineers`, they will be removed from the Infisical group `Engineers` upon their next login. + + Prior to defining any group mappings, ensure that you've created the Infisical groups that you want to map the LDAP groups to. + You can read more about creating (user) groups in Infisical [here](/documentation/platform/groups). + + + + + Enabling LDAP allows members in your organization to log into Infisical via LDAP. + ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) + + + + +Resources: + +- [JumpCloud Cloud LDAP Guide](https://jumpcloud.com/support/use-cloud-ldap) diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx new file mode 100644 index 000000000..4d6c75e15 --- /dev/null +++ b/docs/documentation/platform/ldap/overview.mdx @@ -0,0 +1,44 @@ +--- +title: "LDAP Overview" +sidebarTitle: "Overview" +description: "Learn how to authenticate into Infisical with LDAP." +--- + + + LDAP is a paid feature. + +If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, +then you should contact sales@infisical.com to purchase an enterprise license to use it. + + + +You can configure your organization in Infisical to have members authenticate with the platform via [LDAP](https://en.wikipedia.org/wiki/Lightweight_Directory_Access_Protocol). + +To note, configuring LDAP retains the end-to-end encrypted nature of authentication in Infisical because we decouple the authentication and decryption steps; the LDAP server cannot and will not have access to the decryption key needed to decrypt your secrets. + +LDAP providers: + +- Active Directory +- [JumpCloud LDAP](/documentation/platform/ldap/jumpcloud) +- AWS Directory Service +- Foxpass + +Read the general instructions for configuring LDAP [here](/documentation/platform/ldap/general). + +If the documentation for your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. + +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/documentation/platform/mfa.mdx b/docs/documentation/platform/mfa.mdx index 7629401eb..3ca9c5dff 100644 --- a/docs/documentation/platform/mfa.mdx +++ b/docs/documentation/platform/mfa.mdx @@ -1,6 +1,7 @@ --- -title: "MFA" -description: "Secure your Infisical account with MFA" +title: "Multi-factor Authentication" +sidebarTitle: "MFA" +description: "Learn how to secure your Infisical account with MFA." --- MFA requires users to provide multiple forms of identification to access their account. Currently, this means logging in with your password and a 6-digit code sent to your email. diff --git a/docs/documentation/platform/organization.mdx b/docs/documentation/platform/organization.mdx index 82a4058a5..d45bb6d4f 100644 --- a/docs/documentation/platform/organization.mdx +++ b/docs/documentation/platform/organization.mdx @@ -1,9 +1,9 @@ --- -title: "Organization" -description: "How Infisical structures its organizations." +title: "Organizations" +description: "Learn more and understand the concept of Infisical organizations." --- -An organization houses projects and members. +An Infisical organization is a set of [projects](./project) that use the same billing. Organizations allow one or more users to control billing and project permissions for all of the projects belonging to the organization. Each project belongs to an organization. ## Projects @@ -18,21 +18,23 @@ The **Settings** page lets you manage information about your organization includ - 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) + + +- Security and Authentication: A set of setting to enforce or manage [SAML](/documentation/platform/sso/overview), [SCIM](/documentation/platform/scim/overview), [LDAP](/documentation/platform/ldap/overview), and other authentication configurations. + ![organization settings auth](../../images/platform/organization/organization-settings-auth.png) -## Members +## Access Control -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. +The **Access Control** page is where you can manage identities (both people and machines) that are part of your organization. +You can add or remove additional members as well as modify their permissions. -![organization members](../../images/organization-members.png) +![organization members](../../images/platform/organization/organization-members.png) +![organization identities](../../images/platform/organization/organization-machine-identities.png) -In the **Roles** tab, you can manage roles for members within the organization. +In the **Organization Roles** tab, you can edit current or create new custom roles for members within the organization. Note that Role-Based Access Management (RBAC) is partly a paid feature. @@ -41,13 +43,13 @@ In the **Roles** tab, you can manage roles for members within the organization. at the organization and project level for free. 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. + If you're self-hosting Infisical, then you should contact sales@infisical.com to purchase an enterprise license to use it. ![organization roles](../../images/platform/organization/organization-members-roles.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 +As you can see next, Infisical supports granular permissions that you can tailor to each role. +If you need certain members to only be able to access billing details, for example, then you can assign them that permission only. ![organization role permissions](../../images/platform/organization/organization-members-roles-add-perm.png) diff --git a/docs/documentation/platform/pit-recovery.mdx b/docs/documentation/platform/pit-recovery.mdx index 3cad5eff2..448faddbb 100644 --- a/docs/documentation/platform/pit-recovery.mdx +++ b/docs/documentation/platform/pit-recovery.mdx @@ -1,21 +1,21 @@ --- title: "Point-in-Time Recovery" -description: "How to rollback secrets and configs to any commit with Infisical." +description: "Learn how to rollback secrets and configurations to any snapshot with Infisical." --- 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. + If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. -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. +Infisical's point-in-time recovery functionality allows secrets to be rolled back to any point in time for any given [folder](./folder) or [environment](/documentation/platform/project#project-environments). +Every time a secret is updated, a new snapshot is taken – capturing the state of the folder and environment at that point of time. ## Snapshots -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 +Similar to Git, a commit (also known as 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. @@ -28,12 +28,14 @@ This opens up a sidebar from which you can select to view a particular snapshot: ## Rolling back -After pressing on a snapshot from the sidebar, you can view it and even roll back the state +After pressing on a snapshot from the sidebar, you can view it and roll back the state of the folder to that point in time by pressing the **Rollback** button. ![PIT snapshot](../../images/platform/pit-recovery/pit-recovery-rollback.png) Rolling back secrets to a past snapshot creates a 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. + +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. + \ No newline at end of file diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index 1c7bb9787..9df123612 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -1,6 +1,6 @@ --- -title: "PR Workflows" -description: "Infisical PR Workflows allows you to create a set of policies to control secret operations." +title: "Approval Workflows" +description: "Learn how to enable a set of policies to manage changes to sensitive secrets and environments." --- ## Problem at hand @@ -14,15 +14,15 @@ Updating secrets in high-stakes environments (e.g., production) can have a numbe As a wide-spread software engineering practice, developers have to submit their code as a PR that needs to be approved before the code is merged into the main branch. -In a similar way, to solve the above-mentioned issues, Infisical provides a feature called `PR Workflows` for secret management. This is a set of policies and workflows that help advance access controls, compliance procedures, and stability of a particular environment. In other words, **PR Workflows** help you secure, stabilize, and streamline the change of secrets in high-stakes environments. +In a similar way, to solve the above-mentioned issues, Infisical provides a feature called `Approval Workflows` for secret management. This is a set of policies and workflows that help advance access controls, compliance procedures, and stability of a particular environment. In other words, **Approval Workflows** help you secure, stabilize, and streamline the change of secrets in high-stakes environments. ### Setting a policy -First, you would need to create a set of policies for a certain environment. In the example below you can see a generic policy for a production environment. In this case, any user who submits a change to `prod` would first have to get an approval by a predefined user (or multiple users). +First, you would need to create a set of policies for a certain environment. In the example below, a generic policy for a production environment is shown. In this case, any user who submits a change to `prod` would first have to get an approval by a predefined approver (or multiple approvers). ![create secret update policy](../../images/platform/pr-workflows/secret-update-policy.png) -### Example of updating secrets with PR workflows +### Example of updating secrets with Approval workflows When a user submits a change to an enviropnment that is under a particular policy, a corresponsing change request will go to a predefined approver (or multiple approvers). diff --git a/docs/documentation/platform/project-upgrade.mdx b/docs/documentation/platform/project-upgrade.mdx new file mode 100644 index 000000000..8286342d0 --- /dev/null +++ b/docs/documentation/platform/project-upgrade.mdx @@ -0,0 +1,20 @@ +--- +title: "Enhancing Security and Usability: Project Upgrades" +--- + +At Infisical, we're constantly striving to elevate the security and usability standards of our platform to better serve our users. +With this commitment in mind, we're excited to introduce our latest addition, non-E2EE projects, aimed at addressing two significant issues while enhancing how clients interact with Infisical programmatically. + +Previously, users encountered a challenge where projects risked becoming inaccessible if the project creator deleted their account. +Additionally, our API lacked the capability to interact with projects without dealing with complex cryptographic operations. +These obstacles made API driven automation and collaboration a painful experience for a majority of our users. + +To overcome these limitations, our upgrade focuses on disabling end-to-end encryption (E2EE) for projects. +While this may raise eyebrows, it's important to understand that this decision is a strategic move to make Infisical easier to use and interact with. + +But what does this mean for our users? Essentially nothing, there are no changes required on your end. +Rest assured, all sensitive data remains encrypted at rest according to the latest industry standards. +Our commitment to security remains unwavering, and this upgrade is a testament to our dedication to delivering on our promises in both security and usability when it comes to secrets management. + +To increase consistency with existing and future integrations, all projects created on Infisical from now on will have end-to-end encryption (E2EE) disabled by default. +This will not only reduce confusion for end users, but will also make the Infisical API seamless to use. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 06ad3eaa7..bd80d8ae5 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -1,13 +1,21 @@ --- -title: "Project" -description: "How Infisical organizes secrets into projects." +title: "Projects" +description: "Learn more and understand the concept of Infisical projects." --- -A project houses application configuration and secrets for an application. +A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. +Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. + +## Project environments + +For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be +customized depending on the intended use case. + +![project secrets overview](../../images/platform/project/project-environments.png) ## Secrets Overview -The **Secrets Overview** page captures a birds-eye-view of secrets and folders across environments like development, staging, or production. +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. 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) diff --git a/docs/documentation/platform/role-based-access-controls.mdx b/docs/documentation/platform/role-based-access-controls.mdx deleted file mode 100644 index ba774f0d1..000000000 --- a/docs/documentation/platform/role-based-access-controls.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: "Role-based Access Controls" -description: "Infisical's Role-based Access Controls enable creating permissions for user and machine identities to restrict access to resources and the range of actions that can be performed." ---- - -### General access controls - -Access Control Policies provide a highly granular declarative way to grant or forbid access to certain resources and operations in Infisical. In general, access controls can be split up across projects and organizations. - -### Organization-level access controls - -By default, every user in a organization is either an **admin** or a **member**. - -Admins are able to perform every action with the organization, including adding and removing organization members, managing access controls, setting up security settings, and creating new projects. Members, on the other hand, are restricted from removing organization members, modifying billing information, updating access controls, and performing a number of other actions. - -Overall, organization-level access controls are significantly of administrative nature. Access to projects, secrets and other sensitive data is specified on the project level. - -![Org member role](../../images/platform/rbac/org-member-role.png) - -### Project-level access controls - -By default, every user in a project is either a **viewer**, **developer**, or an **admin**. Each of these roles comes with a varying access to different features and resources inside projects. As such, **admins** by default have access to all environments, folders, secrets, and actions within the project. At the same time, **developers** are restricted from performing project control actions, updating PR Workflow policies, managing roles/members, and more. Lastly, **viewer** is the most limiting default role on the project level – it forbids developers to perform any action and rather shows them in the read-only mode. - -### Creating custom roles - -By creating custom roles, you are able to adjust permissions to the needs of your organization. This can be useful for: -- Creating superadmin roles, roles specific to SRE engineers, etc. -- Restricting access of users to specific secrets, folders, and environments. -- Embedding these specific roles into [PR Workflow policies](https://infisical.com/docs/documentation/platform/pr-workflows) - -![project member custom role](../../images/platform/rbac/project-member-custom-role.png) diff --git a/docs/documentation/platform/scim/azure.mdx b/docs/documentation/platform/scim/azure.mdx new file mode 100644 index 000000000..ff46fe4e7 --- /dev/null +++ b/docs/documentation/platform/scim/azure.mdx @@ -0,0 +1,74 @@ +--- +title: "Azure SCIM" +description: "Learn how to configure SCIM provisioning with Azure for Infisical." +--- + + + Azure SCIM provisioning is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. + + +Prerequisites: +- [Configure Azure SAML for Infisical](/documentation/platform/sso/azure) + + + + In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and + press the **Enable SCIM provisioning** toggle to allow Azure to provision/deprovision users for your organization. + + ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) + + Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Azure. + + ![SCIM create token](/images/platform/scim/scim-create-token.png) + + Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Azure. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + In Azure, head to your Enterprise Application > Provisioning > Overview and press **Get started**. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-get-started.png) + + Next, set the following fields: + + - Provisioning Mode: Select **Automatic**. + - Tenant URL: Input **SCIM URL** from Step 1. + - Secret Token: Input the **New SCIM Token** from Step 1. + + Afterwards, press the **Test Connection** button to check that SCIM is configured properly. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-config.png) + + After you hit **Save**, select **Provision Microsoft Entra ID Users** under the **Mappings** subsection. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-select-user-mappings.png) + + Next, adjust the mappings so you have them configured as below: + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-user-mappings.png) + + Finally, head to your Enterprise Application > Provisioning and set the **Provisioning Status** to **On**. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-provisioning-status.png) + + Alternatively, you can go to **Overview** and press **Start provisioning** to have Azure start provisioning/deprovisioning users to Infisical. + + ![SCIM Azure](/images/platform/scim/azure/scim-azure-start-provisioning.png) + + Now Azure can provision/deprovision users to/from your organization in Infisical. + + + +**FAQ** + + + + Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform. + + For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/jumpcloud.mdx b/docs/documentation/platform/scim/jumpcloud.mdx new file mode 100644 index 000000000..ce4542035 --- /dev/null +++ b/docs/documentation/platform/scim/jumpcloud.mdx @@ -0,0 +1,64 @@ +--- +title: "JumpCloud SCIM" +description: "Learn how to configure SCIM provisioning with JumpCloud for Infisical." +--- + + + JumpCloud SCIM provisioning is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. + + +Prerequisites: +- [Configure JumpCloud SAML for Infisical](/documentation/platform/sso/jumpcloud) + + + + In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and + press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users and user groups for your organization. + + ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) + + Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for JumpCloud. + + ![SCIM create token](/images/platform/scim/scim-create-token.png) + + Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in JumpCloud. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + In JumpCloud, head to your Application > Identity Management > Configuration settings and make sure that + **API Type** is set to **SCIM API** and **SCIM Version** is set to **SCIM 2.0**. + + ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png) + + Next, set the following SCIM connection fields: + + - Base URL: Input the **SCIM URL** from Step 1. + - Token Key: Input the **New SCIM Token** from Step 1. + - Test User Email: Input a test user email to be used by JumpCloud for testing the SCIM connection. + + Alos, under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1. + + ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-config.png) + + Next, press **Test Connection** to check that SCIM is configured properly. Finally, press **Activate** + to have JumpCloud start provisioning/deprovisioning users to Infisical. + + ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png) + + Now JumpCloud can provision/deprovision users and user groups to/from your organization in Infisical. + + + +**FAQ** + + + + Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform. + + For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/okta.mdx b/docs/documentation/platform/scim/okta.mdx new file mode 100644 index 000000000..6b0bf6ccf --- /dev/null +++ b/docs/documentation/platform/scim/okta.mdx @@ -0,0 +1,70 @@ +--- +title: "Okta SCIM" +description: "Learn how to configure SCIM provisioning with Okta for Infisical." +--- + + + Okta SCIM provisioning is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. + + +Prerequisites: +- [Configure Okta SAML for Infisical](/documentation/platform/sso/okta) + + + + In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and + press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users and user groups for your organization. + + ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) + + Next, press **Manage SCIM Tokens** and then **Create** to generate a SCIM token for Okta. + + ![SCIM create token](/images/platform/scim/scim-create-token.png) + + Next, copy the **SCIM URL** and **New SCIM Token** to use when configuring SCIM in Okta. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + In Okta, head to your Application > General > App Settings. Next, select **Edit** and check the box + labled **Enable SCIM provisioning**. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-enable-provisioning.png) + + Next, head to Provisioning > Integration and set the following SCIM connection fields: + + - SCIM connector base URL: Input the **SCIM URL** from Step 1. + - Unique identifier field for users: Input `email`. + - Supported provisioning actions: Select **Push New Users**, **Push Profile Updates**, and **Push Groups**. + - Authentication Mode: `HTTP Header`. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-config.png) + + Under HTTP Header > Authorization: Bearer, input the **New SCIM Token** from Step 1. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-auth.png) + + Next, press **Test Connector Configuration** to check that SCIM is configured properly. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-test.png) + + Next, head to Provisioning > To App and check the boxes labeled **Enable** for **Create Users**, **Update User Attributes**, and **Deactivate Users**. + + ![SCIM Okta](/images/platform/scim/okta/scim-okta-app-settings.png) + + Now Okta can provision/deprovision users and user groups to/from your organization in Infisical. + + + +**FAQ** + + + + Infisical's SCIM implmentation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform. + + For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx new file mode 100644 index 000000000..232df95a1 --- /dev/null +++ b/docs/documentation/platform/scim/overview.mdx @@ -0,0 +1,32 @@ +--- +title: "SCIM Overview" +description: "Learn how to provision users for Infisical via SCIM." +--- + + + SCIM provisioning is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. + + +You can configure your organization in Infisical to have users and user groups be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc. + +- Provisioning: The SCIM provider pushes user information to Infisical. If the user exists in Infisical, Infisical sends an email invitation to add them to the relevant organization in Infisical; if not, Infisical initializes a new user and sends them an email invitation to finish setting up their account in the organization. +- Deprovisioning: The SCIM provider instructs Infisical to remove user(s) from an organization in Infisical. + +SCIM providers: + +- [Okta SCIM](/documentation/platform/scim/okta) +- [Azure SCIM](/documentation/platform/scim/azure) +- [JumpCloud SCIM](/documentation/platform/scim/jumpcloud) + +**FAQ** + + + + Infisical's SCIM implementation accounts for retaining the end-to-end encrypted architecture of Infisical because we decouple the **authentication** and **decryption** steps in the platform. + + For this reason, SCIM-provisioned users are initialized but must finish setting up their account when logging in the first time by creating a master encryption/decryption key. With this implementation, IdPs and SCIM providers cannot and will not have access to the decryption key needed to decrypt your secrets. + + \ No newline at end of file diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 329bc4b9f..ee9ea5a7d 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -1,16 +1,17 @@ --- -title: "Secret Referencing / Importing" -description: "How to use reference secrets in Infisical" +title: "Secret Referencing and Importing" +sidebarTitle: "Referencing and Importing" +description: "Learn the fundamentals of secret referencing and importing in Infisical." --- ## Secret Referencing -Infisical's secret referencing feature lets you reference the value of a "base" secret when defining the value of another secret. +Infisical's secret referencing functionality makes it possible to 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. Currently, the secret referencing feature is only supported by the - [Infisical CLI](/cli/overview) and [native integrations](/integrations/overview). + [Infisical CLI](/cli/overview), [native integrations](/integrations/overview) and [Infisical Agent](/infisical-agent/overview). We intend to add support for it to the [Node SDK](https://infisical.com/docs/sdks/languages/node), [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java) this quarter. @@ -18,7 +19,7 @@ This means that updating the value of a base secret propagates directly to other ![secret referencing](../../images/platform/secret-references-imports/secret-reference.png) -Since secret referencing works by reconstructing values back on the client side, the client, be it a user or service token, fetching back secrets +Since secret referencing works by reconstructing values back on the client side, the client, be it a user, service token, or a machine identity, 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`. @@ -43,7 +44,7 @@ Here are a few more helpful examples for how to reference secrets in different c ## Secret Imports -Infisical's secret imports feature lets you import the items of another environment or folder into the current folder context. +Infisical's Secret Imports functionality makes it possible to import the secrets from 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. To add a secret import, press the downward chevron to the right of the **Add Secret** button; then press on the **Add Import** button. diff --git a/docs/documentation/platform/secret-rotation/aws-iam.mdx b/docs/documentation/platform/secret-rotation/aws-iam.mdx new file mode 100644 index 000000000..c524abfbc --- /dev/null +++ b/docs/documentation/platform/secret-rotation/aws-iam.mdx @@ -0,0 +1,143 @@ +--- +title: "AWS IAM User" +description: "Learn how to automatically rotate Access Key Id and Secret Key of AWS IAM Users." +--- + +Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical +at a specified interval or on-demand. + +## Workflow + +The typical workflow for using the AWS IAM User rotation strategy consists of four steps: + +1. Creating the target IAM user whose credentials you wish to rotate. +2. Creating the managing IAM user used by Infisical to rotate the credentials of the target IAM user. +3. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user. +4. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval. + +In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical. + + + + To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user, + then you can skip this step. + + + Next, create another IAM user to be used by Infisical to rotate the credentials of the IAM user in the previous step. + + 2.1. In your AWS console, head to IAM > Access management > Users and press **Create user**. + + ![iam user secret rotation create user](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png) + + 2.2. Next, give the user a username like **infisical-rotation-manager** and press **Next**. + + ![iam user secret rotation username](../../../images/platform/secret-rotation/aws-iam/rotation-manager-username.png) + + 2.3. Next, in the **Set permissions** step, select **Attach policies directly** and then press **Create policy**. + + ![iam user secret rotation create policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png) + + 2.4. Next, in the **Policy editor**, paste the following JSON and press **Next**: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "iam:DeleteAccessKey", + "iam:GetAccessKeyLastUsed", + "iam:CreateAccessKey" + ], + "Resource": "*" + } + ] + } + ``` + + + The IAM policy above uses the wildcard option in Resource: "*". + + You may want to restrict the policy to a specific path, and make any adjustments as necessary, to control access for the managing user in production. + + Read more about this [here](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/). + + + In the **Review and create** step, give the policy a name like **infisical-rotation-manager**, press **Create policy** to finish creating the policy. + + ![iam user secret rotation policy review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png) + + 2.5. Back in the **Set permissions** step from step 2.3, refresh the policy list and search for the policy you just created from step 2.4. + + Select the policy and press **Next**. + + ![iam user secret rotation attach policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png) + + In the **Review and create** step, press **Create user** to finish creating the IAM user. + + ![iam user secret rotation manager user review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png) + + 2.5. Having created the user, head to its Security credentials > Access keys and press **Create access key**. + + Follow the subsequent steps to create the **access key** and **secret access key** credential pair for the user. + + ![iam user secret rotation manager create access key](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png) + + At the end of the flow, copy the **Access key** and **Secret access key** to use when configuring the AWS IAM User rotation strategy back in Infisical next. + + ![iam user secret rotation manager access keys](../../../images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png) + + + 3.1. Back in Infisical, head to the Project > Secrets > Environment and path where you want the rotated AWS IAM credentials to appear and create two placeholder secrets. + + In this example, we'll create two secrets called `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`. + + ![iam user secret rotation secrets](../../../images/platform/secret-rotation/aws-iam/rotation-config-secrets.png) + + 3.2. Next, in the **Secret Rotation** tab, press on the **AWS IAM** tile to configure the AWS IAM User rotation strategy. + + ![iam user secret rotation select aws iam user method](../../../images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png) + + 3.3. Input the configuration details for the AWS IAM User rotation strategy obtained from steps 1 and 2: + + ![iam user secret rotation config 1](../../../images/platform/secret-rotation/aws-iam/rotation-config-1.png) + + Here's some guidance on each field: + + - Manager User Access Key: The managing IAM user's access key from step 2.5. + - Manager User Secret Key: The managing IAM user's secret access key from step 2.5. + - Manager User AWS Region: The [AWS region](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) for Infisical to make requests to such as `us-east-1`. + - IAM Username: The IAM username of the user from step 1. + + Next, specify the output secret mappings configuration for the rotated AWS IAM credentials; this is the secrets whose values will be replaced with new credentials after each rotation. + Here, you can also specify a rotation interval for the credentials to be automatically rotated periodically. + + In this example, we want to map the output of the rotated AWS IAM credentials to the secrets that we created in step 3.1 (i.e. `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`). + + ![iam user secret rotation config 2](../../../images/platform/secret-rotation/aws-iam/rotation-config-2.png) + + Finally, press **Submit** to create the secret rotation strategy. + + + You should now see the AWS IAM User rotation strategy listed in the **Secret Rotation** tab. + + To manually trigger a rotation, you can press the **Rotate** button on the strategy. + Once triggered, the secrets in step 3.1 should be updated with new rotated credential values. + + ![iam user secret rotations aws iam user](../../../images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png) + + + +**FAQ** + + + + There are a few reasons for why this might happen: + + - The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target IAM username is incorrect, etc.). + - The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary permissions to rotate the credentials. + - The target IAM user already has 2 access keys configured in AWS; you should delete one of the access keys to allow for rotation. + + \ No newline at end of file diff --git a/docs/documentation/platform/secret-rotation/mysql.mdx b/docs/documentation/platform/secret-rotation/mysql.mdx index b630e349a..02356de48 100644 --- a/docs/documentation/platform/secret-rotation/mysql.mdx +++ b/docs/documentation/platform/secret-rotation/mysql.mdx @@ -1,37 +1,102 @@ --- title: "MySQL/MariaDB" -description: "Rotated database user password of a MySQL or MariaDB" +description: "Learn how to automatically rotate MySQL/MariaDB user passwords." --- -Infisical will update periodically the provided database user's password. +The Infisical MySQL secret rotation allows you to automatically rotate your MySQL database user's password at a predefined interval. - - At present Infisical do require access to your database. We will soon be released Infisical agent based rotation which would help you rotate without direct database access from Infisical cloud. - -## Working +## Prerequisite -1. User's has to create the two user's for Infisical to rotate and provide them required database access -2. Infisical will connect with your database with admin access -3. If last rotated one was username1, then username2 is chosen to be rotated -5. Update it's password with random value -6. After testing it gets saved to the provided secret mapping +1. Create two users with the required permission in your MySQL instance. We'll refer to them as `user-a` and `user-b`. +2. Create another MySQL user with just the permission to update the passwords of `user-a` and `user-b`. We'll refer to this user as the `admin` user. + +To learn more about MySQL permission system, please visit this [documentation](https://dev.mysql.com/doc/refman/8.0/en/privileges-provided.html). + +## How it works + +1. Infisical connects to your database using the provided `admin` user account. +2. A random value is generated and the password for `user-a` is updated with the new value. +3. The new password is then tested by logging into the database +4. If test is success, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s). +5. The process is then repeated for `user-b` on the next rotation. +6. The cycle repeats until secret rotation is deleted/stopped. ## Rotation Configuration -1. Head over to Secret Rotation configuration page of your project by clicking on side bar `Secret Rotation` -2. Click on `MySQL` -3. Provide the inputs - - Admin Username: DB admin username - - Admin Password: DB admin password - - Host: DB host - - Port: DB port(number) - - Username1: The first username in two to rotate - - Username2: The second username in two to rotate - - CA: Certificate to connect with database(string) -4. Final step - - Select `Environment`, `Secret Path` and `Interval` to rotate the secrets - - Finally select the secrets in your provided board to replace with new secret after each rotation - - Your done and good to go. + + + Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar + + + + + Rotator admin username + -Congrats. You have 10x your MySQL/MariaDB access security. + + Rotator admin password + + + + Database host url + + + + Database port number + + + + The first username of two to rotate - `user-a` + + + + The second username of two to rotate - `user-b` + + + + Optional database certificate to connect with database + + + + + When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + + + The environment where the rotated credentials should be mapped to. + + + + The secret path where the rotated credentials should be mapped to. + + + + What interval should the credentials be rotated in days. + + + + Select an existing secret key where the rotated database username value should be saved to. + + + + Select an existing select key where the rotated database password value should be saved to. + + + + +## FAQ + + + + When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly. + + This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out. + + To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated. + + + The admin account is used by Infisical to update the credentials for `user-a` and `user-b`. + + You don't need to grant all permission for your admin account but rather just the permissions to update both of the user's passwords. + + diff --git a/docs/documentation/platform/secret-rotation/overview.mdx b/docs/documentation/platform/secret-rotation/overview.mdx index 284375401..57ad17e09 100644 --- a/docs/documentation/platform/secret-rotation/overview.mdx +++ b/docs/documentation/platform/secret-rotation/overview.mdx @@ -1,4 +1,8 @@ -# Secret Rotation Overview +--- +title: "Secret Rotation" +sidebarTitle: "Overview" +description: "Learn how to set up automated secret rotation in Infisical." +--- ## Introduction @@ -7,8 +11,8 @@ Rotating secrets helps prevent unauthorized access to systems and sensitive data Rotated secrets may include, but are not limited to: -1. API keys for external services -2. Database credentials for various platforms +1. API keys for external services; +2. Database credentials for various platforms. ## Rotation Process @@ -42,3 +46,4 @@ Finally, the system promotes the future active (pending) secret to be the new cu 1. [SendGrid Integration](./sendgrid) 2. [PostgreSQL/CockroachDB Implementation](./postgres) 3. [MySQL/MariaDB Configuration](./mysql) +4. [AWS IAM User](./aws-iam) diff --git a/docs/documentation/platform/secret-rotation/postgres.mdx b/docs/documentation/platform/secret-rotation/postgres.mdx index f70e8a70b..0a6339e4e 100644 --- a/docs/documentation/platform/secret-rotation/postgres.mdx +++ b/docs/documentation/platform/secret-rotation/postgres.mdx @@ -1,37 +1,104 @@ --- title: "PostgreSQL/CockroachDB" -description: "Rotated database user password of a postgreSQL or cockroach db" +description: "Learn how to automatically rotate PostgreSQL/CockroachDB user passwords." --- -Infisical will update periodically the provided database user's password. +The Infisical Postgres secret rotation allows you to automatically rotate your Postgres database user's password at a predefined interval. - - At present Infisical do require access to your database. We will soon be released Infisical agent based rotation which would help you rotate without direct database access from Infisical cloud. - -## Working +## Prerequisite -1. User's has to create the two user's for Infisical to rotate and provide them required database access -2. Infisical will connect with your database with admin access -3. If last rotated one was username1, then username2 is chosen to be rotated -5. Update it's password with random value -6. After testing it gets saved to the provided secret mapping +1. Create two users with the required permission in your PostgreSQL instance. We'll refer to them as `user-a` and `user-b`. +2. Create another PostgreSQL user with just the permission to update the passwords of `user-a` and `user-b`. We'll refer to this user as the `admin` user. + +To learn more about Postgres permission system, please visit this [documentation](https://www.postgresql.org/docs/9.1/sql-grant.html). + + +## How it works + +1. Infisical connects to your database using the provided `admin` user account. +2. A random value is generated and the password for `user-a` is updated with the new value. +3. The new password is then tested by logging into the database +4. If test is success, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s). +5. The process is then repeated for `user-b` on the next rotation. +6. The cycle repeats until secret rotation is deleted/stopped. ## Rotation Configuration -1. Head over to Secret Rotation configuration page of your project by clicking on side bar `Secret Rotation` -2. Click on `PostgreSQL` -3. Provide the inputs - - Admin Username: DB admin username - - Admin Password: DB admin password - - Host: DB host - - Port: DB port(number) - - Username1: The first username in two to rotate - - Username2: The second username in two to rotate - - CA: Certificate to connect with database(string) -4. Final step - - Select `Environment`, `Secret Path` and `Interval` to rotate the secrets - - Finally select the secrets in your provided board to replace with new secret after each rotation - - Your done and good to go. + + + Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar + + -Congrats. You have 10x your PostgreSQL/CockroachDB access security. + + + Rotator admin username + + + + Rotator admin password + + + + Database host url + + + + Database port number + + + + The first username of two to rotate - `user-a` + + + + The second username of two to rotate - `user-b` + + + + Optional database certificate to connect with database + + + + + When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + + + The environment where the rotated credentials should be mapped to. + + + + The secret path where the rotated credentials should be mapped to. + + + + What interval should the credentials be rotated in days. + + + + Select an existing secret key where the rotated database username value should be saved to. + + + + Select an existing select key where the rotated database password value should be saved to. + + + + +## FAQ + + + + When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly. + + This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out. + + To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated. + + + The admin account is used by Infisical to update the credentials for `user-a` and `user-b`. + + You don't need to grant all permission for your admin account but rather just the permissions to update both of the user's passwords. + + diff --git a/docs/documentation/platform/secret-rotation/sendgrid.mdx b/docs/documentation/platform/secret-rotation/sendgrid.mdx index c4dd2797f..4f27057ad 100644 --- a/docs/documentation/platform/secret-rotation/sendgrid.mdx +++ b/docs/documentation/platform/secret-rotation/sendgrid.mdx @@ -1,31 +1,58 @@ --- title: "Twilio SendGrid" -description: "Rotate Twilio SendGrid API keys" +description: "Find out how to rotate Twilio SendGrid API keys." --- -Twilio SendGrid is a cloud-based email delivery platform that helps businesses send transactional and marketing emails. -It uses an API key to do various operations. Using Infisical you can easily dynamically change the keys. +Eliminate the use of long lived secrets by rotating Twilio SendGrid API keys with Infisical. -## Working +## Prerequisite -1. Infisical will need an admin token of SendGrid to create API keys dynamically. -2. Using the given admin token and scope by user Infisical will create and rotate API keys periodically -3. Under the hood infisical uses [SendGrid API](https://docs.sendgrid.com/api-reference/api-keys/create-api-keys) +You will need a valid SendGrid admin key with the necessary scope to create additional API keys. + +Follow the [SendGrid Docs to create an admin api key](https://docs.sendgrid.com/ui/account-and-settings/api-keys). + +## How it works + +Using the provided admin API key, Infisical will attempt to create child API keys with the specified permissions. +New keys will ge generated every time a rotation occurs. Behind the scenes, Infisical uses the [SendGrid API](https://docs.sendgrid.com/api-reference/api-keys/create-api-keys) to generate new API keys. ## Rotation Configuration -1. Head over to Secret Rotation configuration page of your project by clicking on side bar `Secret Rotation` -2. Click on `Twilio SendGrid Card` -3. Provide the inputs - - Admin API Key: - SendGrid admin key to create lower scoped API keys. - - API Key Scopes - SendGrid generated API Key's scopes. For more info refer [this doc](https://docs.sendgrid.com/api-reference/api-key-permissions/api-key-permissions) + + + Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar + + + + + SendGrid admin API key with permission to create child scoped API keys. + -4. Final step - - Select `Environment`, `Secret Path` and `Interval` to rotate the secrets - - Finally select the secrets in your provided board to replace with new secret after each rotation - - Your done and good to go. - -Now your output mapped secret value will be replaced periodically by SendGrid. + + The permissions that the newly generated API keys will have. To view possible permissions, visit [this documentation](https://docs.sendgrid.com/api-reference/api-key-permissions/api-key-permissions). + Permissions must be entered as a list of strings. + Example: `["user.profile.read", "user.profile.update"]` + + + + When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + + The environment where the rotated credentials should be mapped to. + + + + The secret path where the rotated credentials should be mapped to. + + + + What interval should the credentials be rotated in days. + + + + Select an existing select key where the newly rotated API key will get saved to. + + + + +Now your output mapped secret value will be replaced periodically by SendGrid. diff --git a/docs/documentation/platform/secret-versioning.mdx b/docs/documentation/platform/secret-versioning.mdx index 04c3086cd..6a0efb8b8 100644 --- a/docs/documentation/platform/secret-versioning.mdx +++ b/docs/documentation/platform/secret-versioning.mdx @@ -1,15 +1,20 @@ --- title: "Secret Versioning" -description: "Version secrets and configurations with Infisical" +description: "Learn how secret versioning works in Infisical." --- -Secret versioning records changes made to every secret. +Every time a secret change is performed, a new version of the same secret is created. -![secret versioning](../../images/secret-versioning.png) +Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read) +by specifying the `version` query parameter. + +![secret versioning](../../images/platform/secret-versioning.png) + +The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical. You can copy and paste a secret version value to the "Value" input field "roll back" to that secret version. This creates a new secret version at the top of - the stack. We're releasing the ability to press and automatically roll back to + the stack. We're releasing the ability to automatically roll back to a secret version soon. diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 20137c19b..cbd5a7d0e 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -1,18 +1,18 @@ --- -title: "Azure SAML" -description: "Configure Azure SAML for Infisical SSO" +title: "Entra ID / Azure AD SAML" +description: "Learn how to configure Microsoft Entra ID for Infisical SSO." --- - Azure SAML SSO feature is a paid feature. + Azure SAML SSO 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. + then you should contact sales@infisical.com to purchase an enterprise license to use it. - In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. + In Infisical, head 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. @@ -91,10 +91,22 @@ description: "Configure Azure SAML for Infisical SSO" ![Azure SAML assignment](../../../images/sso/azure/assignment.png) - Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via Azure. + Enabling SAML SSO allows members in your organization to log into Infisical via Azure. ![Azure SAML assignment](../../../images/sso/azure/enable-saml.png) + + Enforcing SAML SSO ensures that members in your organization can only access Infisical + by logging into the organization via Azure. + + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Azure user with Infisical; + Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Azure + prior to enforcing SAML SSO to prevent any unintended issues. + + diff --git a/docs/documentation/platform/sso/github.mdx b/docs/documentation/platform/sso/github.mdx index 87d1b3cf7..53a9b9156 100644 --- a/docs/documentation/platform/sso/github.mdx +++ b/docs/documentation/platform/sso/github.mdx @@ -1,6 +1,6 @@ --- title: "GitHub SSO" -description: "Configure GitHub SSO for Infisical" +description: "Learn how to 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. diff --git a/docs/documentation/platform/sso/gitlab.mdx b/docs/documentation/platform/sso/gitlab.mdx index 446758ae0..d2a537bfa 100644 --- a/docs/documentation/platform/sso/gitlab.mdx +++ b/docs/documentation/platform/sso/gitlab.mdx @@ -1,6 +1,6 @@ --- title: "GitLab SSO" -description: "Configure GitLab SSO for Infisical" +description: "Learn how to 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. diff --git a/docs/documentation/platform/sso/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx new file mode 100644 index 000000000..1897a651a --- /dev/null +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -0,0 +1,95 @@ +--- +title: "Google SAML" +description: "Learn how to configure Google SAML for Infisical SSO." +--- + + + Google 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 sales@infisical.com to purchase an enterprise license to use it. + + + + + In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. + + Next, note the **ACS URL** and **SP Entity ID** to use when configuring the Google SAML application. + + ![Google SAML initial configuration](../../../images/sso/google-saml/init-config.png) + + + 2.1. In your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps and + create a **custom SAML app**. + + ![Google SAML app creation](../../../images/sso/google-saml/create-custom-saml-app.png) + + 2.2. In the **App details** tab, give the application a unique name like Infisical. + + ![Google SAML app naming](../../../images/sso/google-saml/name-custom-saml-app.png) + + 2.3. In the **Google Identity Provider details** tab, copy the **SSO URL**, **Entity ID** and **Certificate**. + + ![Google SAML custom app details](../../../images/sso/google-saml/custom-saml-app-config.png) + + 2.4. Back in Infisical, set **SSO URL**, **IdP Entity ID**, and **Certificate** to the corresponding items from step 2.3. + + ![Google SAML Infisical config](../../../images/sso/google-saml/infisical-config.png) + + 2.5. Back in the Google Admin console, in the **Service provider details** tab, set the **ACS URL** and **Entity ID** to the corresponding items from step 1. + + Also, check the **Signed response** checkbox. + + ![Google SAML app config 2](../../../images/sso/google-saml/custom-saml-app-config-2.png) + + 2.6. In the **Attribute mapping** tab, configure the following map: + + - **First name** -> **firstName** + - **Last name** -> **lastName** + - **Primary email** -> **email** + + ![Google SAML attribute mapping](../../../images/sso/google-saml/attribute-mapping.png) + + Click **Finish**. + + + Back in your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps > your SAML app + and press on **User access**. + + ![Google SAML user access](../../../images/sso/google-saml/user-access.png) + + To assign everyone in your organization to the application, click **On for everyone** or **Off for everyone** and then click **Save**. + + You can also assign an organizational unit or set of users to an application; you can learn more about that [here](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). + + ![Google SAML user access assignment](../../../images/sso/google-saml/user-access-assign.png) + + + Enabling SAML SSO allows members in your organization to log into Infisical via Google Workspace. + + ![Google SAML enable](../../../images/sso/google-saml/enable-saml.png) + + + Enforcing SAML SSO ensures that members in your organization can only access Infisical + by logging into the organization via Google. + + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Google user with Infisical; + Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Google + prior to enforcing SAML SSO to prevent any unintended issues. + + + + + + If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to + set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: + + - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. + - `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) + + +References: +- Google's guide to [set up your own custom SAML app](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app). \ No newline at end of file diff --git a/docs/documentation/platform/sso/google.mdx b/docs/documentation/platform/sso/google.mdx index cf35dcb68..36ee511d1 100644 --- a/docs/documentation/platform/sso/google.mdx +++ b/docs/documentation/platform/sso/google.mdx @@ -1,6 +1,6 @@ --- title: "Google SSO" -description: "Configure Google SSO for Infisical" +description: "Learn how to 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. diff --git a/docs/documentation/platform/sso/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index e9ffb4f5e..781f5224a 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -1,18 +1,18 @@ --- title: "JumpCloud SAML" -description: "Configure JumpCloud SAML for Infisical SSO" +description: "Learn how to configure JumpCloud SAML for Infisical SSO." --- - JumpCloud SAML SSO feature is a paid feature. + JumpCloud SAML SSO 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. + then you should contact sales@infisical.com to purchase an enterprise license to use it. - In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. + In Infisical, head 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. @@ -71,10 +71,22 @@ description: "Configure JumpCloud SAML for Infisical SSO" ![JumpCloud SAML assignment](../../../images/sso/jumpcloud/assignment.png) - Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via JumpCloud. + Enabling SAML SSO allows members in your organization to log into Infisical via JumpCloud. ![JumpCloud SAML assignment](../../../images/sso/jumpcloud/enable-saml.png) + + Enforcing SAML SSO ensures that members in your organization can only access Infisical + by logging into the organization via JumpCloud. + + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one JumpCloud user with Infisical; + Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in JumpCloud + prior to enforcing SAML SSO to prevent any unintended issues. + + diff --git a/docs/documentation/platform/sso/keycloak-saml.mdx b/docs/documentation/platform/sso/keycloak-saml.mdx new file mode 100644 index 000000000..981739711 --- /dev/null +++ b/docs/documentation/platform/sso/keycloak-saml.mdx @@ -0,0 +1,139 @@ +--- +title: "Keycloak SAML" +description: "Learn how to configure Keycloak SAML for Infisical SSO." +--- + + + Keycloak SAML SSO 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 sales@infisical.com to purchase an enterprise license to use it. + + + + + In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Manage**. + + ![Keycloak SAML organization security section](../../../images/sso/keycloak/org-security-section.png) + + Next, copy the **Valid redirect URI** and **SP Entity ID** to use when configuring the Keycloak SAML application. + + ![Keycloak SAML initial configuration](../../../images/sso/keycloak/init-config.png) + + + 2.1. In your realm, navigate to the **Clients** tab and click **Create client** to create a new client application. + + ![SAML keycloak list of clients](../../../images/sso/keycloak/clients-list.png) + + + You don’t typically need to make a realm dedicated to Infisical. We recommend adding Infisical as a client to your primary realm. + + + In the General Settings step, set **Client type** to **SAML**, the **Client ID** field to `https://app.infisical.com`, and the **Name** field to a friendly name like **Infisical**. + + ![SAML keycloak create client general settings](../../../images/sso/keycloak/create-client-general-settings.png) + + + If you’re self-hosting Infisical, then you will want to replace https://app.infisical.com with your own domain. + + + Next, in the Login Settings step, set both the **Home URL** field and **Valid redirect URIs** field to the **Valid redirect URI** from step 1 and press **Save**. + + ![SAML keycloak create client login settings](../../../images/sso/keycloak/create-client-login-settings.png) + + 2.2. Once you've created the client, under its **Settings** tab, make sure to set the following values: + + - Under **SAML Capabilities**: + - Name ID format: email (or username). + - Force name ID format: On. + - Force POST binding: On. + - Include AuthnStatement: On. + - Under **Signature and Encryption**: + - Sign documents: On. + - Sign assertions: On. + - Signature algorithm: RSA_SHA256. + + ![SAML keycloak client SAML capabilities](../../../images/sso/keycloak/client-saml-capabilities.png) + + ![SAML keycloak client signature encryption](../../../images/sso/keycloak/client-signature-encryption.png) + + 2.3. Next, navigate to the **Client scopes** tab select the client's dedicated scope. + + ![SAML keycloak client scopes list](../../../images/sso/keycloak/client-scopes-list.png) + + Next click **Add predefined mapper**. + + ![SAML keycloak client mappers empty](../../../images/sso/keycloak/client-mappers-empty.png) + + Select the **X500 email**, **X500 givenName**, and **X500 surname** attributes and click **Add**. + + ![SAML keycloak client mappers predefined](../../../images/sso/keycloak/client-mappers-predefined.png) + + Now click on the **X500 email** mapper and set the **SAML Attribute Name** field to **email**. + + ![SAML keycloak client mappers email](../../../images/sso/keycloak/client-mappers-email.png) + + Repeat the same for **X500 givenName** and **X500 surname** mappers, setting the **SAML Attribute Name** field to **firstName** and **lastName** respectively. + + Next, back in the client scope's **Mappers**, click **Add mapper** and select **by configuration**. + + ![SAML keycloak client mappers by configuration](../../../images/sso/keycloak/client-mappers-by-configuration.png) + + Select **User Property**. + + ![SAML keycloak client mappers user property](../../../images/sso/keycloak/client-mappers-user-property.png) + + Set the the **Name** field to **Username**, the **Property** field to **username**, and the **SAML Attribtue Name** to **username**. + + ![SAML keycloak client mappers username](../../../images/sso/keycloak/client-mappers-username.png) + + Repeat the same for the `id` attribute, setting the **Name** field to **ID**, the **Property** field to **id**, and the **SAML Attribute Name** to **id**. + + ![SAML keycloak client mappers id](../../../images/sso/keycloak/client-mappers-id.png) + + Once you've completed the above steps, the list of mappers should look like this: + + ![SAML keycloak client mappers completed](../../../images/sso/keycloak/client-mappers-completed.png) + + + Back in Keycloak, navigate to Configure > Realm settings > General tab > Endpoints > SAML 2.0 Identity Provider Metadata and copy the IDP URL. This should appear in various places and take the form: `https://keycloak-mysite.com/realms/myrealm/protocol/saml`. + + ![SAML keycloak realm SAML metadata](../../../images/sso/keycloak/realm-saml-metadata.png) + + Also, in the **Keys** tab, locate the RS256 key and copy the certificate to use when finishing configuring Keycloak SAML in Infisical. + + ![SAML keycloak realm settings keys](../../../images/sso/keycloak/realm-settings-keys.png) + + + Back in Infisical, set **IDP URL** and **Certificate** to the items from step 3. Also, set the **Client ID** to the `https://app.infisical.com`. + + Once you've done that, press **Update** to complete the required configuration. + + ![SAML Okta paste values into Infisical](../../../images/sso/keycloak/idp-values.png) + + + Enabling SAML SSO allows members in your organization to log into Infisical via Keycloak. + + ![SAML keycloak enable SAML](../../../images/sso/keycloak/enable-saml.png) + + + Enforcing SAML SSO ensures that members in your organization can only access Infisical + by logging into the organization via Keycloak. + + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Keycloak user with Infisical; + Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Keycloak + prior to enforcing SAML SSO to prevent any unintended issues. + + + + + + If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to + set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work: + + - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. + - `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/okta.mdx b/docs/documentation/platform/sso/okta.mdx index c07aca9ac..b0ac046d0 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -1,18 +1,18 @@ --- title: "Okta SAML" -description: "Configure Okta SAML 2.0 for Infisical SSO" +description: "Learn how to 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. + Okta SAML SSO 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 sales@infisical.com to purchase an enterprise license to use + it. - In Infisical, head over to your organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**. + In Infisical, head 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. ![Okta SAML initial configuration](../../../images/sso/okta/init-config.png) @@ -22,24 +22,24 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" button. ![SAML Okta create app integration](../../../images/sso/okta/create-app-integration.png) - + In the Create a New Application Integration dialog, select the **SAML 2.0** radio button: ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/create-saml-app.png) - + On the General Settings screen, give the application a unique name like Infisical and select **Next**. - + ![SAML Okta create SAML 2.0 integration](../../../images/sso/okta/general-settings.png) - + On the Configure SAML screen, set the **Single sign-on URL** and **Audience URI (SP Entity ID)** from step 1. ![SAML Okta configure IdP fields](../../../images/sso/okta/configure-saml.png) - + If you're self-hosting Infisical, then you will want to replace `https://app.infisical.com` with your own domain. - + Also on the Configure SAML screen, configure the **Attribute Statements** to map: - `id -> user.id`, @@ -50,6 +50,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" ![SAML Okta attribute statements](../../../images/sso/okta/attribute-statements.png) Once configured, select **Next** to proceed to the Feedback screen and select **Finish**. + Once your application is created, select the **Sign On** tab for the app and select the **View Setup Instructions** button located on the right side of the screen: @@ -59,12 +60,14 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" Copy the **Identity Provider Single Sign-On URL**, the **Identity Provider Issuer**, and the **X.509 Certificate** to use when finishing configuring Okta SAML in Infisical. ![SAML Okta IdP values](../../../images/sso/okta/idp-values.png) + Back in Infisical, set **Identity Provider Single Sign-On URL**, **Identity Provider Issuer**, and **Certificate** to **X.509 Certificate** from step 3. Once you've done that, press **Update** to complete the required configuration. ![SAML Okta paste values into Infisical](../../../images/sso/okta/idp-values-2.png) + Back in Okta, navigate to the **Assignments** tab and select **Assign**. You can assign access to the application on a user-by-user basis using the Assign to People option, or in-bulk using the Assign to Groups option. @@ -72,18 +75,34 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" ![SAML Okta assignment](../../../images/sso/okta/assignment.png) At this point, you have configured everything you need within the context of the Okta Admin Portal. + - Enabling SAML SSO enforces all members in your organization to only be able to log into Infisical via Okta. + Enabling SAML SSO allows members in your organization to log into Infisical via Okta. + + ![SAML Okta enable SAML](../../../images/sso/okta/enable-saml.png) + + + + Enforcing SAML SSO ensures that members in your organization can only access Infisical + by logging into the organization via Okta. + + To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Okta user with Infisical; + Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO. + + + We recommend ensuring that your account is provisioned the application in Okta + prior to enforcing SAML SSO to prevent any unintended issues. + - ![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 `AUTH_SECRET` and `SITE_URL` environment variable for it to work: - - - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`. - - `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 + If you're configuring SAML SSO on a self-hosted instance of Infisical, make + sure to set the `AUTH_SECRET` and `SITE_URL` environment variable for it to + work: - `AUTH_SECRET`: A secret key used for signing and verifying JWT. This + can be a random 32-byte base64 string generated with `openssl rand -base64 + 32`. - `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/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 8f4b3bb0f..9ab0acc3a 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -1,20 +1,26 @@ --- title: "SSO Overview" -description: "Log in to Infisical via SSO protocols" +sidebarTitle: "Overview" +description: "Learn how to log in to Infisical via SSO protocols." --- - - 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 support industry-leading providers including - Okta, Azure AD, and JumpCloud; with any questions, please reach out to [sales@infisical.com](mailto:sales@infisical.com). - + + 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 support industry-leading providers including Okta, Azure AD, and JumpCloud; + with any questions, please reach out to team@infisical.com. + You can configure your organization in Infisical to have members authenticate with the platform via protocols like [SAML 2.0](https://en.wikipedia.org/wiki/SAML_2.0). -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. +To note, Infisical's SSO implementation decouples the **authentication** and **decryption** steps – which implies that no +Identity Provider can have access to the decryption key needed to decrypt your secrets (this also implies that Infisical requires entering the user's Master Password on top of authenticating with SSO). + +## Identity providers + +Infisical supports these and many other identity providers: - [Google SSO](/documentation/platform/sso/google) - [GitHub SSO](/documentation/platform/sso/github) @@ -22,3 +28,23 @@ your IdP cannot and will not have access to the decryption key needed to decrypt - [Okta SAML](/documentation/platform/sso/okta) - [Azure SAML](/documentation/platform/sso/azure) - [JumpCloud SAML](/documentation/platform/sso/jumpcloud) +- [Keycloak SAML](/documentation/platform/sso/keycloak-saml) +- [Google SAML](/documentation/platform/sso/google-saml) + +If your required identity provider is not shown in the list above, please reach out to [team@infisical.com](mailto:team@infisical.com) for assistance. + +## FAQ + + + + By default, Infisical Cloud is configured to not trust emails from external + identity providers to prevent any malicious account takeover attempts via + email spoofing. Accordingly, Infisical creates a new user for anyone provisioned + through an external identity provider and requires an additional email + verification step upon their first login. + + If you're running a self-hosted instance of Infisical and would like it to trust emails from external identity providers, + you can configure this behavior in the admin panel. + + + diff --git a/docs/documentation/platform/token.mdx b/docs/documentation/platform/token.mdx index 9e304de3d..13bd4cd19 100644 --- a/docs/documentation/platform/token.mdx +++ b/docs/documentation/platform/token.mdx @@ -1,8 +1,15 @@ --- -title: "Service token" -description: "Infisical service tokens allows you to programmatically interact with Infisical" +title: "Service Token" +description: "Infisical service tokens allow users to programmatically interact with Infisical." --- + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + + 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. @@ -17,8 +24,8 @@ Service Token (ST) is the current widely-used authentication method for managing 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. + 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. @@ -35,7 +42,7 @@ 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). + 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. ![token add](../../images/project-token-old-permissions.png) @@ -43,25 +50,32 @@ Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/b 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. + + For a deeper understanding of service tokens, it is recommended to read [this + guide](https://infisical.com/docs/internals/service-tokens). + + **FAQ** - - There are a few reasons for why this might happen: + + There are a few reasons for why this might happen: - - The service token has expired. - - The service token is insufficiently 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. + - The service token has expired. + - The service token is insufficiently 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. - 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/`. + + + 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. - 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/`. + 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/`. - 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/`. - + 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/`. + + diff --git a/docs/documentation/platform/webhooks.mdx b/docs/documentation/platform/webhooks.mdx index e0de7be05..22277dd8c 100644 --- a/docs/documentation/platform/webhooks.mdx +++ b/docs/documentation/platform/webhooks.mdx @@ -1,6 +1,6 @@ --- title: "Webhooks" -description: "How Infisical webhooks works?" +description: "Learn the fundamentals of Infisical webhooks." --- Webhooks can be used to trigger changes to your integrations when secrets are modified, providing smooth integration with other third-party applications. diff --git a/docs/images/agent/infisical-agent-diagram.png b/docs/images/agent/infisical-agent-diagram.png index 27356ba11..5eab132f7 100644 Binary files a/docs/images/agent/infisical-agent-diagram.png and b/docs/images/agent/infisical-agent-diagram.png differ diff --git a/docs/images/auth-methods/access-personal-settings.png b/docs/images/auth-methods/access-personal-settings.png new file mode 100644 index 000000000..a5e1989c1 Binary files /dev/null and b/docs/images/auth-methods/access-personal-settings.png differ diff --git a/docs/images/docker-swarm-secrets-complete.png b/docs/images/docker-swarm-secrets-complete.png new file mode 100644 index 000000000..28b439445 Binary files /dev/null and b/docs/images/docker-swarm-secrets-complete.png differ diff --git a/docs/images/guides/agent-with-ecs/access-token-deposit.png b/docs/images/guides/agent-with-ecs/access-token-deposit.png new file mode 100644 index 000000000..8bf3b0450 Binary files /dev/null and b/docs/images/guides/agent-with-ecs/access-token-deposit.png differ diff --git a/docs/images/guides/agent-with-ecs/ecs-diagram.png b/docs/images/guides/agent-with-ecs/ecs-diagram.png new file mode 100644 index 000000000..ee159017c Binary files /dev/null and b/docs/images/guides/agent-with-ecs/ecs-diagram.png differ diff --git a/docs/images/guides/agent-with-ecs/file_browser_main.png b/docs/images/guides/agent-with-ecs/file_browser_main.png new file mode 100644 index 000000000..402583aa8 Binary files /dev/null and b/docs/images/guides/agent-with-ecs/file_browser_main.png differ diff --git a/docs/images/guides/agent-with-ecs/filebrowser_afterlogin.png b/docs/images/guides/agent-with-ecs/filebrowser_afterlogin.png new file mode 100644 index 000000000..b3caab8d3 Binary files /dev/null and b/docs/images/guides/agent-with-ecs/filebrowser_afterlogin.png differ diff --git a/docs/images/guides/agent-with-ecs/secrets-deposit.png b/docs/images/guides/agent-with-ecs/secrets-deposit.png new file mode 100644 index 000000000..19b3eedd7 Binary files /dev/null and b/docs/images/guides/agent-with-ecs/secrets-deposit.png differ diff --git a/docs/images/guides/microsoft-power-apps/custom-connector.png b/docs/images/guides/microsoft-power-apps/custom-connector.png new file mode 100644 index 000000000..e74fe61f1 Binary files /dev/null and b/docs/images/guides/microsoft-power-apps/custom-connector.png differ diff --git a/docs/images/guides/microsoft-power-apps/function-app.png b/docs/images/guides/microsoft-power-apps/function-app.png new file mode 100644 index 000000000..9b92cdc97 Binary files /dev/null and b/docs/images/guides/microsoft-power-apps/function-app.png differ diff --git a/docs/images/integrations/aws/integrations-amplify-app-id.png b/docs/images/integrations/aws/integrations-amplify-app-id.png new file mode 100644 index 000000000..a89fa3eaf Binary files /dev/null and b/docs/images/integrations/aws/integrations-amplify-app-id.png differ diff --git a/docs/images/integrations/aws/integrations-amplify-env-console-identity.png b/docs/images/integrations/aws/integrations-amplify-env-console-identity.png new file mode 100644 index 000000000..f3e975ad2 Binary files /dev/null and b/docs/images/integrations/aws/integrations-amplify-env-console-identity.png differ diff --git a/docs/images/integrations/aws/integrations-amplify-env-console.png b/docs/images/integrations/aws/integrations-amplify-env-console.png new file mode 100644 index 000000000..10791acf7 Binary files /dev/null and b/docs/images/integrations/aws/integrations-amplify-env-console.png differ diff --git a/docs/images/integrations/aws/integrations-aws-parameter-store-auth.png b/docs/images/integrations/aws/integrations-aws-parameter-store-auth.png index 7891e636b..14d54c9a6 100644 Binary files a/docs/images/integrations/aws/integrations-aws-parameter-store-auth.png and b/docs/images/integrations/aws/integrations-aws-parameter-store-auth.png differ diff --git a/docs/images/integrations/aws/integrations-aws-parameter-store-create.png b/docs/images/integrations/aws/integrations-aws-parameter-store-create.png index 7c51eb2b2..a168925d6 100644 Binary files a/docs/images/integrations/aws/integrations-aws-parameter-store-create.png and b/docs/images/integrations/aws/integrations-aws-parameter-store-create.png differ diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-auth.png b/docs/images/integrations/aws/integrations-aws-secret-manager-auth.png index 4dcaa04dd..cc17097e1 100644 Binary files a/docs/images/integrations/aws/integrations-aws-secret-manager-auth.png and b/docs/images/integrations/aws/integrations-aws-secret-manager-auth.png differ diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png index 703fb6101..e43cfbf9e 100644 Binary files a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png and b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png differ diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-options.png b/docs/images/integrations/aws/integrations-aws-secret-manager-options.png new file mode 100644 index 000000000..f8492cdfa Binary files /dev/null and b/docs/images/integrations/aws/integrations-aws-secret-manager-options.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-env.png b/docs/images/integrations/github/integrations-github-scope-env.png new file mode 100644 index 000000000..e38874bd3 Binary files /dev/null and b/docs/images/integrations/github/integrations-github-scope-env.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-org.png b/docs/images/integrations/github/integrations-github-scope-org.png new file mode 100644 index 000000000..d5ef76a2b Binary files /dev/null and b/docs/images/integrations/github/integrations-github-scope-org.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-repo.png b/docs/images/integrations/github/integrations-github-scope-repo.png new file mode 100644 index 000000000..353527c78 Binary files /dev/null and b/docs/images/integrations/github/integrations-github-scope-repo.png differ diff --git a/docs/images/integrations/github/integrations-github.png b/docs/images/integrations/github/integrations-github.png index dccc42c0d..38550b466 100644 Binary files a/docs/images/integrations/github/integrations-github.png and b/docs/images/integrations/github/integrations-github.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku-create.png b/docs/images/integrations/heroku/integrations-heroku-create.png index a2a8d4e76..452dc5159 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku-create.png and b/docs/images/integrations/heroku/integrations-heroku-create.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku.png b/docs/images/integrations/heroku/integrations-heroku.png index 31c8284cd..ead332447 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku.png and b/docs/images/integrations/heroku/integrations-heroku.png differ diff --git a/docs/images/integrations/jenkins/jenkins_10_identity.png b/docs/images/integrations/jenkins/jenkins_10_identity.png new file mode 100644 index 000000000..e2a578860 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_10_identity.png differ diff --git a/docs/images/integrations/jenkins/jenkins_11.png b/docs/images/integrations/jenkins/jenkins_11.png index 577cca4c5..61f1e364f 100644 Binary files a/docs/images/integrations/jenkins/jenkins_11.png and b/docs/images/integrations/jenkins/jenkins_11.png differ diff --git a/docs/images/integrations/jenkins/jenkins_11_identity.png b/docs/images/integrations/jenkins/jenkins_11_identity.png new file mode 100644 index 000000000..34325277a Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_11_identity.png differ diff --git a/docs/images/integrations/jenkins/jenkins_4.png b/docs/images/integrations/jenkins/jenkins_4.png index 1103da236..9e7360e97 100644 Binary files a/docs/images/integrations/jenkins/jenkins_4.png and b/docs/images/integrations/jenkins/jenkins_4.png differ diff --git a/docs/images/integrations/jenkins/jenkins_4_identity_id.png b/docs/images/integrations/jenkins/jenkins_4_identity_id.png new file mode 100644 index 000000000..b7f4eb116 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_4_identity_id.png differ diff --git a/docs/images/integrations/jenkins/jenkins_4_identity_secret.png b/docs/images/integrations/jenkins/jenkins_4_identity_secret.png new file mode 100644 index 000000000..fe6acea77 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_4_identity_secret.png differ diff --git a/docs/images/integrations/jenkins/jenkins_5.png b/docs/images/integrations/jenkins/jenkins_5.png index 824cacfff..9491df302 100644 Binary files a/docs/images/integrations/jenkins/jenkins_5.png and b/docs/images/integrations/jenkins/jenkins_5.png differ diff --git a/docs/images/integrations/jenkins/jenkins_5_identity.png b/docs/images/integrations/jenkins/jenkins_5_identity.png new file mode 100644 index 000000000..22eb83adc Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_5_identity.png differ diff --git a/docs/images/integrations/jenkins/jenkins_9.png b/docs/images/integrations/jenkins/jenkins_9.png index 109d7305b..81bd4f2ba 100644 Binary files a/docs/images/integrations/jenkins/jenkins_9.png and b/docs/images/integrations/jenkins/jenkins_9.png differ diff --git a/docs/images/integrations/jenkins/jenkins_9_identity.png b/docs/images/integrations/jenkins/jenkins_9_identity.png new file mode 100644 index 000000000..b3cbdfc76 Binary files /dev/null and b/docs/images/integrations/jenkins/jenkins_9_identity.png differ diff --git a/docs/images/integrations/jenkins/plugin/add-infisical-secret.png b/docs/images/integrations/jenkins/plugin/add-infisical-secret.png new file mode 100644 index 000000000..6a9bc56a1 Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/add-infisical-secret.png differ diff --git a/docs/images/integrations/jenkins/plugin/install-plugin.png b/docs/images/integrations/jenkins/plugin/install-plugin.png new file mode 100644 index 000000000..c08e618bd Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/install-plugin.png differ diff --git a/docs/images/integrations/jenkins/plugin/pipeline-configuration.png b/docs/images/integrations/jenkins/plugin/pipeline-configuration.png new file mode 100644 index 000000000..9880164ea Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/pipeline-configuration.png differ diff --git a/docs/images/integrations/jenkins/plugin/pipeline-syntax-highlight.png b/docs/images/integrations/jenkins/plugin/pipeline-syntax-highlight.png new file mode 100644 index 000000000..a38d64be8 Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/pipeline-syntax-highlight.png differ diff --git a/docs/images/integrations/jenkins/plugin/plugin-checked.png b/docs/images/integrations/jenkins/plugin/plugin-checked.png new file mode 100644 index 000000000..1fd92127f Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/plugin-checked.png differ diff --git a/docs/images/integrations/jenkins/plugin/universal-auth-credential.png b/docs/images/integrations/jenkins/plugin/universal-auth-credential.png new file mode 100644 index 000000000..26c2bc307 Binary files /dev/null and b/docs/images/integrations/jenkins/plugin/universal-auth-credential.png differ diff --git a/docs/images/organization-members.png b/docs/images/organization-members.png deleted file mode 100644 index 70190df0c..000000000 Binary files a/docs/images/organization-members.png and /dev/null differ diff --git a/docs/images/platform/access-controls/access-request-policies.png b/docs/images/platform/access-controls/access-request-policies.png new file mode 100644 index 000000000..d7ea4829c Binary files /dev/null and b/docs/images/platform/access-controls/access-request-policies.png differ diff --git a/docs/images/platform/access-controls/access-requests-completed.png b/docs/images/platform/access-controls/access-requests-completed.png new file mode 100644 index 000000000..a2a167f78 Binary files /dev/null and b/docs/images/platform/access-controls/access-requests-completed.png differ diff --git a/docs/images/platform/access-controls/access-requests-pending.png b/docs/images/platform/access-controls/access-requests-pending.png new file mode 100644 index 000000000..d75f669e2 Binary files /dev/null and b/docs/images/platform/access-controls/access-requests-pending.png differ diff --git a/docs/images/platform/access-controls/add-additional-privileges.png b/docs/images/platform/access-controls/add-additional-privileges.png new file mode 100644 index 000000000..28848075a Binary files /dev/null and b/docs/images/platform/access-controls/add-additional-privileges.png differ diff --git a/docs/images/platform/access-controls/additional-privileges.png b/docs/images/platform/access-controls/additional-privileges.png new file mode 100644 index 000000000..4561021ba Binary files /dev/null and b/docs/images/platform/access-controls/additional-privileges.png differ diff --git a/docs/images/platform/access-controls/configure-temporary-access.png b/docs/images/platform/access-controls/configure-temporary-access.png new file mode 100644 index 000000000..0c16bbc35 Binary files /dev/null and b/docs/images/platform/access-controls/configure-temporary-access.png differ diff --git a/docs/images/platform/access-controls/confirm-additional-privileges.png b/docs/images/platform/access-controls/confirm-additional-privileges.png new file mode 100644 index 000000000..b6fdbf518 Binary files /dev/null and b/docs/images/platform/access-controls/confirm-additional-privileges.png differ diff --git a/docs/images/platform/access-controls/create-access-request-policy.png b/docs/images/platform/access-controls/create-access-request-policy.png new file mode 100644 index 000000000..6593fd733 Binary files /dev/null and b/docs/images/platform/access-controls/create-access-request-policy.png differ diff --git a/docs/images/platform/access-controls/edit-role.png b/docs/images/platform/access-controls/edit-role.png new file mode 100644 index 000000000..598f585b8 Binary files /dev/null and b/docs/images/platform/access-controls/edit-role.png differ diff --git a/docs/images/platform/access-controls/rbac.png b/docs/images/platform/access-controls/rbac.png new file mode 100644 index 000000000..22380c805 Binary files /dev/null and b/docs/images/platform/access-controls/rbac.png differ diff --git a/docs/images/platform/access-controls/request-access.png b/docs/images/platform/access-controls/request-access.png new file mode 100644 index 000000000..63c76dbd5 Binary files /dev/null and b/docs/images/platform/access-controls/request-access.png differ diff --git a/docs/images/platform/access-controls/review-access-request.png b/docs/images/platform/access-controls/review-access-request.png new file mode 100644 index 000000000..8376f9691 Binary files /dev/null and b/docs/images/platform/access-controls/review-access-request.png differ diff --git a/docs/images/platform/access-controls/temporary-access.png b/docs/images/platform/access-controls/temporary-access.png new file mode 100644 index 000000000..24be8a584 Binary files /dev/null and b/docs/images/platform/access-controls/temporary-access.png differ diff --git a/docs/images/platform/audit-log-streams/betterstack-create-source.png b/docs/images/platform/audit-log-streams/betterstack-create-source.png new file mode 100644 index 000000000..bee4513ea Binary files /dev/null and b/docs/images/platform/audit-log-streams/betterstack-create-source.png differ diff --git a/docs/images/platform/audit-log-streams/betterstack-source-details.png b/docs/images/platform/audit-log-streams/betterstack-source-details.png new file mode 100644 index 000000000..d67980ae8 Binary files /dev/null and b/docs/images/platform/audit-log-streams/betterstack-source-details.png differ diff --git a/docs/images/platform/audit-log-streams/data-create-api-key.png b/docs/images/platform/audit-log-streams/data-create-api-key.png new file mode 100644 index 000000000..d25a2c64e Binary files /dev/null and b/docs/images/platform/audit-log-streams/data-create-api-key.png differ diff --git a/docs/images/platform/audit-log-streams/data-dog-api-key.png b/docs/images/platform/audit-log-streams/data-dog-api-key.png new file mode 100644 index 000000000..8e49e89e7 Binary files /dev/null and b/docs/images/platform/audit-log-streams/data-dog-api-key.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-api-sidebar.png b/docs/images/platform/audit-log-streams/datadog-api-sidebar.png new file mode 100644 index 000000000..d95cb9b2d Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-api-sidebar.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png new file mode 100644 index 000000000..7960b1145 Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-logging-endpoint.png differ diff --git a/docs/images/platform/audit-log-streams/datadog-source-details.png b/docs/images/platform/audit-log-streams/datadog-source-details.png new file mode 100644 index 000000000..5ae25b0b3 Binary files /dev/null and b/docs/images/platform/audit-log-streams/datadog-source-details.png differ diff --git a/docs/images/platform/audit-log-streams/stream-create.png b/docs/images/platform/audit-log-streams/stream-create.png new file mode 100644 index 000000000..949278e3d Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-create.png differ diff --git a/docs/images/platform/audit-log-streams/stream-inputs.png b/docs/images/platform/audit-log-streams/stream-inputs.png new file mode 100644 index 000000000..6b9d7c57b Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-inputs.png differ diff --git a/docs/images/platform/audit-log-streams/stream-list.png b/docs/images/platform/audit-log-streams/stream-list.png new file mode 100644 index 000000000..c5cc5598b Binary files /dev/null and b/docs/images/platform/audit-log-streams/stream-list.png differ diff --git a/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png new file mode 100644 index 000000000..8d0fd3ecc Binary files /dev/null and b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png b/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png new file mode 100644 index 000000000..4a816614a Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png new file mode 100644 index 000000000..e6da94dcd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png new file mode 100644 index 000000000..e97554415 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png new file mode 100644 index 000000000..3ae9155a3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png new file mode 100644 index 000000000..6956c44e4 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-mysql.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-mysql.png new file mode 100644 index 000000000..de4911d0d Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-mysql.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png new file mode 100644 index 000000000..053873a9c Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-oracle.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal.png new file mode 100644 index 000000000..5f487dd7f Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png new file mode 100644 index 000000000..d412109fa Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png new file mode 100644 index 000000000..b9ce04aef Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal.png new file mode 100644 index 000000000..3cd6c06f6 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret.png b/docs/images/platform/dynamic-secrets/dynamic-secret.png new file mode 100644 index 000000000..e1ec71fcd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-data.png b/docs/images/platform/dynamic-secrets/lease-data.png new file mode 100644 index 000000000..aecd8c11d Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-data.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png b/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png new file mode 100644 index 000000000..4764eceb2 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-values.png b/docs/images/platform/dynamic-secrets/lease-values.png new file mode 100644 index 000000000..d552845f8 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-cql-statements.png b/docs/images/platform/dynamic-secrets/modify-cql-statements.png new file mode 100644 index 000000000..d1e1b9b98 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-cql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png b/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png new file mode 100644 index 000000000..8ad9fc0e3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png b/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png new file mode 100644 index 000000000..0874aa23d Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sql-statements.png new file mode 100644 index 000000000..d0f3b09da Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/provision-lease.png b/docs/images/platform/dynamic-secrets/provision-lease.png new file mode 100644 index 000000000..f144a5ae2 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/provision-lease.png differ diff --git a/docs/images/platform/groups/groups-org-create.png b/docs/images/platform/groups/groups-org-create.png new file mode 100644 index 000000000..a8a1e677c Binary files /dev/null and b/docs/images/platform/groups/groups-org-create.png differ diff --git a/docs/images/platform/groups/groups-org-users-assign.png b/docs/images/platform/groups/groups-org-users-assign.png new file mode 100644 index 000000000..b5f629c2e Binary files /dev/null and b/docs/images/platform/groups/groups-org-users-assign.png differ diff --git a/docs/images/platform/groups/groups-org-users.png b/docs/images/platform/groups/groups-org-users.png new file mode 100644 index 000000000..383425e77 Binary files /dev/null and b/docs/images/platform/groups/groups-org-users.png differ diff --git a/docs/images/platform/groups/groups-org.png b/docs/images/platform/groups/groups-org.png new file mode 100644 index 000000000..13b2edc44 Binary files /dev/null and b/docs/images/platform/groups/groups-org.png differ diff --git a/docs/images/platform/groups/groups-project-create.png b/docs/images/platform/groups/groups-project-create.png new file mode 100644 index 000000000..9232aa042 Binary files /dev/null and b/docs/images/platform/groups/groups-project-create.png differ diff --git a/docs/images/platform/groups/groups-project.png b/docs/images/platform/groups/groups-project.png new file mode 100644 index 000000000..83e384861 Binary files /dev/null and b/docs/images/platform/groups/groups-project.png differ diff --git a/docs/images/platform/identities/identities-org-create-aws-auth-method.png b/docs/images/platform/identities/identities-org-create-aws-auth-method.png new file mode 100644 index 000000000..4b902c048 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-aws-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png b/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png new file mode 100644 index 000000000..899130c42 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-gcp-gce-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png b/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png new file mode 100644 index 000000000..9dacf9f89 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-gcp-iam-auth-method.png differ diff --git a/docs/images/platform/identities/identities-org-create-kubernetes-auth-method.png b/docs/images/platform/identities/identities-org-create-kubernetes-auth-method.png new file mode 100644 index 000000000..0c2fe072d Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-kubernetes-auth-method.png differ diff --git a/docs/images/platform/ldap/jumpcloud/ldap-jumpcloud-enable-bind-dn.png b/docs/images/platform/ldap/jumpcloud/ldap-jumpcloud-enable-bind-dn.png new file mode 100644 index 000000000..b50c1e0be Binary files /dev/null and b/docs/images/platform/ldap/jumpcloud/ldap-jumpcloud-enable-bind-dn.png differ diff --git a/docs/images/platform/ldap/jumpcloud/ldap-jumpcloud-org-dn.png b/docs/images/platform/ldap/jumpcloud/ldap-jumpcloud-org-dn.png new file mode 100644 index 000000000..cd6166b1f Binary files /dev/null and b/docs/images/platform/ldap/jumpcloud/ldap-jumpcloud-org-dn.png differ diff --git a/docs/images/platform/ldap/ldap-config.png b/docs/images/platform/ldap/ldap-config.png new file mode 100644 index 000000000..2cd711dd1 Binary files /dev/null and b/docs/images/platform/ldap/ldap-config.png differ diff --git a/docs/images/platform/ldap/ldap-group-mappings-section.png b/docs/images/platform/ldap/ldap-group-mappings-section.png new file mode 100644 index 000000000..9f668e44b Binary files /dev/null and b/docs/images/platform/ldap/ldap-group-mappings-section.png differ diff --git a/docs/images/platform/ldap/ldap-group-mappings-table.png b/docs/images/platform/ldap/ldap-group-mappings-table.png new file mode 100644 index 000000000..1003b5af8 Binary files /dev/null and b/docs/images/platform/ldap/ldap-group-mappings-table.png differ diff --git a/docs/images/platform/ldap/ldap-test-connection.png b/docs/images/platform/ldap/ldap-test-connection.png new file mode 100644 index 000000000..9f1a3896c Binary files /dev/null and b/docs/images/platform/ldap/ldap-test-connection.png differ diff --git a/docs/images/platform/ldap/ldap-toggle.png b/docs/images/platform/ldap/ldap-toggle.png new file mode 100644 index 000000000..30755b7ec Binary files /dev/null and b/docs/images/platform/ldap/ldap-toggle.png differ diff --git a/docs/images/platform/organization/organization-machine-identities.png b/docs/images/platform/organization/organization-machine-identities.png new file mode 100644 index 000000000..17bea6e9b Binary files /dev/null and b/docs/images/platform/organization/organization-machine-identities.png differ diff --git a/docs/images/platform/organization/organization-members-roles.png b/docs/images/platform/organization/organization-members-roles.png index 454af0809..08c2d1e90 100644 Binary files a/docs/images/platform/organization/organization-members-roles.png and b/docs/images/platform/organization/organization-members-roles.png differ diff --git a/docs/images/platform/organization/organization-members.png b/docs/images/platform/organization/organization-members.png new file mode 100644 index 000000000..a79d3bbe0 Binary files /dev/null and b/docs/images/platform/organization/organization-members.png differ diff --git a/docs/images/platform/organization/organization-settings-auth.png b/docs/images/platform/organization/organization-settings-auth.png index 8643c44da..ca2340e9f 100644 Binary files a/docs/images/platform/organization/organization-settings-auth.png and b/docs/images/platform/organization/organization-settings-auth.png differ diff --git a/docs/images/platform/project/project-environments.png b/docs/images/platform/project/project-environments.png new file mode 100644 index 000000000..e468f2b82 Binary files /dev/null and b/docs/images/platform/project/project-environments.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-config.png b/docs/images/platform/scim/azure/scim-azure-config.png new file mode 100644 index 000000000..5255c3a8d Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-config.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-get-started.png b/docs/images/platform/scim/azure/scim-azure-get-started.png new file mode 100644 index 000000000..c97574673 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-get-started.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-provisioning-status.png b/docs/images/platform/scim/azure/scim-azure-provisioning-status.png new file mode 100644 index 000000000..d457a1170 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-provisioning-status.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-select-user-mappings.png b/docs/images/platform/scim/azure/scim-azure-select-user-mappings.png new file mode 100644 index 000000000..2654f86fe Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-select-user-mappings.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-start-provisioning.png b/docs/images/platform/scim/azure/scim-azure-start-provisioning.png new file mode 100644 index 000000000..949474a49 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-start-provisioning.png differ diff --git a/docs/images/platform/scim/azure/scim-azure-user-mappings.png b/docs/images/platform/scim/azure/scim-azure-user-mappings.png new file mode 100644 index 000000000..b96ab6cf7 Binary files /dev/null and b/docs/images/platform/scim/azure/scim-azure-user-mappings.png differ diff --git a/docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png new file mode 100644 index 000000000..b10fd099d Binary files /dev/null and b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-api-type.png differ diff --git a/docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png new file mode 100644 index 000000000..1c42729a7 Binary files /dev/null and b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-config.png differ diff --git a/docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png new file mode 100644 index 000000000..e1980fdfb Binary files /dev/null and b/docs/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-app-settings.png b/docs/images/platform/scim/okta/scim-okta-app-settings.png new file mode 100644 index 000000000..a3ea836ec Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-app-settings.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-auth.png b/docs/images/platform/scim/okta/scim-okta-auth.png new file mode 100644 index 000000000..97ad34567 Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-auth.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-config.png b/docs/images/platform/scim/okta/scim-okta-config.png new file mode 100644 index 000000000..bca1a25eb Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-config.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-enable-provisioning.png b/docs/images/platform/scim/okta/scim-okta-enable-provisioning.png new file mode 100644 index 000000000..d5688182e Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-enable-provisioning.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-test.png b/docs/images/platform/scim/okta/scim-okta-test.png new file mode 100644 index 000000000..f1c2e9221 Binary files /dev/null and b/docs/images/platform/scim/okta/scim-okta-test.png differ diff --git a/docs/images/platform/scim/scim-copy-token.png b/docs/images/platform/scim/scim-copy-token.png new file mode 100644 index 000000000..d3a4586c2 Binary files /dev/null and b/docs/images/platform/scim/scim-copy-token.png differ diff --git a/docs/images/platform/scim/scim-create-token.png b/docs/images/platform/scim/scim-create-token.png new file mode 100644 index 000000000..9fe4eac3e Binary files /dev/null and b/docs/images/platform/scim/scim-create-token.png differ diff --git a/docs/images/platform/scim/scim-enable-provisioning.png b/docs/images/platform/scim/scim-enable-provisioning.png new file mode 100644 index 000000000..a4385244f Binary files /dev/null and b/docs/images/platform/scim/scim-enable-provisioning.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-config-1.png b/docs/images/platform/secret-rotation/aws-iam/rotation-config-1.png new file mode 100644 index 000000000..2e6e5b830 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-config-1.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-config-2.png b/docs/images/platform/secret-rotation/aws-iam/rotation-config-2.png new file mode 100644 index 000000000..487147c47 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-config-2.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-config-secrets.png b/docs/images/platform/secret-rotation/aws-iam/rotation-config-secrets.png new file mode 100644 index 000000000..8a15d486f Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-config-secrets.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-key-third-party.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-key-third-party.png new file mode 100644 index 000000000..8e01b60f5 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-key-third-party.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png new file mode 100644 index 000000000..f30b69dad Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png new file mode 100644 index 000000000..4944366de Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png new file mode 100644 index 000000000..7a71e4a5e Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png new file mode 100644 index 000000000..46ae782e1 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png new file mode 100644 index 000000000..05542dae3 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png new file mode 100644 index 000000000..ae81055b0 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png new file mode 100644 index 000000000..34773f913 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotation-manager-username.png b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-username.png new file mode 100644 index 000000000..573c3cf94 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotation-manager-username.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png b/docs/images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png new file mode 100644 index 000000000..5ea395e68 Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png differ diff --git a/docs/images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png b/docs/images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png new file mode 100644 index 000000000..17bbe3b1f Binary files /dev/null and b/docs/images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png differ diff --git a/docs/images/platform/secret-versioning.png b/docs/images/platform/secret-versioning.png new file mode 100644 index 000000000..593e8c96f Binary files /dev/null and b/docs/images/platform/secret-versioning.png differ diff --git a/docs/images/project-token-old-add.png b/docs/images/project-token-old-add.png index 960013a3f..c8d1b9e05 100644 Binary files a/docs/images/project-token-old-add.png and b/docs/images/project-token-old-add.png differ diff --git a/docs/images/secret-rotation/mysql-step1.png b/docs/images/secret-rotation/mysql-step1.png new file mode 100644 index 000000000..316dd3adf Binary files /dev/null and b/docs/images/secret-rotation/mysql-step1.png differ diff --git a/docs/images/secret-rotation/postgres-step1.png b/docs/images/secret-rotation/postgres-step1.png new file mode 100644 index 000000000..8b64932ea Binary files /dev/null and b/docs/images/secret-rotation/postgres-step1.png differ diff --git a/docs/images/secret-rotation/postgres-step2.png b/docs/images/secret-rotation/postgres-step2.png new file mode 100644 index 000000000..b261e7464 Binary files /dev/null and b/docs/images/secret-rotation/postgres-step2.png differ diff --git a/docs/images/secret-rotation/sendgrid-step1.png b/docs/images/secret-rotation/sendgrid-step1.png new file mode 100644 index 000000000..cb919e34f Binary files /dev/null and b/docs/images/secret-rotation/sendgrid-step1.png differ diff --git a/docs/images/secret-rotation/sendgrid-step2.png b/docs/images/secret-rotation/sendgrid-step2.png new file mode 100644 index 000000000..62c1f29ff Binary files /dev/null and b/docs/images/secret-rotation/sendgrid-step2.png differ diff --git a/docs/images/self-hosting/applicable-to-all/selfhost-signup.png b/docs/images/self-hosting/applicable-to-all/selfhost-signup.png new file mode 100644 index 000000000..745c32a44 Binary files /dev/null and b/docs/images/self-hosting/applicable-to-all/selfhost-signup.png differ diff --git a/docs/images/self-hosting/configuration/email/ses-create-identity.png b/docs/images/self-hosting/configuration/email/ses-create-identity.png new file mode 100644 index 000000000..58b2b2e24 Binary files /dev/null and b/docs/images/self-hosting/configuration/email/ses-create-identity.png differ diff --git a/docs/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png b/docs/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png new file mode 100644 index 000000000..bfd2bb520 Binary files /dev/null and b/docs/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png differ diff --git a/docs/images/self-hosting/guides/mongo-postgres/mongo-migration.png b/docs/images/self-hosting/guides/mongo-postgres/mongo-migration.png new file mode 100644 index 000000000..b74cd4963 Binary files /dev/null and b/docs/images/self-hosting/guides/mongo-postgres/mongo-migration.png differ diff --git a/docs/images/self-hosting/reference-architectures/Infisical-AWS-ECS-architecture.jpeg b/docs/images/self-hosting/reference-architectures/Infisical-AWS-ECS-architecture.jpeg new file mode 100644 index 000000000..2c63045ec Binary files /dev/null and b/docs/images/self-hosting/reference-architectures/Infisical-AWS-ECS-architecture.jpeg differ diff --git a/docs/images/self-hosting/reference-architectures/on-premise-architecture.png b/docs/images/self-hosting/reference-architectures/on-premise-architecture.png new file mode 100644 index 000000000..a4d04f98d Binary files /dev/null and b/docs/images/self-hosting/reference-architectures/on-premise-architecture.png differ diff --git a/docs/images/sso/google-saml/attribute-mapping.png b/docs/images/sso/google-saml/attribute-mapping.png new file mode 100644 index 000000000..b5702cd2b Binary files /dev/null and b/docs/images/sso/google-saml/attribute-mapping.png differ diff --git a/docs/images/sso/google-saml/create-custom-saml-app.png b/docs/images/sso/google-saml/create-custom-saml-app.png new file mode 100644 index 000000000..6139932f1 Binary files /dev/null and b/docs/images/sso/google-saml/create-custom-saml-app.png differ diff --git a/docs/images/sso/google-saml/custom-saml-app-config-2.png b/docs/images/sso/google-saml/custom-saml-app-config-2.png new file mode 100644 index 000000000..9839dd0c4 Binary files /dev/null and b/docs/images/sso/google-saml/custom-saml-app-config-2.png differ diff --git a/docs/images/sso/google-saml/custom-saml-app-config.png b/docs/images/sso/google-saml/custom-saml-app-config.png new file mode 100644 index 000000000..8f4ad5928 Binary files /dev/null and b/docs/images/sso/google-saml/custom-saml-app-config.png differ diff --git a/docs/images/sso/google-saml/enable-saml.png b/docs/images/sso/google-saml/enable-saml.png new file mode 100644 index 000000000..7a90eed55 Binary files /dev/null and b/docs/images/sso/google-saml/enable-saml.png differ diff --git a/docs/images/sso/google-saml/infisical-config.png b/docs/images/sso/google-saml/infisical-config.png new file mode 100644 index 000000000..250b4ed37 Binary files /dev/null and b/docs/images/sso/google-saml/infisical-config.png differ diff --git a/docs/images/sso/google-saml/init-config.png b/docs/images/sso/google-saml/init-config.png new file mode 100644 index 000000000..c4b967e54 Binary files /dev/null and b/docs/images/sso/google-saml/init-config.png differ diff --git a/docs/images/sso/google-saml/name-custom-saml-app.png b/docs/images/sso/google-saml/name-custom-saml-app.png new file mode 100644 index 000000000..580896d05 Binary files /dev/null and b/docs/images/sso/google-saml/name-custom-saml-app.png differ diff --git a/docs/images/sso/google-saml/user-access-assign.png b/docs/images/sso/google-saml/user-access-assign.png new file mode 100644 index 000000000..afa115c65 Binary files /dev/null and b/docs/images/sso/google-saml/user-access-assign.png differ diff --git a/docs/images/sso/google-saml/user-access.png b/docs/images/sso/google-saml/user-access.png new file mode 100644 index 000000000..bd69c2277 Binary files /dev/null and b/docs/images/sso/google-saml/user-access.png differ diff --git a/docs/images/sso/keycloak/client-mappers-by-configuration.png b/docs/images/sso/keycloak/client-mappers-by-configuration.png new file mode 100644 index 000000000..9bebb422e Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-by-configuration.png differ diff --git a/docs/images/sso/keycloak/client-mappers-completed.png b/docs/images/sso/keycloak/client-mappers-completed.png new file mode 100644 index 000000000..38fb82006 Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-completed.png differ diff --git a/docs/images/sso/keycloak/client-mappers-email.png b/docs/images/sso/keycloak/client-mappers-email.png new file mode 100644 index 000000000..e1a369bab Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-email.png differ diff --git a/docs/images/sso/keycloak/client-mappers-empty.png b/docs/images/sso/keycloak/client-mappers-empty.png new file mode 100644 index 000000000..01ec1d3e6 Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-empty.png differ diff --git a/docs/images/sso/keycloak/client-mappers-id.png b/docs/images/sso/keycloak/client-mappers-id.png new file mode 100644 index 000000000..a45638b87 Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-id.png differ diff --git a/docs/images/sso/keycloak/client-mappers-predefined.png b/docs/images/sso/keycloak/client-mappers-predefined.png new file mode 100644 index 000000000..750d600b7 Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-predefined.png differ diff --git a/docs/images/sso/keycloak/client-mappers-user-property.png b/docs/images/sso/keycloak/client-mappers-user-property.png new file mode 100644 index 000000000..c854f9521 Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-user-property.png differ diff --git a/docs/images/sso/keycloak/client-mappers-username.png b/docs/images/sso/keycloak/client-mappers-username.png new file mode 100644 index 000000000..ff2a8fc39 Binary files /dev/null and b/docs/images/sso/keycloak/client-mappers-username.png differ diff --git a/docs/images/sso/keycloak/client-saml-capabilities.png b/docs/images/sso/keycloak/client-saml-capabilities.png new file mode 100644 index 000000000..a4383628a Binary files /dev/null and b/docs/images/sso/keycloak/client-saml-capabilities.png differ diff --git a/docs/images/sso/keycloak/client-scopes-list.png b/docs/images/sso/keycloak/client-scopes-list.png new file mode 100644 index 000000000..16f908af4 Binary files /dev/null and b/docs/images/sso/keycloak/client-scopes-list.png differ diff --git a/docs/images/sso/keycloak/client-signature-encryption.png b/docs/images/sso/keycloak/client-signature-encryption.png new file mode 100644 index 000000000..b03b07a75 Binary files /dev/null and b/docs/images/sso/keycloak/client-signature-encryption.png differ diff --git a/docs/images/sso/keycloak/clients-list.png b/docs/images/sso/keycloak/clients-list.png new file mode 100644 index 000000000..ad05b2004 Binary files /dev/null and b/docs/images/sso/keycloak/clients-list.png differ diff --git a/docs/images/sso/keycloak/create-client-general-settings.png b/docs/images/sso/keycloak/create-client-general-settings.png new file mode 100644 index 000000000..866a92070 Binary files /dev/null and b/docs/images/sso/keycloak/create-client-general-settings.png differ diff --git a/docs/images/sso/keycloak/create-client-login-settings.png b/docs/images/sso/keycloak/create-client-login-settings.png new file mode 100644 index 000000000..6fa8b4ce4 Binary files /dev/null and b/docs/images/sso/keycloak/create-client-login-settings.png differ diff --git a/docs/images/sso/keycloak/enable-saml.png b/docs/images/sso/keycloak/enable-saml.png new file mode 100644 index 000000000..f66af968a Binary files /dev/null and b/docs/images/sso/keycloak/enable-saml.png differ diff --git a/docs/images/sso/keycloak/idp-values.png b/docs/images/sso/keycloak/idp-values.png new file mode 100644 index 000000000..9de14f23b Binary files /dev/null and b/docs/images/sso/keycloak/idp-values.png differ diff --git a/docs/images/sso/keycloak/init-config.png b/docs/images/sso/keycloak/init-config.png new file mode 100644 index 000000000..d500bb86e Binary files /dev/null and b/docs/images/sso/keycloak/init-config.png differ diff --git a/docs/images/sso/keycloak/org-security-section.png b/docs/images/sso/keycloak/org-security-section.png new file mode 100644 index 000000000..bbbfb2d42 Binary files /dev/null and b/docs/images/sso/keycloak/org-security-section.png differ diff --git a/docs/images/sso/keycloak/realm-saml-metadata.png b/docs/images/sso/keycloak/realm-saml-metadata.png new file mode 100644 index 000000000..c5ea5d497 Binary files /dev/null and b/docs/images/sso/keycloak/realm-saml-metadata.png differ diff --git a/docs/images/sso/keycloak/realm-settings-keys.png b/docs/images/sso/keycloak/realm-settings-keys.png new file mode 100644 index 000000000..3add94290 Binary files /dev/null and b/docs/images/sso/keycloak/realm-settings-keys.png differ diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 95fe53ece..936c8974a 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -3,17 +3,20 @@ title: "GitHub Actions" description: "How to sync secrets from Infisical to GitHub Actions" --- + + Alternatively, you can use Infisical's official Github Action + [here](https://github.com/Infisical/secrets-action). + + +Infisical lets you sync secrets to GitHub at the organization-level, repository-level, and repository environment-level. + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) +- Ensure that you have admin privileges to the repository you want to sync secrets to. + - - Infisical can sync secrets to GitHub repo secrets only. If your repo uses environment secrets, then stay tuned with this [issue](https://github.com/Infisical/infisical/issues/54). - - - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - Ensure you have admin privileges to the repo you want to sync secrets to. - Navigate to your project's integrations tab in Infisical. @@ -29,12 +32,27 @@ description: "How to sync secrets from Infisical to GitHub Actions" Although this step breaks E2EE, it's necessary for Infisical to sync the environment variables to the cloud platform. - - Select which Infisical environment secrets you want to sync to which GitHub repo and press start integration to start syncing secrets to the repo. + + Select which Infisical environment secrets you want to sync to which GitHub organization, repository, or repository environment. + + + + ![integrations github](../../images/integrations/github/integrations-github-scope-repo.png) + + + ![integrations github](../../images/integrations/github/integrations-github-scope-org.png) + + + ![integrations github](../../images/integrations/github/integrations-github-scope-env.png) + + + + Finally, press create integration to start syncing secrets to GitHub. ![integrations github](../../images/integrations/github/integrations-github.png) + Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub @@ -45,13 +63,13 @@ description: "How to sync secrets from Infisical to GitHub Actions" ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) - ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-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/integrations/github/oauth2/callback`. - ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) - + ![integrations github config](../../images/integrations/github/integrations-github-config-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. @@ -59,17 +77,17 @@ description: "How to sync secrets from Infisical to GitHub Actions" Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. - - ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) - + + ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) + Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: - `CLIENT_ID_GITHUB`: The **Client ID** of your GitHub OAuth application. - `CLIENT_SECRET_GITHUB`: The **Client Secret** of your GitHub OAuth application. - + Once added, restart your Infisical instance and use the GitHub integration. + - diff --git a/docs/integrations/cicd/jenkins.mdx b/docs/integrations/cicd/jenkins.mdx index 58c92d218..a83d90700 100644 --- a/docs/integrations/cicd/jenkins.mdx +++ b/docs/integrations/cicd/jenkins.mdx @@ -1,122 +1,273 @@ --- -title: "Jenkins" +title: "Jenkins Plugin" description: "How to effectively and securely manage secrets in Jenkins using Infisical" --- +**Objective**: Fetch secrets from Infisical to Jenkins pipelines + +In this guide, we'll outline the steps to deliver secrets from Infisical to Jenkins via the Infisical CLI. +At a high level, the Infisical CLI will be executed within your build environment and use a machine identity to authenticate with Infisical. +This token must be added as a Jenkins Credential and then passed to the Infisical CLI as an environment variable, enabling it to access and retrieve secrets within your workflows. + Prerequisites: - Set up and add secrets to [Infisical](https://app.infisical.com). +- Create a [machine identity](/documentation/platform/identities/machine-identities) (Recommended), or a service token in Infisical. - You have a working Jenkins installation with the [credentials plugin](https://plugins.jenkins.io/credentials/) installed. -- You have the Infisical CLI installed on your Jenkins executor nodes or container images. +- You have the [Infisical CLI](/cli/overview) installed on your Jenkins executor nodes or container images. -## Add Infisical Service Token to Jenkins + -After setting up your project in Infisical and adding the Infisical CLI to container images, you will need to add the Infisical Service Token to Jenkins. Once you have generated the token, browse to **Manage Jenkins > Manage Credentials** in your Jenkins installation. + + + ## Jenkins Infisical Plugin -![Jenkins step 1](../../images/integrations/jenkins/jenkins_1.png) + This plugin adds a build wrapper to set environment variables from [Infisical](https://infisical.com). Secrets are generally masked in the build log, so you can't accidentally print them. -Click on the credential store you want to store the Infisical Service Token in. In this case, we're using the default Jenkins global store. + ## Installation - - Each of your projects will have a different INFISICAL_SERVICE_TOKEN though. - As a result, it may make sense to spread these out into separate credential domains depending on your use case. - + To install the plugin, navigate to `Manage Jenkins -> Plugins -> Available plugins` and search for `Infisical`. Install the plugin and restart Jenkins. -![Jenkins step 2](../../images/integrations/jenkins/jenkins_2.png) + ![Install Plugin](../../images/integrations/jenkins/plugin/install-plugin.png) -Now, click Add Credentials. + ## Infisical Authentication -![Jenkins step 3](../../images/integrations/jenkins/jenkins_3.png) + Authenticating with Infisical is done through the use of [Machine Identities](https://infisical.com/docs/documentation/platform/identities/machine-identities). + Currently the Jenkins plugin only supports [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) for authentication. More methods will be added soon. -Choose **Secret text** from the **Kind** dropdown menu, paste the Infisical Service Token into the **Secret** field, enter `INFISICAL_SERVICE_TOKEN` into the **Description** field, and click **OK**. - -![Jenkins step 4](../../images/integrations/jenkins/jenkins_4.png) - -When you're done, you should have a credential similar to the one below: - -![Jenkins step 5](../../images/integrations/jenkins/jenkins_5.png) + ### How does Universal Auth work? + To use Universal Auth, you'll need to create a new Credential _(Infisical Universal Auth Credential)_. The credential should contain your Universal Auth client ID, and your Universal Auth client secret. + Please [read more here](https://infisical.com/docs/documentation/platform/identities/universal-auth) on how to setup a Machine Identity to use universal auth. -## Use Infisical in a Freestyle Project + ### Creating a Universal Auth credential -To use Infisical in a Freestyle Project job, you'll need to expose the credential you created above in an environment variable. First, click New Item from the dashboard navigation sidebar: + Creating a universal auth credential inside Jenkins is very straight forward. -![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + Simply navigate to
+ `Dashboard -> Manage Jenkins -> Credentials -> System -> Global credentials (unrestricted)`. -Enter the name of the job, choose the **Freestyle Project** option, and click **OK**. + Press the `Add Credentials` button and select `Infisical Universal Auth Credential` in the `Kind` field. -![Jenkins step 7](../../images/integrations/jenkins/jenkins_7.png) + The `ID` and `Description` field doesn't matter much in this case, as they won't be read anywhere. The description field will be displayed as the credential name during the plugin configuration. -Scroll down to the **Build Environment** section and enable the **Use secret text(s) or file(s)** option. Then click **Add** under the **Bindings** section and choose **Secret text** from the dropdown menu. - -![Jenkins step 8](../../images/integrations/jenkins/jenkins_8.png) - -Enter INFISICAL_SERVICE_TOKEN in the **Variable** field, select the **Specific credentials** option from the Credentials section and choose INFISICAL_SERVICE_TOKEN from the dropdown menu. - -![Jenkins step 9](../../images/integrations/jenkins/jenkins_9.png) - -Scroll down to the **Build** section and choose **Execute shell** from the **Add build step** menu. - -![Jenkins step 10](../../images/integrations/jenkins/jenkins_10.png) - -In the command field, enter the following command and click **Save**: - -``` -infisical run -- printenv -``` - -![Jenkins step 11](../../images/integrations/jenkins/jenkins_11.png) - -Finally, click **Build Now** from the navigation sidebar to test your new job. - - - Running into issues? Join Infisical's [community Slack](https://infisical.com/slack) for quick support. - + ![Infisical Universal Auth Credential](../../images/integrations/jenkins/plugin/universal-auth-credential.png) -## Use Infisical in a Jenkins Pipeline + ## Plugin Usage + ### Configuration -To use Infisical in a Pipeline job, you'll need to expose the credential you created above as an environment variable. First, click **New Item** from the dashboard navigation sidebar: + Configuration takes place on a job-level basis. -![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + Inside your job, you simply tick the `Infisical Plugin` checkbox under "Build Environment". After enabling the plugin, you'll see a new section appear where you'll have to configure the plugin. -Enter the name of the job, choose the **Pipeline** option, and click OK. + ![Plugin enabled](../../images/integrations/jenkins/plugin/plugin-checked.png) -![Jenkins step 12](../../images/integrations/jenkins/jenkins_12.png) + You'll be prompted with 4 options to fill: + * Infisical URL + * This defaults to https://app.infisical.com. This field is only relevant if you're running a managed or self-hosted instance. If you are using Infisical Cloud, leave this as-is, otherwise enter the URL of your Infisical instance. + * Infisical Credential + * This is where you select your Infisical credential to use for authentication. In the step above [Creating a Universal Auth credential](#creating-a-universal-auth-credential), you can read on how to configure the credential. Simply select the credential you have created for this field. + * Infisical Project Slug + * This is the slug of the project you wish to fetch secrets from. You can find this in your project settings on Infisical by clicking "Copy project slug". + * Environment Slug + * This is the slug of the environment to fetch secrets from. In most cases it's either `dev`, `staging`, or `prod`. You can however create custom environments in Infisical. If you are using custom environments, you need to enter the slug of the custom environment you wish to fetch secrets from. + + That's it! Now you're ready to select which secrets you want to fetch into Jenkins. + By clicking the `Add an Infisical secret` in the Jenkins UI like seen in the screenshot below. -Scroll down to the **Pipeline** section, paste the following into the **Script** field, and click **Save**. + ![Add Infisical secret](../../images/integrations/jenkins/plugin/add-infisical-secret.png) -``` -pipeline { - agent any + You need to select which secrets that should be pulled into Jenkins. + You start by specifying a [folder path from Infisical](https://infisical.com/docs/documentation/platform/folder#comparing-folders). The root path is simply `/`. You also need to select wether or not you want to [include imports](https://infisical.com/docs/documentation/platform/secret-reference#secret-imports). Now you can add secrets the secret keys that you want to pull from Infisical into Jenkins. If you want to add multiple secrets, press the "Add key/value pair". - environment { - INFISICAL_SERVICE_TOKEN = credentials('INFISICAL_SERVICE_TOKEN') + If you wish to pull secrets from multiple paths, you can press the "Add an Infisical secret" button at the bottom, and configure a new set of secrets to pull. + + + ## Pipeline usage + + + ### Generating pipeline block + + Using the Infisical Plugin in a Jenkins pipeline is very straight forward. To generate a block to use the Infisical Plugin in a Pipeline, simply to go `{JENKINS_URL}/jenkins/job/{JOB_ID}/pipeline-syntax/`. + + You can find a direct link on the Pipeline configuration page in the very bottom of the page, see image below. + + ![Pipeline Syntax Highlight](../../images/integrations/jenkins/plugin/pipeline-syntax-highlight.png) + + On the Snippet Generator page, simply configure the Infisical Plugin like it's documented in the [Configuration documentation](#configuration) step. + + Once you have filled out the configuration, press `Generate Pipeline Script`, and it will generate a block you can use in your pipeline. + + ![Pipeline Configuration](../../images/integrations/jenkins/plugin/pipeline-configuration.png) + + ### Using Infisical in a Pipeline + + Using the generated block in a pipeline is very straight forward. There's a few approaches on how to implement the block in a Pipeline script. + Here's an example of using the generated block in a pipeline script. Make sure to replace the placeholder values with your own values. + + The script is formatted for clarity. All these fields will be pre-filled for you if you use the `Snippet Generator` like described in the [step above](#generating-pipeline-block). + ```groovy + node { + withInfisical( + configuration: [ + infisicalCredentialId: 'YOUR_CREDENTIAL_ID', + infisicalEnvironmentSlug: 'PROJECT_ENV_SLUG', + infisicalProjectSlug: 'PROJECT_SLUG', + infisicalUrl: 'https://app.infisical.com' // Change this to your Infisical instance URL if you aren't using Infisical Cloud. + ], + infisicalSecrets: [ + infisicalSecret( + includeImports: true, + path: '/', + secretValues: [ + [infisicalKey: 'DATABASE_URL'], + [infisicalKey: "API_URL"], + [infisicalKey: 'THIS_KEY_MIGHT_NOT_EXIST', isRequired: false], + ] + ) + ] + ) { + // Code runs here + sh "printenv" + } } + ``` - stages { - stage('Run Infisical') { - steps { - sh("infisical secrets") - // doesn't work - // sh("docker run --rm test-container infisical secrets") +
- // works - // sh("docker run -e INFISICAL_SERVICE_TOKEN=${INFISICAL_SERVICE_TOKEN} --rm test-container infisical secrets") + + ## Add Infisical Service Token to Jenkins - // doesn't work - // sh("docker-compose up -d") + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). - // works - // sh("INFISICAL_SERVICE_TOKEN=${INFISICAL_SERVICE_TOKEN} docker-compose up -d") + **Please use our Jenkins Plugin instead!** + + + After setting up your project in Infisical and installing the Infisical CLI to the environment where your Jenkins builds will run, you will need to add the Infisical Service Token to Jenkins. + + To generate a Infisical service token, follow the guide [here](/documentation/platform/token). + Once you have generated the token, navigate to **Manage Jenkins > Manage Credentials** in your Jenkins instance. + + ![Jenkins step 1](../../images/integrations/jenkins/jenkins_1.png) + + Click on the credential store you want to store the Infisical Service Token in. In this case, we're using the default Jenkins global store. + + + Each of your projects will have a different `INFISICAL_TOKEN`. + As a result, it may make sense to spread these out into separate credential domains depending on your use case. + + + ![Jenkins step 2](../../images/integrations/jenkins/jenkins_2.png) + + Now, click Add Credentials. + + ![Jenkins step 3](../../images/integrations/jenkins/jenkins_3.png) + + Choose **Secret text** for the **Kind** option from the dropdown list and enter the Infisical Service Token in the **Secret** field. + Although the **ID** can be any value, we'll set it to `infisical-service-token` for the sake of this guide. + The description is optional and can be any text you prefer. + + + ![Jenkins step 4](../../images/integrations/jenkins/jenkins_4.png) + + When you're done, you should see a credential similar to the one below: + + ![Jenkins step 5](../../images/integrations/jenkins/jenkins_5.png) + + + ## Use Infisical in a Freestyle Project + + To fetch secrets with Infisical in a Freestyle Project job, you'll need to expose the credential you created above as an environment variable to the Infisical CLI. + To do so, first click **New Item** from the dashboard navigation sidebar: + + ![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + + Enter the name of the job, choose the **Freestyle Project** option, and click **OK**. + + ![Jenkins step 7](../../images/integrations/jenkins/jenkins_7.png) + + Scroll down to the **Build Environment** section and enable the **Use secret text(s) or file(s)** option. Then click **Add** under the **Bindings** section and choose **Secret text** from the dropdown menu. + + ![Jenkins step 8](../../images/integrations/jenkins/jenkins_8.png) + + Enter `INFISICAL_TOKEN` in the **Variable** field then click the **Specific credentials** option from the Credentials section and select the credential you created earlier. + In this case, we saved it as `Infisical service token` so we'll choose that from the dropdown menu. + + ![Jenkins step 9](../../images/integrations/jenkins/jenkins_9.png) + + Scroll down to the **Build** section and choose **Execute shell** from the **Add build step** menu. + + ![Jenkins step 10](../../images/integrations/jenkins/jenkins_10.png) + + In the command field, you can now use the Infisical CLI to fetch secrets. + The example command below will print the secrets using the service token passed as a credential. When done, click **Save**. + + ``` + infisical secrets --env=dev --path=/ + ``` + + ![Jenkins step 11](../../images/integrations/jenkins/jenkins_11.png) + + Finally, click **Build Now** from the navigation sidebar to run your new job. + + + Running into issues? Join Infisical's [community Slack](https://infisical.com/slack) for quick support. + + + + + ## Use Infisical in a Jenkins Pipeline + + To fetch secrets using Infisical in a Pipeline job, you'll need to expose the Jenkins credential you created above as an environment variable. + To do so, click **New Item** from the dashboard navigation sidebar: + + ![Jenkins step 6](../../images/integrations/jenkins/jenkins_6.png) + + Enter the name of the job, choose the **Pipeline** option, and click OK. + + ![Jenkins step 12](../../images/integrations/jenkins/jenkins_12.png) + + Scroll down to the **Pipeline** section, paste the following into the **Script** field, and click **Save**. + + ``` + pipeline { + agent any + + environment { + INFISICAL_TOKEN = credentials('infisical-service-token') + } + + stages { + stage('Run Infisical') { + steps { + sh("infisical secrets --env=dev --path=/") + + // doesn't work + // sh("docker run --rm test-container infisical secrets") + + // works + // sh("docker run -e INFISICAL_TOKEN=${INFISICAL_TOKEN} --rm test-container infisical secrets --env=dev --path=/") + + // doesn't work + // sh("docker-compose up -d") + + // works + // sh("INFISICAL_TOKEN=${INFISICAL_TOKEN} docker-compose up -d") + } } } } -} -``` + ``` -This is a very basic sample that you can work from. Jenkins injects the INFISICAL_SERVICE_TOKEN environment variable defined in the pipeline into the shell the commands execute with, but there are some situations where that won't pass through properly – notably if you're executing docker containers on the executor machine. The examples above should give you some idea for how that will work. + -Finally, click **Build Now** from the navigation sidebar to test your new job. +
+ +The example provided above serves as an initial guide. It shows how Jenkins adds the `INFISICAL_TOKEN` environment variable, which is configured in the pipeline, into the shell for executing commands. +There may be instances where this doesn't work as expected in the context of running Docker commands. +However, the list of working examples should provide some insight into how this can be handled properly. diff --git a/docs/integrations/cloud/aws-amplify.mdx b/docs/integrations/cloud/aws-amplify.mdx new file mode 100644 index 000000000..761971025 --- /dev/null +++ b/docs/integrations/cloud/aws-amplify.mdx @@ -0,0 +1,137 @@ +--- +title: "AWS Amplify" +description: "Learn how to sync secrets from Infisical to AWS Amplify." +--- + +Prerequisites: + +- Infisical Cloud account +- Add the secrets you wish to sync to Amplify to [Infisical Cloud](https://app.infisical.com) + +There are many approaches to sync secrets stored within Infisical to AWS Amplify. This guide describes two such approaches below. + +## Access Infisical secrets at Amplify build time + +This approach enables you to fetch secrets from Infisical during Amplify build time. + + + + + + + Create a machine identtiy and connect it to your Infisical project. You can read more about how to use machine identities [here](/documentation/platform/identities/machine-identities). The machine identity will allow you to authenticate and fetch secrets from Infisical. + + + + ![aws amplify env console](../../images/integrations/aws/integrations-amplify-env-console-identity.png) + 1. In the Amplify console, choose App Settings, and then select Environment variables. + 2. In the Environment variables section, select Manage variables. + 3. Under the first Variable enter `INFISICAL_MACHINE_IDENTITY_CLIENT_ID`, and for the value, enter the client ID of the machine identity you created in the previous step. + 4. Under the second Variable enter `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET`, and for the value, enter the client secret of the machine identity you created in the previous step. + 5. Click save. + + + + In the prebuild phase, add the command in AWS Amplify to install the Infisical CLI. + + ```yaml + build: + phases: + preBuild: + commands: + - sudo curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sudo -E bash + - sudo yum -y install infisical + ``` + + + + You can now pull secrets from Infisical using the CLI and save them as a `.env` file. To do this, modify the build commands. + + ```yaml + build: + phases: + build: + commands: + - INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=${INFISICAL_MACHINE_IDENTITY_CLIENT_ID} --client-secret=${INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET} --silent --plain) + - infisical export --format=dotenv > .env + - + ``` + + + + + + + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + + They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + + + + Go to your project settings in the Infisical dashboard to generate a [service token](/documentation/platform/token). This service token will allow you to authenticate and fetch secrets from Infisical. Once you have created a service token with the required permissions, you’ll need to provide the token to the CLI installed in your Docker container. + + + ![aws amplify env console](../../images/integrations/aws/integrations-amplify-env-console.png) + 1. In the Amplify console, choose App Settings, and then select Environment variables. + 2. In the Environment variables section, select Manage variables. + 3. Under Variable, enter the key **INFISICAL_TOKEN**. For the value, enter the generated service token from the previous step. + 4. Click save. + + + In the prebuild phase, add the command in AWS Amplify to install the Infisical CLI. + + ```yaml + build: + phases: + preBuild: + commands: + - sudo curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sudo -E bash + - sudo yum -y install infisical + ``` + + + You can now pull secrets from Infisical using the CLI and save them as a `.env` file. To do this, modify the build commands. + + ```yaml + build: + phases: + build: + commands: + - INFISICAL_TOKEN=${INFISICAL_TOKEN} + - infisical export --format=dotenv > .env + - + ``` + + + + ## Sync Secrets Using AWS SSM Parameter Store + + Another approach to use secrets from Infisical in AWS Amplify is to utilize AWS Parameter Store. + At high level, you begin by using Infisical's AWS SSM Parameter Store integration to sync secrets from Infisical to AWS SSM Parameter Store. You then instruct AWS Amplify to consume those secrets from AWS SSM Parameter Store as [environment secrets](https://docs.aws.amazon.com/amplify/latest/userguide/environment-variables.html#environment-secrets). + + + + Follow the [Infisical AWS SSM Parameter Store Integration Guide](./aws-parameter-store) to set up the integration. Pause once you reach the step where it asks you to select the path you would like to sync. + + + ![amplify app id](../../images/integrations/aws/integrations-amplify-app-id.png) + 1. Open your AWS Amplify App console. + 2. Go to **Actions >> View App Settings** + 3. The App ID will be the last part of the App ARN field after the slash. + + + You need to set the path in the format `/amplify/[amplify_app_id]/[your-amplify-environment-name]` as the path option in AWS SSM Parameter Infisical Integration. + + + + + + + + Accessing an environment secret during a build is similar to accessing + environment variables, except that environment secrets are stored in + `process.env.secrets` as a JSON string. + diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 6fd341018..fdad8b638 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -1,6 +1,6 @@ --- title: "AWS Parameter Store" -description: "How to sync secrets from Infisical to AWS Parameter Store" +description: "Learn how to sync secrets from Infisical to AWS Parameter Store." --- Prerequisites: @@ -29,13 +29,19 @@ Prerequisites: "ssm:PutParameter", "ssm:DeleteParameter", "ssm:GetParametersByPath", - "ssm:DeleteParameters" + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys @@ -43,7 +49,7 @@ Prerequisites: ![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) - + Navigate to your project's integrations tab in Infisical. ![integrations](../../images/integrations.png) @@ -58,6 +64,7 @@ Prerequisites: breaks E2EE, it's necessary for Infisical to sync the environment variables to the cloud platform. + 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. @@ -71,6 +78,6 @@ Prerequisites: secret like `TEST` to be stored as `/[project_name]/[environment]/TEST` in AWS Parameter Store. + - diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index f761d5164..9b3a8a2f8 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -1,6 +1,6 @@ --- title: "AWS Secrets Manager" -description: "How to sync secrets from Infisical to AWS Secrets Manager" +description: "Learn how to sync secrets from Infisical to AWS Secrets Manager." --- Prerequisites: @@ -28,13 +28,21 @@ Prerequisites: "Action": [ "secretsmanager:GetSecretValue", "secretsmanager:CreateSecret", - "secretsmanager:UpdateSecret" + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", // if you need to add tags to secrets + "secretsmanager:TagResource", // if you need to add tags to secrets + "secretsmanager:UntagResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys @@ -42,7 +50,7 @@ Prerequisites: ![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) - + Navigate to your project's integrations tab in Infisical. ![integrations](../../images/integrations.png) @@ -51,23 +59,49 @@ Prerequisites: ![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 - Infisical access to your project's environment variables. Although this step - breaks E2EE, it's necessary for Infisical to sync the environment variables to - the cloud platform. - - 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. + Select how you want to integration to work by specifying a number of parameters: + + + The environment in Infisical from which you want to sync secrets to AWS Secrets Manager. + + + The path within the preselected environment form which you want to sync secrets to AWS Secrets Manager. + + + The region that you want to integrate with in AWS Secrets Manager. + + + How you want the integration to map the secrets. The selected value could be either one to one or one to many. + + + The secret name/path in AWS into which you want to sync the secrets from Infisical. + ![integration create](../../images/integrations/aws/integrations-aws-secret-manager-create.png) + Optionally, you can add tags or specify the encryption key of all the secrets created via this integration: + + + The Key/Value of a tag that will be added to secrets in AWS. Please note that it is possible to add multiple tags via API. + + + The alias/ID of the AWS KMS key used for encryption. Please note that key should be enabled in order to work and the IAM user should have access to it. + + ![integration options](../../images/integrations/aws/integrations-aws-secret-manager-options.png) + + Then, press `Create Integration` to start syncing secrets to AWS Secrets Manager. + Infisical currently syncs environment variables to AWS Secrets Manager as key-value pairs under one secret. We're actively exploring ways to help users group environment variable key-pairs under multiple secrets for greater control. + + Please note that upon deleting secrets in Infisical, AWS Secrets Manager immediately makes the secrets inaccessible but only schedules them for deletion after at least 7 days. + + - \ No newline at end of file + diff --git a/docs/integrations/cloud/heroku.mdx b/docs/integrations/cloud/heroku.mdx index 2d8fdc445..903ab8270 100644 --- a/docs/integrations/cloud/heroku.mdx +++ b/docs/integrations/cloud/heroku.mdx @@ -30,6 +30,17 @@ description: "How to sync secrets from Infisical to Heroku" Select which Infisical environment secrets you want to sync to which Heroku app and press create integration to start syncing secrets to Heroku. ![integrations heroku](../../images/integrations/heroku/integrations-heroku-create.png) + + Here's some guidance on each field: + + - Project Environment: The environment in the current Infisical project from which you want to sync secrets from. + - Secrets Path: The path in the current Infisical project from which you want to sync secrets from such as `/` (for secrets that do not reside in a folder) or `/foo/bar` (for secrets nested in a folder, in this case a folder called `bar` in another folder called `foo`). + - Heroku App: The application in Heroku that you want to sync secrets to. + - Initial Sync Behavior (default is **Import - Prefer values from Infisical**): The behavior of the first sync operation triggered after creating the integration. + - **No Import - Overwrite all values in Heroku**: Sync secrets and overwrite any existing secrets in Heroku. + - **Import - Prefer values from Infisical**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, do nothing. Afterwards, sync secrets to Heroku. + - **Import - Prefer values from Heroku**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, replace its value with the one from Heroku. Afterwards, sync secrets to Heroku. + ![integrations heroku](../../images/integrations/heroku/integrations-heroku.png) diff --git a/docs/integrations/frameworks/terraform.mdx b/docs/integrations/frameworks/terraform.mdx index 2643ca5af..dfacfbc57 100644 --- a/docs/integrations/frameworks/terraform.mdx +++ b/docs/integrations/frameworks/terraform.mdx @@ -1,6 +1,6 @@ --- title: "Terraform" -description: "Fetch Secrets From Infisical With Terraform" +description: "Learn how to fetch Secrets From Infisical With Terraform." --- This guide provides step-by-step guidance on how to fetch secrets from Infisical using Terraform. @@ -34,7 +34,9 @@ Set up the Infisical provider by specifying the `host` and `service_token`. Repl ```hcl main.tf provider "infisical" { host = "https://app.infisical.com" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com - service_token = "<>" # Get token https://infisical.com/docs/documentation/platform/token + client_id = "<>" + client_secret = "<>" + service_token = "<>" # DEPRECATED, USE MACHINE IDENTITY AUTH INSTEAD } ``` @@ -54,6 +56,7 @@ Use the `infisical_secrets` data source to fetch your secrets. In this block, yo data "infisical_secrets" "my-secrets" { env_slug = "dev" folder_path = "/some-folder/another-folder" + workspace_id = "your-project-id" } ``` diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index efb9e4f63..ad95d0d5d 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -1,10 +1,23 @@ --- title: "Ansible" -description: "How to use Infisical for secret management in Ansible" +description: "Learn how to use Infisical for secret management in Ansible." --- The documentation for using Infisical to manage secrets in Ansible is currently available [here](https://galaxy.ansible.com/ui/repo/published/infisical/vault/). - - Have any questions? Join Infisical's [community Slack](https://infisical.com/slack) for quick support. - +## Troubleshoot + + + If you get this Python error when you running the lookup plugin:- + + ``` + objc[72832]: +[__NSCFConstantString initialize] may have been in progress in another thread when fork() was called. We cannot safely call it or ignore it in the fork() child process. Crashing instead. Set a breakpoint on objc_initializeAfterForkError to debug. + Fatal Python error: Aborted + ``` + + You will need to add this to your shell environment or ansible wrapper script:- + + ``` + export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES + ``` + diff --git a/docs/integrations/platforms/docker-compose.mdx b/docs/integrations/platforms/docker-compose.mdx index 238e3dd71..47715eb94 100644 --- a/docs/integrations/platforms/docker-compose.mdx +++ b/docs/integrations/platforms/docker-compose.mdx @@ -1,6 +1,6 @@ --- title: "Docker Compose" -description: "How to use Infisical to inject environment variables into services defined in your Docker Compose file." +description: "Find out how to use Infisical to inject environment variables into services defined in your Docker Compose file." --- Prerequisites: @@ -11,46 +11,109 @@ Prerequisites: Follow this [guide](./docker) to configure the Infisical CLI for each service that you wish to inject environment variables into; you'll have to update the Dockerfile of each service. -## Generate service token + + + ### Generate and configure machine identity + Generate a machine identity for each service you want to inject secrets into. You can do this by following the steps in the [Machine Identity](/documentation/platform/identities/machine-identities) guide. -Generate a unique [Infisical Token](/documentation/platform/token) for each service. + ### Set the machine identity client ID and client secret as environment variables + For each service you want to inject secrets into, set two environment variable called `INFISICAL_MACHINE_IDENTITY_CLIENT_ID`, and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET` equal to the client ID and client secret of the machine identity(s) you created in the previous step. -## Feed service token to your Docker Compose file + In the example below, we set two sets of client ID and client secret for the services. -For each service you want to inject secrets into, set an environment variable called `INFISICAL_TOKEN` equal to a unique identifier variable. + For the web service we set `INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_WEB` and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_WEB` as the client ID and client secret respectively. -In the example below, we set `INFISICAL_TOKEN_FOR_WEB` and `INFISICAL_TOKEN_FOR_API` as the `INFISICAL_TOKEN` for the services. + For the API service we set `INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_API` and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_API` as the client ID and client secret respectively. -```yaml -# Example Docker Compose file -services: - web: - build: . - image: example-service-1 - environment: - - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_WEB} + ```yaml + # Example Docker Compose file + services: + web: + build: . + image: example-service-1 + environment: + - INFISICAL_MACHINE_IDENTITY_CLIENT_ID=${INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_WEB} + - INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET=${INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_WEB} - api: - build: . - image: example-service-2 - environment: - - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_API} -``` + api: + build: . + image: example-service-2 + environment: + - INFISICAL_MACHINE_IDENTITY_CLIENT_ID=${INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_API} + - INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET=${INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_API} -## Export shell variables + ``` -Next, set the shell variables you defined in your compose file. This can be done manually or via your CI/CD environment. Once done, it will be used to populate the corresponding `INFISICAL_TOKEN` -in your Docker Compose file. + ### Export shell variables + Next, set the shell variables you defined in your compose file. This can be done manually or via your CI/CD environment. Once done, it will be used to populate the corresponding `INFISICAL_MACHINE_IDENTITY_CLIENT_ID` and `INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET` in your Docker Compose file. -```bash -#Example + ```bash + #Example -# Token refers to the token we generated in step 2 for this service -export INFISICAL_TOKEN_FOR_WEB= + # Token refers to the token we generated in step 2 for this service + export INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_WEB= + export INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_WEB= -# Token refers to the token we generated in step 2 for this service -export INFISICAL_TOKEN_FOR_API= + # Token refers to the token we generated in step 2 for this service + export INFISICAL_MACHINE_IDENTITY_CLIENT_ID_FOR_API= + export INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET_FOR_API= -# Then run your compose file in the same terminal. -docker-compose ... -``` + # Then run your compose file in the same terminal. + docker-compose ... + ``` + + + + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + + + ## Generate service token + Generate a unique [Service Token](/documentation/platform/token) for each service. + + ## Feed service token to your Docker Compose file + + For each service you want to inject secrets into, set an environment variable called `INFISICAL_TOKEN` equal to a unique identifier variable. + + In the example below, we set `INFISICAL_TOKEN_FOR_WEB` and `INFISICAL_TOKEN_FOR_API` as the `INFISICAL_TOKEN` for the services. + + ```yaml + # Example Docker Compose file + services: + web: + build: . + image: example-service-1 + environment: + - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_WEB} + + api: + build: . + image: example-service-2 + environment: + - INFISICAL_TOKEN=${INFISICAL_TOKEN_FOR_API} + ``` + + ## Export shell variables + + Next, set the shell variables you defined in your compose file. This can be done manually or via your CI/CD environment. Once done, it will be used to populate the corresponding `INFISICAL_TOKEN` + in your Docker Compose file. + + ```bash + #Example + + # Token refers to the token we generated in step 2 for this service + export INFISICAL_TOKEN_FOR_WEB= + + # Token refers to the token we generated in step 2 for this service + export INFISICAL_TOKEN_FOR_API= + + # Then run your compose file in the same terminal. + docker-compose ... + ``` + + + diff --git a/docs/integrations/platforms/docker-intro.mdx b/docs/integrations/platforms/docker-intro.mdx index 5f23584c8..bec0f4213 100644 --- a/docs/integrations/platforms/docker-intro.mdx +++ b/docs/integrations/platforms/docker-intro.mdx @@ -1,25 +1,25 @@ --- title: "Docker" -description: "Learn how to feed secrets from Infisical into your docker application" +description: "Learn how to feed secrets from Infisical into your Docker application." --- -There are many methods to inject Infisical secrets to docker-based applications. -Regardless of which method you choose, these methods will inject secrets from Infisical as environment variables into your Docker container. +There are many methods to inject Infisical secrets into Docker-based applications. +Regardless of the method you choose, they all inject secrets from Infisical as environment variables into your Docker container. Install and run your app start command with Infisical CLI - Feed secrets via `--env-file` flag in docker run command + Feed secrets with the `--env-file` flag when using the + `docker run` command - Inject secrets to multiple services using Docker Compose + Inject secrets into multiple services using Docker Compose The main difference between the "Docker Entrypoint" and "Docker run" approach is where the Infisical CLI is installed. -In most production settings, it's typically inconvenient to have the Infisical CLI installed and executed externally. -As a result, we suggest using the "Docker Entrypoint" method for production purposes. +In most production settings, it's typically less convenient to have the Infisical CLI installed and executed externally, so we suggest using the "Docker Entrypoint" method for production purposes. However, if this limitation doesn't apply to you, select the method that best fits your needs. \ No newline at end of file diff --git a/docs/integrations/platforms/docker-pass-envs.mdx b/docs/integrations/platforms/docker-pass-envs.mdx index d6451de71..cf595de3d 100644 --- a/docs/integrations/platforms/docker-pass-envs.mdx +++ b/docs/integrations/platforms/docker-pass-envs.mdx @@ -1,6 +1,6 @@ --- title: "Docker Run" -description: "Pass secrets to your docker container at run time" +description: "Learn how to pass secrets to your docker container at run time." --- This method allows you to feed secrets from Infisical into your container using the `--env-file` flag of `docker run` command. @@ -10,8 +10,11 @@ For this method to function as expected, you must have a bash shell (for process ## 1. Authentication -If you are already logged in via the CLI you can skip this step. Otherwise, head to your project settings in Infisical Cloud to generate an [Infisical Token](/documentation/platform/token). The service token will allow you to authenticate and fetch secrets from Infisical. -Once you have created a service token with the required permissions, you'll need to feed the token to the CLI. +If you are already logged in via the CLI you can skip this step. Otherwise, head to your organization settings in Infisical Cloud to create a [Machine Identity](../../documentation/platform/identities/machine-identities). The machine identity will allow you to authenticate and fetch secrets from Infisical. +Once you have created a machine identity with the required permissions, you'll need to feed the token to the CLI. + + Please note that we highly recommend using `infisical login` for local development. + #### Pass as flag You may use the --token flag to set the token @@ -27,8 +30,14 @@ The CLI is configured to look for an environment variable named `INFISICAL_TOKEN export INFISICAL_TOKEN=<> ``` +You can use the `infisical login --method=universal-auth` command to directly obtain a universal auth access token and set it as an environment variable. + +```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) +``` + - In production scenarios, please to avoid using the `infisical login` command and instead use a [service token](/documentation/platform/token). + In production scenarios, please to avoid using the `infisical login` command and instead use a [machine identity](../../documentation/platform/identities/machine-identities). ## 2. Run your docker command with Infisical diff --git a/docs/integrations/platforms/docker-swarm-with-agent.mdx b/docs/integrations/platforms/docker-swarm-with-agent.mdx new file mode 100644 index 000000000..30118a8f0 --- /dev/null +++ b/docs/integrations/platforms/docker-swarm-with-agent.mdx @@ -0,0 +1,164 @@ +--- +title: 'Docker Swarm' +description: "Learn how to manage secrets in Docker Swarm services." +--- + +In this guide, we'll demonstrate how to use Infisical for managing secrets within Docker Swarm. +Specifically, we'll set up a sidecar container using the [Infisical Agent](/infisical-agent/overview), which authenticates with Infisical to retrieve secrets and access tokens. +These secrets are then stored in a shared volume accessible by other services in your Docker Swarm. + +## Prerequisites +- Infisical account +- Docker version 20.10.24 or newer +- Basic knowledge of Docker Swarm +- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your system +- Familiarity with the [Infisical Agent](/infisical-agent/overview) + +## Objective +Our goal is to deploy an Nginx instance in your Docker Swarm cluster, configured to display Infisical secrets on its landing page. This will provide hands-on experience in fetching and utilizing secrets from Infisical within Docker Swarm. The principles demonstrated here are also applicable to Docker Compose deployments. + + + + Start by cloning the [Infisical guide assets repository](https://github.com/Infisical/infisical-guides.git) from Github. This repository includes necessary assets for this and other Infisical guides. Focus on the `docker-swarm-with-agent` sub-directory, which we'll use as our working directory. + + + + To allow the agent to fetch your Infisical secrets, choose an authentication method for the agent. For this guide, we will use [Universal Auth](/documentation/platform/identities/universal-auth) for authentication. Follow the instructions [here](/documentation/platform/identities/universal-auth) to generate a client ID and client secret. + + + + Copy the client ID and client secret obtained in the previous step into the `client-id` and `client-secret` text files, respectively. + + + + The Infisical Agent will authenticate using Universal Auth and retrieve secrets for rendering as specified in the template(s). + Adjust the `polling-interval` to control the frequency of secret updates. + + In the example template, the secrets are rendered as an HTML page, which will be set as Nginx's home page to demonstrate successful secret retrieval and utilization. + + + Remember to add your project id, environment slug and path of corresponding Infisical project to the secret template. + + + + ```yaml infisical-agent-config + infisical: + address: "https://app.infisical.com" + auth: + type: "universal-auth" + config: + client-id: "/run/secrets/infisical-universal-auth-client-id" + client-secret: "/run/secrets/infisical-universal-auth-client-secret" + remove_client_secret_on_read: false + sinks: + - type: "file" + config: + path: "/infisical-secrets/access-token" + templates: + - source-path: /run/secrets/nginx-home-page-template + destination-path: /infisical-secrets/index.html + config: + polling-interval: 60s + ``` + + Some paths contain `/run/secrets/` because the contents of those files reside in a [Docker secret](https://docs.docker.com/engine/swarm/secrets/#how-docker-manages-secrets). + + + + ```html nginx-home-page-template + + + +

This file is rendered by Infisical agent template engine

+

Here are the secrets that have been fetched from Infisical and stored in your volume mount

+
    + {{- with secret "7df67a5f-d26a-4988-a375-7153c08149da" "dev" "/" }} + {{- range . }} +
  1. {{ .Key }}={{ .Value }}
  2. + {{- end }} + {{- end }} +
+ + + ``` +
+
+
+ + + Define the `infisical-agent` and `nginx` services in your Docker Compose file. `infisical-agent` will handle secret retrieval and storage. These secrets are stored in a volume, accessible by other services like Nginx. + + ```yaml docker-compose.yaml + version: "3.1" + + services: + infisical-agent: + container_name: infisical-agnet + image: infisical/cli:0.18.0 + command: agent --config=/run/secrets/infisical-agent-config + volumes: + - infisical-agent:/infisical-secrets + secrets: + - infisical-universal-auth-client-id + - infisical-universal-auth-client-secret + - infisical-agent-config + - nginx-home-page-template + networks: + - infisical_network + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - infisical-agent:/usr/share/nginx/html + networks: + - infisical_network + + volumes: + infisical-agent: + + secrets: + infisical-universal-auth-client-id: + file: ./client-id + infisical-universal-auth-client-secret: + file: ./client-secret + infisical-agent-config: + file: ./infisical-agent-config + nginx-home-page-template: + file: ./nginx-home-page-template + + + networks: + infisical_network: + ``` + + + + ``` + docker swarm init + ``` + + + + ``` + docker stack deploy -c docker-compose.yaml agent-demo + ``` + + + + To confirm that secrets are properly rendered and accessible, navigate to `http://localhost`. You should see the Infisical secrets displayed on the Nginx landing page. + + ![Nginx displaying Infisical secrets](/images/docker-swarm-secrets-complete.png) + + + + ``` + docker stack rm agent-demo + ``` + +
+ +## Considerations +- Secret Updates: Applications that access secrets directly from the volume mount will receive updates in real-time, in accordance with the `polling-interval` set in agent config. +- In-Memory Secrets: If your application loads secrets into memory, the new secrets will be available to the application on the next deployment. diff --git a/docs/integrations/platforms/docker.mdx b/docs/integrations/platforms/docker.mdx index e9682b785..e858ddad0 100644 --- a/docs/integrations/platforms/docker.mdx +++ b/docs/integrations/platforms/docker.mdx @@ -1,6 +1,6 @@ --- title: "Docker Entrypoint" -description: "How to use Infisical to inject environment variables into a Docker container." +description: "Learn how to use Infisical to inject environment variables into a Docker container." --- This approach allows you to inject secrets from Infisical directly into your application. @@ -41,6 +41,54 @@ This is achieved by installing the Infisical CLI into your docker image and modi Starting your service with the Infisical CLI pulls your secrets from Infisical and injects them into your service. + + + ```dockerfile + CMD ["infisical", "run", "--projectId", "", "--", "[your service start command]"] + +# example with single single command + +CMD ["infisical", "run", "--projectId", "", "--", "npm", "run", "start"] + +# example with multiple commands + +CMD ["infisical", "run", "--projectId", "", "--command", "npm run start && ..."] + +```` + + + + Generate a machine identity for your project by following the steps in the [Machine Identity](/documentation/platform/identities/machine-identities) guide. The machine identity will allow you to authenticate and fetch secrets from Infisical. + + + Obtain an access token for the machine identity by running the following command: + ```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --plain --silent) + ``` + + + Please note that the access token has a limited lifespan. The `infisical token renew` command can be used to renew the token if needed. + + + + The last step is to give the Infisical CLI installed in your Docker container access to the access token. This will allow the CLI to fetch and inject the secrets into your application. + + To feed the access token to the container, use the INFISICAL_TOKEN environment variable as shown below. + + ```bash + docker run --env INFISICAL_TOKEN=$INFISICAL_TOKEN [DOCKER-IMAGE]... + ``` + + + + + + +Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + ```dockerfile CMD ["infisical", "run", "--", "[your service start command]"] @@ -49,19 +97,24 @@ CMD ["infisical", "run", "--", "npm", "run", "start"] # example with multiple commands CMD ["infisical", "run", "--command", "npm run start && ..."] -``` +```` -## Generate an service token + + + Head to your project settings in the Infisical dashboard to generate an [service token](/documentation/platform/token). + This service token will allow you to authenticate and fetch secrets from Infisical. + Once you have created a service token with the required permissions, you’ll need to feed the token to the CLI installed in your docker container. + + + The last step is to give the Infisical CLI installed in your Docker container access to the service token. This will allow the CLI to fetch and inject the secrets into your application. -Head to your project settings in the Infisical dashboard to generate an [service token](/documentation/platform/token). -This service token will allow you to authenticate and fetch secrets from Infisical. -Once you have created a service token with the required permissions, you’ll need to feed the token to the CLI installed in your docker container. + To feed the service token to the container, use the INFISICAL_TOKEN environment variable as shown below. -## Feed service token to docker container -The last step is to give the Infisical CLI installed in your Docker container access to the service token. This will allow the CLI to fetch and inject the secrets into your application. + ```bash + docker run --env INFISICAL_TOKEN=[token] [DOCKER-IMAGE]... + ``` + -To feed the service token to the container, use the INFISICAL_TOKEN environment variable as shown below. - -```bash - docker run --env INFISICAL_TOKEN=[token] [DOCKER-IMAGE]... -``` + + + diff --git a/docs/integrations/platforms/ecs-with-agent.mdx b/docs/integrations/platforms/ecs-with-agent.mdx new file mode 100644 index 000000000..43760d2bb --- /dev/null +++ b/docs/integrations/platforms/ecs-with-agent.mdx @@ -0,0 +1,287 @@ +--- +title: 'Amazon ECS' +description: "Learn how to deliver secrets to Amazon Elastic Container Service." +--- + +![ecs diagram](/images/guides/agent-with-ecs/ecs-diagram.png) + +This guide will go over the steps needed to access secrets stored in Infisical from Amazon Elastic Container Service (ECS). + +At a high level, the steps involve setting up an ECS task with a [Infisical Agent](/infisical-agent/overview) as a sidecar container. This sidecar container uses [Universal Auth](/documentation/platform/identities/universal-auth) to authenticate with Infisical to fetch secrets/access tokens. +Once the secrets/access tokens are retrieved, they are then stored in a shared [Amazon Elastic File System](https://aws.amazon.com/efs/) (EFS) volume. This volume is then made accessible to your application and all of its replicas. + +This guide primarily focuses on integrating Infisical Cloud with Amazon ECS on AWS Fargate and Amazon EFS. +However, the principles and steps can be adapted for use with any instance of Infisical (on premise or cloud) and different ECS launch configurations. + +## Prerequisites +This guide requires the following prerequisites: +- Infisical account +- Git installed +- Terraform v1.0 or later installed +- Access to AWS credentials +- Understanding of [Infisical Agent](/infisical-agent/overview) + +## What we will deploy +For this demonstration, we'll deploy the [File Browser](https://github.com/filebrowser/filebrowser) application on our ECS cluster. +Although this guide focuses on File Browser, the principles outlined here can be applied to any application of your choice. + +File Browser plays a key role in this context because it enables us to view all files attached to a specific volume. +This feature is important for our demonstration, as it allows us to verify whether the Infisical agent is depositing the expected files into the designated file volume and if those files are accessible to the application. + + +Volumes that contain sensitive secrets should not be publicly accessible. The use of File Browser here is solely for demonstration and verification purposes. + + + +## Configure Authentication with Infisical +In order for the Infisical agent to fetch credentials from Infisical, we'll first need to authenticate with Infisical. +While Infisical supports various authentication methods, this guide focuses on using Universal Auth to authenticate the agent with Infisical. + +Follow the documentation to configure and generate a client id and client secret with Universal auth [here](/documentation/platform/identities/universal-auth). +Make sure to save these credentials somewhere handy because you'll need them soon. + +## Clone guide assets repository +To help you quickly deploy the example application, please clone the guide assets from this [Github repository](https://github.com/Infisical/infisical-guides.git). +This repository contains assets for all Infisical guides. The content for this guide can be found within a sub directory called `aws-ecs-with-agent`. +The guide will assume that `aws-ecs-with-agent` is your working directory going forward. + +## Deploy example application + +Before we can deploy our full application and its related infrastructure with Terraform, we'll need to first configure our Infisical agent. + +### Agent configuration overview +The agent config file defines what authentication method will be used when connecting with Infisical along with where the fetched secrets/access tokens should be saved to. + +Since the Infisical agent will be deployed as a sidecar, the agent configuration file and any secret template files will need to be encoded in base64. +This encoding step is necessary as it allows these files to be added into our Terraform configuration file without needing to upload them first. + +#### Secret template file +The Infisical agent accepts one or more optional template files. If provided, the agent will fetch secrets using the set authentication method and format the fetched secrets according to the given template file. + +For demonstration purposes, we will create the following secret template file. +This template will transform our secrets from Infisical project with the ID `62fd92aa8b63973fee23dec7`, in the `dev` environment, and secrets located in the path `/`, into a `KEY=VALUE` format. + + + Remember to update the project id, environment slug and secret path to one that exists within your Infisical project + + +```secrets.template secrets.template +{{- with secret "62fd92aa8b63973fee23dec7" "dev" "/" }} +{{- range . }} +{{ .Key }}={{ .Value }} +{{- end }} +{{- end }} +``` + +Next, we need encode this template file in `base64` so it can be set in the agent configuration file. + +```bash +cat secrets.template | base64 +Cnt7LSB3aXRoIHNlY3JldCAiMWVkMjk2MWQtNDM5NS00MmNlLTlkNzQtYjk2ZGQwYmYzMDg0IiAiZGV2IiAiLyIgfX0Ke3stIHJhbmdlIC4gfX0Ke3sgLktleSB9fT17eyAuVmFsdWUgfX0Ke3stIGVuZCB9fQp7ey0gZW5kIH19 +``` + +#### Full agent configuration file +This agent config file will connect with Infisical Cloud using Universal Auth and deposit access tokens at path `/infisical-agent/access-token` and render secrets to file `/infisical-agent/secrets`. + +You'll notice that instead of passing the path to the secret template file as we normally would, we set the base64 encoded template from the previous step under `base64-template-content` property. + +```yaml agent-config.yaml +infisical: + address: https://app.infisical.com + exit-after-auth: true +auth: + type: universal-auth + config: + remove_client_secret_on_read: false +sinks: + - type: file + config: + path: /infisical-agent/access-token +templates: + - base64-template-content: Cnt7LSB3aXRoIHNlY3JldCAiMWVkMjk2MWQtNDM5NS00MmNlLTlkNzQtYjk2ZGQwYmYzMDg0IiAiZGV2IiAiLyIgfX0Ke3stIHJhbmdlIC4gfX0Ke3sgLktleSB9fT17eyAuVmFsdWUgfX0Ke3stIGVuZCB9fQp7ey0gZW5kIH19 + destination-path: /infisical-agent/secrets +``` + +Again, we'll need to encode the full configuration file in `base64` so it can be easily delivered via Terraform. + +```bash +cat agent-config.yaml | base64 +aW5maXNpY2FsOgogIGFkZHJlc3M6IGh0dHBzOi8vYXBwLmluZmlzaWNhbC5jb20KICBleGl0LWFmdGVyLWF1dGg6IHRydWUKYXV0aDoKICB0eXBlOiB1bml2ZXJzYWwtYXV0aAogIGNvbmZpZzoKICAgIHJlbW92ZV9jbGllbnRfc2VjcmV0X29uX3JlYWQ6IGZhbHNlCnNpbmtzOgogIC0gdHlwZTogZmlsZQogICAgY29uZmlnOgogICAgICBwYXRoOiAvaW5maXNpY2FsLWFnZW50L2FjY2Vzcy10b2tlbgp0ZW1wbGF0ZXM6CiAgLSBiYXNlNjQtdGVtcGxhdGUtY29udGVudDogQ250N0xTQjNhWFJvSUhObFkzSmxkQ0FpTVdWa01qazJNV1F0TkRNNU5TMDBNbU5sTFRsa056UXRZamsyWkdRd1ltWXpNRGcwSWlBaVpHVjJJaUFpTHlJZ2ZYMEtlM3N0SUhKaGJtZGxJQzRnZlgwS2Uzc2dMa3RsZVNCOWZUMTdleUF1Vm1Gc2RXVWdmWDBLZTNzdElHVnVaQ0I5ZlFwN2V5MGdaVzVrSUgxOQogICAgZGVzdGluYXRpb24tcGF0aDogL2luZmlzaWNhbC1hZ2VudC9zZWNyZXRzCg== +``` + +## Add auth credentials & agent config +With the base64 encoded agent config file and Universal Auth credentials in hand, it's time to assign them as values in our Terraform config file. + +To configure these values, navigate to the `ecs.tf` file in your preferred code editor and assign values to `auth_client_id`, `auth_client_secret`, and `agent_config`. + +```hcl ecs.tf +...snip... +data "template_file" "cb_app" { + template = file("./templates/ecs/cb_app.json.tpl") + + vars = { + app_image = var.app_image + sidecar_image = var.sidecar_image + app_port = var.app_port + fargate_cpu = var.fargate_cpu + fargate_memory = var.fargate_memory + aws_region = var.aws_region + auth_client_id = "" + auth_client_secret = "" + agent_config = "" + } +} +...snip... +``` + + + To keep this guide simple, `auth_client_id`, `auth_client_secret` have been added directly into the ECS container definition. + However, in production, you should securely fetch these values from AWS Secrets Manager or AWS Parameter store and feed them directly to agent sidecar. + + +After these values have been set, they will be passed to the Infisical agent during startup through environment variables, as configured in the `infisical-sidecar` container below. + +```terraform templates/ecs/cb_app.json.tpl +[ +...snip... + { + "name": "infisical-sidecar", + "image": "${sidecar_image}", + "cpu": 1024, + "memory": 1024, + "networkMode": "bridge", + "command": ["agent"], + "essential": false, + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/agent", + "awslogs-region": "${aws_region}", + "awslogs-stream-prefix": "ecs" + } + }, + "healthCheck": { + "command": ["CMD-SHELL", "agent", "--help"], + "interval": 30, + "timeout": 5, + "retries": 3, + "startPeriod": 0 + }, + "environment": [ + { + "name": "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID", + "value": "${auth_client_id}" + }, + { + "name": "INFISICAL_UNIVERSAL_CLIENT_SECRET", + "value": "${auth_client_secret}" + }, + { + "name": "INFISICAL_AGENT_CONFIG_BASE64", + "value": "${agent_config}" + } + ], + "mountPoints": [ + { + "containerPath": "/infisical-agent", + "sourceVolume": "infisical-efs" + } + ] + } +] +``` + +In the above container definition, you'll notice that that the Infisical agent has a `mountPoints` defined. +This mount point is referencing to the already configured EFS volume as shown below. +`containerPath` is set to `/infisical-agent` because that is that the folder we have instructed the agent to deposit the credentials to. + +```hcl terraform/efs.tf +resource "aws_efs_file_system" "infisical_efs" { + tags = { + Name = "INFISICAL-ECS-EFS" + } +} + +resource "aws_efs_mount_target" "mount" { + count = length(aws_subnet.private.*.id) + file_system_id = aws_efs_file_system.infisical_efs.id + subnet_id = aws_subnet.private[count.index].id + security_groups = [aws_security_group.efs_sg.id] +} +``` + +## Configure AWS credentials +Because we'll be deploying the example file browser application to AWS via Terraform, you will need to obtain a set of `AWS Access Key` and `Secret Key`. +Once you have generated these credentials, export them to your terminal. + +1. Export the AWS Access Key ID: + + ```bash + export AWS_ACCESS_KEY_ID= + ``` + +2. Export the AWS Secret Access Key: + + ```bash + export AWS_SECRET_ACCESS_KEY= + ``` + +## Deploy terraform configuration +With the agent's sidecar configuration complete, we can now deploy our changes to AWS via Terraform. + +1. Change your directory to `terraform` +```sh +cd terraform +``` + +2. Initialize Terraform +``` +$ terraform init +``` + +3. Preview resources that will be created +``` +$ terraform plan +``` + +4. Trigger resource creation +```bash +$ terraform apply + +Do you want to perform these actions? + Terraform will perform the actions described above. + Only 'yes' will be accepted to approve. + + Enter a value: yes +``` + +```bash + +Apply complete! Resources: 1 added, 1 changed, 1 destroyed. + +Outputs: + +alb_hostname = "cb-load-balancer-1675475779.us-east-1.elb.amazonaws.com:8080" +``` + +Once the resources have been successfully deployed, Terrafrom will output the host address where the file browser application will be accessible. +It may take a few minutes for the application to become fully ready. + + +## Verify secrets/tokens in EFS volume +To verify that the agent is depositing access tokens and rendering secrets to the paths specified in the agent config, navigate to the web address from the previous step. +Once you visit the address, you'll be prompted to login. Enter the credentials shown below. + +![file browser main login page](/images/guides/agent-with-ecs/file_browser_main.png) + +Since our EFS volume is mounted to the path of the file browser application, we should see the access token and rendered secret file we defined via the agent config file. + +![file browswer dashbaord](/images/guides/agent-with-ecs/filebrowser_afterlogin.png) + +As expected, two files are present: `access-token` and `secrets`. +The `access-token` file should hold a valid `Bearer` token, which can be used to make HTTP requests to Infisical. +The `secrets` file should contain secrets, formatted according to the specifications in our secret template file (presented in key=value format). + +![file browser access token deposit](/images/guides/agent-with-ecs/access-token-deposit.png) + +![file browser secrets render](/images/guides/agent-with-ecs/secrets-deposit.png) \ No newline at end of file diff --git a/docs/infisical-agent/overview.mdx b/docs/integrations/platforms/infisical-agent.mdx similarity index 91% rename from docs/infisical-agent/overview.mdx rename to docs/integrations/platforms/infisical-agent.mdx index c98090289..1516ae045 100644 --- a/docs/infisical-agent/overview.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -1,11 +1,12 @@ --- title: "Infisical Agent" +description: "This page describes how to manage secrets using Infisical Agent." --- Infisical Agent is a client daemon that simplifies the adoption of Infisical by providing a more scalable and user-friendly approach for applications to interact with Infisical. It eliminates the need to modify application logic by enabling clients to decide how they want their secrets rendered through the use of templates. - +![agent diagram](/images/agent/infisical-agent-diagram.png) ### Key features: - Token renewal: Automatically authenticates with Infisical and deposits renewed access tokens at specified path for applications to consume @@ -51,6 +52,9 @@ While specifying an authentication method is mandatory to start the agent, confi | `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | | `templates[].source-path` | The path to the template file that should be used to render secrets. | | `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | +| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `5 minutes` (optional) | +| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | +| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | ## Quick start Infisical Agent @@ -76,6 +80,11 @@ sinks: templates: - source-path: my-dot-ev-secret-template destination-path: /some/path/.env + config: + polling-interval: 60s + execute: + timeout: 30 + command: ./reload-app.sh ``` Above is an example agent configuration file that defines the token authentication method, one sink location (where to deposit access tokens after renewal) and a secret template. diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 7ddb616b2..3d1b72331 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -1,18 +1,17 @@ --- -title: 'Kubernetes' +title: "Kubernetes" description: "How to use Infisical to inject secrets into Kubernetes clusters." --- ![title](../../images/k8-diagram.png) - -The Infisical Secrets Operator is a Kubernetes controller that retrieves secrets from Infisical and stores them in a designated cluster. +The Infisical Secrets Operator is a Kubernetes controller that retrieves secrets from Infisical and stores them in a designated cluster. It uses an `InfisicalSecret` resource to specify authentication and storage methods. The operator continuously updates secrets and can also reload dependent deployments automatically. ## Install Operator -The operator can be install via [Helm](helm.sh) or [kubectl](https://github.com/kubernetes/kubectl) +The operator can be install via [Helm](https://helm.sh) or [kubectl](https://github.com/kubernetes/kubectl) @@ -26,8 +25,8 @@ The operator can be install via [Helm](helm.sh) or [kubectl](https://github.com/ **Install the Helm chart** For production deployments, it is highly recommended to set the chart version and the application version during installs and upgrades. - This will prevent the operator from being accidentally updated to the latest version and introduce unintended breaking changes. - + This will prevent the operator from being accidentally updated to the latest version and introduce unintended breaking changes. + View application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags) and chart versions [here](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/secrets-operator/#versions) ```bash @@ -42,50 +41,72 @@ The operator can be install via [Helm](helm.sh) or [kubectl](https://github.com/ For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. Doing so will help you avoid accidental updates to the newest release which may introduce unintended breaking changes. View all application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags). +The command below will install the most recent version of the Kubernetes operator. +However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version. - The command below will install the most recent version of the Kubernetes operator. - However, to set the version manually, download the manifest and set the image tag version of `infisical/kubernetes-operator` according to your desired version. - - Once you apply the manifest, the operator will be installed in `infisical-operator-system` namespace. +Once you apply the manifest, the operator will be installed in `infisical-operator-system` namespace. ``` kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical/main/k8-operator/kubectl-install/install-secrets-operator.yaml - ``` + ``` + ## Sync Infisical Secrets to your cluster -Once you have installed the operator to your cluster, you'll need to create a `InfisicalSecret` custom resource definition (CRD). + +Once you have installed the operator to your cluster, you'll need to create a `InfisicalSecret` custom resource definition (CRD). ```yaml example-infisical-secret-crd.yaml apiVersion: secrets.infisical.com/v1alpha1 kind: InfisicalSecret metadata: - # Name of of this InfisicalSecret resource name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" spec: - # The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used hostAPI: https://app.infisical.com/api - resyncInterval: 60 + resyncInterval: 10 authentication: + # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. + # If you have multiple authentication methods defined, it may cause issues. + universalAuth: + secretsScope: + projectSlug: + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: "" # Root is "/" + recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. + + credentialsRef: + secretName: universal-auth-credentials + secretNamespace: default + + # Service tokens are deprecated and will be removed in the near future. Please use Machine Identities for authenticating with Infisical. serviceToken: serviceTokenSecretReference: secretName: service-token secretNamespace: default secretsScope: - envSlug: dev - secretsPath: "/" + envSlug: + secretsPath: # Root is "/" + recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. + managedSecretReference: - secretName: managed-secret # <-- the name of kubernetes secret that will be created - secretNamespace: default # <-- where the kubernetes secret should be created + secretName: managed-secret + secretNamespace: default + creationPolicy: "Orphan" ## Owner | Orphan (default) + # secretType: kubernetes.io/dockerconfigjson ``` + ### InfisicalSecret CRD properties If you are fetching secrets from a self hosted instance of Infisical set the value of `hostAPI` to ` https://your-self-hosted-instace.com/api` - When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. +When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. If you have installed your Infisical instance within the same cluster as the Infisical operator, you can optionally access the Infisical backend's service directly without having to route through the public internet. @@ -96,68 +117,131 @@ spec: ``` Make sure to replace `` and `` with the appropriate values for your backend service and namespace. + -This property defines the time in seconds between each secret re-sync from Infisical. Shorter time between re-syncs will require higher rate limits only available on paid plans. -Default re-sync interval is every 1 minute. + This property defines the time in seconds between each secret re-sync from + Infisical. Shorter time between re-syncs will require higher rate limits only + available on paid plans. Default re-sync interval is every 1 minute. - This block defines the method that will be used to authenticate with Infisical so that secrets can be fetched. Currently, only [Service Tokens](../../documentation/platform/token) can be used to authenticate with Infisical. + This block defines the method that will be used to authenticate with Infisical + so that secrets can be fetched - - The service token required to authenticate with Infisical needs to be stored in a Kubernetes secret. This block defines the reference to the name and name space of secret that stores this service token. - Follow the instructions below to create and store the service token in a Kubernetes secrets and reference it in your CRD. + + The universal machine identity authentication method is used to authenticate with Infisical. The client ID and client secret needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores these credentials. - #### 1. Generate service token + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about machine identities here](/documentation/platform/identities/universal-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to create a Kubernetes secret containing the identity credentials. + To quickly create a Kubernetes secret containing the identity credentials, you can run the command below. + + Make sure you replace `` with the identity client ID and `` with the identity client secret. - You can generate a [service token](../../documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. + ``` bash + kubectl create secret generic universal-auth-credentials --from-literal=clientId="" --from-literal=clientSecret="" + ``` + - #### 2. Create Kubernetes secret containing service token + + Once the secret is created, add the `secretName` and `secretNamespace` of the secret that was just created under `authentication.universalAuth.credentialsRef` field in the InfisicalSecret resource. + - Once you have generated the service token, you will need to create a Kubernetes secret containing the service token you generated. - To quickly create a Kubernetes secret containing the generated service token, you can run the command below. Make sure you replace `` with your service token. + - ``` bash - kubectl create secret generic service-token --from-literal=infisicalToken= - ``` +{" "} - #### 3. Add reference for the Kubernetes secret containing service token + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + - Once the secret is created, add the name and namespace of the secret that was just created under `authentication.serviceToken.serviceTokenSecretReference` field in the InfisicalSecret resource. +## Example + +```yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + universalAuth: + secretsScope: + projectSlug: # <-- project slug + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: "" # Root is "/" + credentialsRef: + secretName: universal-auth-credentials # <-- name of the Kubernetes secret that stores our machine identity credentials + secretNamespace: default # <-- namespace of the Kubernetes secret that stores our machine identity credentials + ... +``` - ## Example - ```yaml - apiVersion: secrets.infisical.com/v1alpha1 - kind: InfisicalSecret - metadata: - name: infisicalsecret-sample-crd - spec: - authentication: - serviceToken: - serviceTokenSecretReference: - secretName: service-token # <-- name of the Kubernetes secret that stores our service token - secretNamespace: option # <-- namespace of the Kubernetes secret that stores our service token - ... - ``` - - This block defines the scope of what secrets should be fetched. This is needed as your service token can have access to multiple folders and environments. - A scope is defined by `envSlug` and `secretsPath`. - - #### envSlug + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). - This refers to the short hand name of an environment. For example for the `development` environment the environment slug is `dev`. You can locate the slug of your environment by heading to your project settings in the Infisical dashboard. +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). - #### secretsPath - - secretsPath is the path to the secret in the given environment. For example a path of `/` would refer to the root of the environment whereas `/folder1` would refer to the secrets in folder1 from the root. + + +The service token required to authenticate with Infisical needs to be stored in a Kubernetes secret. This block defines the reference to the name and namespace of secret that stores this service token. +Follow the instructions below to create and store the service token in a Kubernetes secrets and reference it in your CRD. + +#### 1. Generate service token + +You can generate a [service token](../../documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. + +#### 2. Create Kubernetes secret containing service token + +Once you have generated the service token, you will need to create a Kubernetes secret containing the service token you generated. +To quickly create a Kubernetes secret containing the generated service token, you can run the command below. Make sure you replace `` with your service token. + +```bash +kubectl create secret generic service-token --from-literal=infisicalToken="" +``` + +#### 3. Add reference for the Kubernetes secret containing service token + +Once the secret is created, add the name and namespace of the secret that was just created under `authentication.serviceToken.serviceTokenSecretReference` field in the InfisicalSecret resource. + +{" "} + + + Make sure to also populate the `secretsScope` field with the, environment slug + _`envSlug`_, and secrets path _`secretsPath`_ that you want to fetch secrets + from. Please see the example below. + + +## Example + +```yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + serviceToken: + serviceTokenSecretReference: + secretName: service-token # <-- name of the Kubernetes secret that stores our service token + secretNamespace: option # <-- namespace of the Kubernetes secret that stores our service token + secretsScope: + envSlug: # "dev", "staging", "prod", etc.. + secretsPath: # Root is "/" + ... +``` - Both fields are required. @@ -177,10 +261,25 @@ The namespace of the managed Kubernetes secret to be created. Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets. + +Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator. +This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically. -### Propagating labels & annotations +#### Available options -The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created. +- `Orphan` (default) +- `Owner` + + + When creation policy is set to `Owner`, the `InfisicalSecret` CRD must be in + the same namespace as where the managed kubernetes secret. + + + + +### Propagating labels & annotations + +The operator will transfer all labels & annotations present on the `InfisicalSecret` CRD to the managed Kubernetes secret to be created. Thus, if a specific label is required on the resulting secret, it can be applied as demonstrated in the following example: @@ -205,8 +304,7 @@ This would result in the following managed secret to be created: ```yaml apiVersion: v1 -data: - ... +data: ... kind: Secret metadata: annotations: @@ -218,20 +316,21 @@ metadata: namespace: default type: Opaque ``` + +### Apply the Infisical CRD to your cluster -### Apply the Infisical CRD to your cluster -Once you have configured the Infisical CRD with the required fields, you can apply it to your cluster. +Once you have configured the Infisical CRD with the required fields, you can apply it to your cluster. After applying, you should notice that the managed secret has been created in the desired namespace your specified. ``` kubectl apply -f example-infisical-secret-crd.yaml ``` -### Verify managed secret creation +### Verify managed secret creation -To verify that the operator has successfully created the managed secret, you can check the secrets in the namespace that was specified. +To verify that the operator has successfully created the managed secret, you can check the secrets in the namespace that was specified. ```bash # Verify managed secret is created @@ -243,48 +342,49 @@ kubectl get secrets -n 1 minutes. -### Using managed secret in your deployment -Incorporating the managed secret created by the operator into your deployment can be achieved through several methods. +### Using managed secret in your deployment + +Incorporating the managed secret created by the operator into your deployment can be achieved through several methods. Here, we will highlight three of the most common ways to utilize it. Learn more about Kubernetes secrets [here](https://kubernetes.io/docs/concepts/configuration/secret/) - This will take all the secrets from your managed secret and expose them to your container + This will take all the secrets from your managed secret and expose them to your container - ```yaml - envFrom: - - secretRef: - name: managed-secret # managed secret name - ``` - - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: - app: nginx - spec: - replicas: 1 - selector: - matchLabels: - app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - envFrom: - - secretRef: - name: managed-secret # <- name of managed secret - ports: - - containerPort: 80 +````yaml + envFrom: + - secretRef: + name: managed-secret # managed secret name ``` - + Example usage in a deployment + ```yaml + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + envFrom: + - secretRef: + name: managed-secret # <- name of managed secret + ports: + - containerPort: 80 +```` + + This will allow you to select individual secrets by key name from your managed secret and expose them to your container @@ -298,95 +398,101 @@ Here, we will highlight three of the most common ways to utilize it. Learn more key: SOME_SECRET_KEY # The name of the key which exists in the managed secret ``` - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: - app: nginx - spec: - replicas: 1 - selector: - matchLabels: - app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.14.2 - env: - - name: STRIPE_API_SECRET - valueFrom: - secretKeyRef: - name: managed-secret # <- name of managed secret - key: STRIPE_API_SECRET - ports: - - containerPort: 80 - ``` +Example usage in a deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: +name: nginx-deployment +labels: +app: nginx +spec: +replicas: 1 +selector: +matchLabels: +app: nginx +template: +metadata: +labels: +app: nginx +spec: +containers: - name: nginx +image: nginx:1.14.2 +env: - name: STRIPE_API_SECRET +valueFrom: +secretKeyRef: +name: managed-secret # <- name of managed secret +key: STRIPE_API_SECRET +ports: - containerPort: 80 + +``` + - This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret - ```yaml - volumes: - - name: secrets-volume-name # The name of the volume under which secrets will be stored - secret: - secretName: managed-secret # managed secret name - ``` +This will allow you to create a volume on your container which comprises of files holding the secrets in your managed kubernetes secret +```yaml +volumes: + - name: secrets-volume-name # The name of the volume under which secrets will be stored + secret: + secretName: managed-secret # managed secret name +```` - You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - ```yaml - volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true - ``` +You can then mount this volume to the container's filesystem so that your deployment can access the files containing the managed secrets - Example usage in a deployment - ```yaml - apiVersion: apps/v1 - kind: Deployment - metadata: - name: nginx-deployment - labels: +```yaml +volumeMounts: + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true +``` + +Example usage in a deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: app: nginx - spec: - replicas: 1 - selector: - matchLabels: + template: + metadata: + labels: app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: + spec: + containers: - name: nginx image: nginx:1.14.2 volumeMounts: - - name: secrets-volume-name - mountPath: /etc/secrets - readOnly: true + - name: secrets-volume-name + mountPath: /etc/secrets + readOnly: true ports: - - containerPort: 80 - volumes: + - containerPort: 80 + volumes: - name: secrets-volume-name secret: secretName: managed-secret # <- managed secrets - ``` +``` + -## Auto redeployment -Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed. +## Auto redeployment + +Deployments using managed secrets don't reload automatically on updates, so they may use outdated secrets unless manually redeployed. To address this, we added functionality to automatically redeploy your deployment when its managed secret updates. -### Enabling auto redeploy +### Enabling auto redeploy + To enable auto redeployment you simply have to add the following annotation to the deployment that consumes a managed secret + ```yaml secrets.infisical.com/auto-reload: "true" ``` @@ -422,18 +528,20 @@ spec: ``` -## Global configuration -To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. +## Global configuration + +To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. For example, you can configure all `InfisicalSecret` instances to fetch secrets from a single backend API without specifying the `hostAPI` parameter for each instance. ### Available global properties -| Property | Description | Default value -| -------- | ------------------------------------- |------------------------ -| hostAPI | If `hostAPI` in `InfisicalSecret` instance is left empty, this value will be used | https://app.infisical.com/api +| Property | Description | Default value | +| -------- | --------------------------------------------------------------------------------- | ----------------------------- | +| hostAPI | If `hostAPI` in `InfisicalSecret` instance is left empty, this value will be used | https://app.infisical.com/api | ### Applying global configurations -All global configurations must reside in a Kubernetes ConfigMap named `infisical-config` in the namespace `infisical-operator-system`. + +All global configurations must reside in a Kubernetes ConfigMap named `infisical-config` in the namespace `infisical-operator-system`. To apply global configuration to the operator, copy the following yaml into `infisical-config.yaml` file. ```yaml infisical-config.yaml @@ -451,13 +559,12 @@ data: hostAPI: https://example.com/api # <-- global hostAPI ``` -Then apply this change via kubectl by running the following +Then apply this change via kubectl by running the following -```bash -kubectl apply -f infisical-config.yaml +```bash +kubectl apply -f infisical-config.yaml ``` - ## Troubleshoot operator If the operator is unable to fetch secrets from the API, it will not affect the managed Kubernetes secret. @@ -506,7 +613,6 @@ The managed secret created by the operator will not be deleted when the operator - ## Useful Articles - [Managing secrets in OpenShift with Infisical](https://xphyr.net/post/infisical_ocp/) diff --git a/docs/internals/components.mdx b/docs/internals/components.mdx index 02c6d3692..65506a500 100644 --- a/docs/internals/components.mdx +++ b/docs/internals/components.mdx @@ -1,6 +1,6 @@ --- title: "Components" -description: "Infisical's components span multiple clients, an API, and a storage backend" +description: "Infisical's components span multiple clients, an API, and a storage backend." --- ## Infisical API @@ -9,9 +9,7 @@ The Infisical API (sometimes referred to as the **backend**) contains the core p ## Storage backend -Infisical relies on a storage backend to store data including users and secrets. - -Currently, the only supported storage backend is [MongoDB](https://www.mongodb.com) but we plan to add support for other options including PostgreSQL in Q1 2024. +Infisical relies on a storage backend to store data including users and secrets. Infisical's storage backend is Postgres. ## Redis @@ -27,4 +25,4 @@ Clients are any application or infrastructure that connecting to the Infisical A - Public API: Making API requests directly to the Infisical API. - Client SDK: A platform-specific library with method abstractions for working with secrets. Currently, there are three official SDKs: [Node SDK](https://infisical.com/docs/sdks/languages/node), [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java). - CLI: A terminal-based interface for interacting with the Infisical API. -- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store \ No newline at end of file +- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store diff --git a/docs/internals/flows.mdx b/docs/internals/flows.mdx index e18a37a31..0da671d64 100644 --- a/docs/internals/flows.mdx +++ b/docs/internals/flows.mdx @@ -1,6 +1,6 @@ --- title: "Flows" -description: "Infisical's core flows have strong cryptographic underpinnings" +description: "Infisical's core flows have strong cryptographic underpinnings." --- ## Signup diff --git a/docs/internals/overview.mdx b/docs/internals/overview.mdx index e5c47682d..a80ebf294 100644 --- a/docs/internals/overview.mdx +++ b/docs/internals/overview.mdx @@ -1,6 +1,6 @@ --- title: "Overview" -description: "How Infisical works under the hood" +description: "Read how Infisical works under the hood." --- This section covers the internals of Infisical including its technical underpinnings, architecture, and security properties. @@ -12,26 +12,26 @@ This section covers the internals of Infisical including its technical underpinn ## Learn More - - Learn about the fundamental parts of Infisical + + Learn about the fundamental parts of Infisical. - - Find out more about the structure of core user flows in Infisical + + Find out more about the structure of core user flows in Infisical. - Read about most common security-related topics and questions + Read about most common security-related topics and questions. - Learn best practices for utilizing Infisical service tokens + Learn best practices for utilizing Infisical service tokens. Please note that service tokens are now deprecated and will be removed entirely in the future. diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 180d44ec4..1b0fb9f32 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -1,6 +1,6 @@ --- title: "Security" -description: "Infisical's security model includes many considerations and initiatives" +description: "Infisical's security model includes many considerations and initiatives." --- Given that Infisical is a secret management platform that manages sensitive data, the Infisical security model is very important. @@ -87,13 +87,23 @@ Since these encryption operations occur on the client-side, the Infisical API is ### High availability -Infisical leverages the robust container orchestration capabilities of Kubernetes and the inherent high availability features of the storage backend (i.e. Bitnami MongoDB) to ensure resilience and fault tolerance. +Infisical Cloud utilizes several strategies to ensure high availability, leveraging AWS services to maintain continuous operation and data integrity. -- Kubernetes: By deploying multiple replicas of Infisical application on Kubernetes, operations continue even if a single instance fails. Kubernetes Services facilitate load balancing, effectively distributing traffic across your application’s instances and ensuring optimal performance. -- Storage backend: Bitnami MongoDB supports replica sets, which provide data redundancy and automatic failover for the underlying database. -- If using [Infisical Cloud](https://app.infisical.com), data is stored in a Mongo Atlas cluster with storage autoscaling and cluster tier autoscaling enabled; as you'd expect, the cluster sits on a dedicated node. +#### Multi-AZ AWS RDS +Infisical Cloud uses AWS Relational Database Service (RDS) with Multi-AZ deployments. +This configuration ensures that the database service is highly available and durable. +AWS RDS automatically provisions and maintains a synchronous standby replica of the database in a different Availability Zone (AZ). +This setup facilitates immediate failover to the standby in the event of an AZ failure, thereby ensuring that database operations can continue with minimal interruption. +The continuous backup and replication to the standby instance safeguard data against loss and ensure its availability even during system failures. -Together, Kubernetes’ self-healing mechanisms and Bitnami MongoDB’s failover capabilities work to create a highly available and fault-tolerant application capable of recovering gracefully from unexpected failures. +#### Multi-AZ ECS for Container Orchestration +Infisical Cloud leverages Amazon Elastic Container Service (ECS) in a Multi-AZ configuration for container orchestration. +This arrangement enables the management and operation of containers across multiple availability zones, increasing the application's fault tolerance. +Should there be an AZ failure, load is seamlessly sent to an operational AZ, thus minimizing downtime and preserving service availability. + +#### Standby Regions for Regional Failover +To fight regional outages, secondary regions are always in standby mode and maintained with up-to-date configurations and data, ready to take over in case the primary region fails. +The standby regions enable a rapid transition and service continuity with minimal disruption in the event of a complete regional failure, ensuring that Infisical Cloud services remain accessible. ### Snapshots diff --git a/docs/internals/service-tokens.mdx b/docs/internals/service-tokens.mdx index a04eccee7..39569326a 100644 --- a/docs/internals/service-tokens.mdx +++ b/docs/internals/service-tokens.mdx @@ -1,13 +1,20 @@ --- title: "Service tokens" -description: "Understanding service tokens and their best practices" +description: "Understanding service tokens and their best practices." --- + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). + +They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + + ​ Many clients use service tokens to authenticate and read/write secrets from/to Infisical; they can be created in your project settings. ## Anatomy -A service token in Infisical consists of the token itself, a `string`, and a corresponding document in the storage backend containing its +A service token in Infisical consists of the token itself, a `string`, and a corresponding document in the storage backend containing its properties and metadata. ### Database model @@ -22,12 +29,13 @@ The storage backend model for a token contains the following information: ### Token -A service token itself consist of two parts used for authentication and decryption, separated by the delimiter `.`. +A service token itself consist of two parts used for authentication and decryption, separated by the delimiter `.`. 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 ### Issuance @@ -46,4 +54,4 @@ Since service tokens grant access to your secrets, we recommend storing them sec 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](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 +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/mint.json b/docs/mint.json index 1f8dc618b..06b92711c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1,5 +1,6 @@ { "name": "Infisical", + "openapi": "https://app.infisical.com/api/docs/json", "logo": { "dark": "/logo/dark.svg", "light": "/logo/light.svg", @@ -7,26 +8,34 @@ }, "favicon": "/favicon.png", "colors": { - "primary": "#A1B659", - "light": "#E1EB55", + "primary": "#26272b", + "light": "#97b31d", "dark": "#A1B659", - "ultraLight": "#EFF4DD", + "ultraLight": "#E7F256", "ultraDark": "#8D9F4C", "background": { + "light": "#ffffff", "dark": "#0D1117" }, "anchors": { - "from": "#A1B659", - "to": "#F8B7BD" + "from": "#000000", + "to": "#707174" } }, + "modeToggle": { + "default": "light", + "isHidden": true + }, "feedback": { "suggestEdit": true, "raiseIssue": true, "thumbsRating": true }, "api": { - "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] + "baseUrl": [ + "https://app.infisical.com", + "http://localhost:8080" + ] }, "topbarLinks": [ { @@ -38,61 +47,37 @@ "name": "Start for Free", "url": "https://app.infisical.com/signup" }, - "anchors": [ + "tabs": [ { - "name": "Internals", - "icon": "sitemap", - "url": "internals" + "name": "Integrations", + "url": "integrations" }, { - "name": "SDKs", - "icon": "puzzle-piece", - "url": "sdks" + "name": "CLI", + "url": "cli" }, { "name": "API Reference", - "icon": "cloud", "url": "api-reference" }, + { + "name": "SDKs", + "url": "sdks" + }, { "name": "Changelog", - "icon": "timer", "url": "changelog" - }, - { - "name": "Contributing", - "icon": "code", - "url": "contributing" - }, - { - "name": "Blog", - "icon": "newspaper", - "url": "https://infisical.com/blog" - }, - { - "name": "Slack", - "icon": "slack", - "url": "https://infisical.com/slack" - }, - { - "name": "GitHub", - "icon": "github", - "url": "https://github.com/Infisical/infisical" } ], "navigation": [ { - "group": "Overview", + "group": "Getting Started", "pages": [ + "documentation/getting-started/introduction", { - "group": "Getting Started", + "group": "Quickstart", "pages": [ - "documentation/getting-started/introduction", - "documentation/getting-started/platform", - "documentation/getting-started/sdks", - "integrations/platforms/kubernetes", - "integrations/platforms/docker-intro", - "documentation/getting-started/api" + "documentation/guides/local-development" ] }, { @@ -101,7 +86,8 @@ "documentation/guides/introduction", "documentation/guides/node", "documentation/guides/python", - "documentation/guides/nextjs-vercel" + "documentation/guides/nextjs-vercel", + "documentation/guides/microsoft-power-apps" ] } ] @@ -112,30 +98,70 @@ "documentation/platform/organization", "documentation/platform/project", "documentation/platform/folder", - "documentation/platform/secret-reference", - "documentation/platform/webhooks", - "documentation/platform/pit-recovery", - "documentation/platform/audit-logs", + { + "group": "Secrets", + "pages": [ + "documentation/platform/secret-versioning", + "documentation/platform/pit-recovery", + "documentation/platform/secret-reference", + "documentation/platform/webhooks" + ] + }, { "group": "Identities", "pages": [ "documentation/platform/identities/overview", - "documentation/platform/identities/universal-auth" + "documentation/platform/identities/user-identities", + "documentation/platform/identities/machine-identities" + ] + }, + { + "group": "Access Control", + "pages": [ + "documentation/platform/access-controls/overview", + "documentation/platform/access-controls/role-based-access-controls", + "documentation/platform/access-controls/additional-privileges", + "documentation/platform/access-controls/temporary-access", + "documentation/platform/access-controls/access-requests", + "documentation/platform/pr-workflows", + "documentation/platform/audit-logs" ] }, - "documentation/platform/token", - "documentation/platform/mfa", - "documentation/platform/pr-workflows", - "documentation/platform/role-based-access-controls", { "group": "Secret Rotation", "pages": [ "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/sendgrid", "documentation/platform/secret-rotation/postgres", - "documentation/platform/secret-rotation/mysql" + "documentation/platform/secret-rotation/mysql", + "documentation/platform/secret-rotation/aws-iam" ] }, + { + "group": "Dynamic Secrets", + "pages": [ + "documentation/platform/dynamic-secrets/overview", + "documentation/platform/dynamic-secrets/postgresql", + "documentation/platform/dynamic-secrets/mysql", + "documentation/platform/dynamic-secrets/oracle", + "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/aws-iam" + ] + }, + "documentation/platform/groups", + "documentation/platform/audit-log-streams" + ] + }, + { + "group": "Authentication Methods", + "pages": [ + "documentation/platform/auth-methods/email-password", + "documentation/platform/token", + "documentation/platform/identities/universal-auth", + "documentation/platform/identities/kubernetes-auth", + "documentation/platform/identities/gcp-auth", + "documentation/platform/identities/aws-auth", + "documentation/platform/mfa", { "group": "SSO", "pages": [ @@ -145,7 +171,26 @@ "documentation/platform/sso/gitlab", "documentation/platform/sso/okta", "documentation/platform/sso/azure", - "documentation/platform/sso/jumpcloud" + "documentation/platform/sso/jumpcloud", + "documentation/platform/sso/keycloak-saml", + "documentation/platform/sso/google-saml" + ] + }, + { + "group": "LDAP", + "pages": [ + "documentation/platform/ldap/overview", + "documentation/platform/ldap/jumpcloud", + "documentation/platform/ldap/general" + ] + }, + { + "group": "SCIM", + "pages": [ + "documentation/platform/scim/overview", + "documentation/platform/scim/okta", + "documentation/platform/scim/azure", + "documentation/platform/scim/jumpcloud" ] } ] @@ -153,27 +198,32 @@ { "group": "Self-host Infisical", "pages": [ + "self-hosting/overview", { - "group": "Deployment options", + "group": "Installation methods", "pages": [ - "self-hosting/overview", "self-hosting/deployment-options/standalone-infisical", + "self-hosting/deployment-options/docker-swarm", "self-hosting/deployment-options/docker-compose", - "self-hosting/deployment-options/kubernetes-helm", - "self-hosting/deployment-options/aws-ec2", - "self-hosting/deployment-options/aws-lightsail", - "self-hosting/deployment-options/gcp-cloud-run", - "self-hosting/deployment-options/azure-app-services", - "self-hosting/deployment-options/azure-container-instances", - "self-hosting/deployment-options/digital-ocean-marketplace", - "self-hosting/deployment-options/fly.io", - "self-hosting/deployment-options/railway" + "self-hosting/deployment-options/kubernetes-helm" ] }, "self-hosting/configuration/envars", - "self-hosting/configuration/email", - "self-hosting/configuration/redis", - "self-hosting/configuration/sso", + "self-hosting/configuration/requirements", + { + "group": "Guides", + "pages": [ + "self-hosting/configuration/schema-migrations", + "self-hosting/guides/mongo-to-postgres" + ] + }, + { + "group": "Reference architectures", + "pages": [ + "self-hosting/reference-architectures/aws-ecs" + ] + }, + "self-hosting/ee", "self-hosting/faq" ] }, @@ -190,6 +240,7 @@ "cli/commands/run", "cli/commands/secrets", "cli/commands/export", + "cli/commands/token", "cli/commands/service-token", "cli/commands/vault", "cli/commands/user", @@ -209,19 +260,18 @@ "cli/faq" ] }, - { - "group": "Agent", - "pages": [ - "infisical-agent/overview" - ] - }, - { - "group": "Integrations", - "pages": ["integrations/overview"] - }, { "group": "Infrastructure Integrations", "pages": [ + { + "group": "Container orchestrators", + "pages": [ + "integrations/platforms/kubernetes", + "integrations/platforms/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + }, + "integrations/platforms/infisical-agent", { "group": "Docker", "pages": [ @@ -231,58 +281,70 @@ "integrations/platforms/docker-compose" ] }, - "integrations/platforms/kubernetes", "integrations/frameworks/terraform", "integrations/platforms/ansible" ] }, { - "group": "3rd-party Integrations", + "group": "Native Integrations", "pages": [ { "group": "AWS", "pages": [ "integrations/cloud/aws-parameter-store", - "integrations/cloud/aws-secret-manager" + "integrations/cloud/aws-secret-manager", + "integrations/cloud/aws-amplify" ] }, - { - "group": "Digital Ocean", - "pages": ["integrations/cloud/digital-ocean-app-platform"] - }, - "integrations/cloud/heroku", "integrations/cloud/vercel", - "integrations/cloud/netlify", - "integrations/cloud/render", - "integrations/cloud/railway", - "integrations/cloud/flyio", - "integrations/cloud/laravel-forge", - "integrations/cloud/supabase", - "integrations/cloud/northflank", - "integrations/cloud/hasura-cloud", - "integrations/cloud/terraform-cloud", - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers", - "integrations/cloud/qovery", - "integrations/cloud/hashicorp-vault", "integrations/cloud/azure-key-vault", "integrations/cloud/gcp-secret-manager", - "integrations/cloud/cloud-66", - "integrations/cloud/windmill" + { + "group": "Cloudflare", + "pages": [ + "integrations/cloud/cloudflare-pages", + "integrations/cloud/cloudflare-workers" + ] + }, + "integrations/cloud/heroku", + "integrations/cloud/render", + { + "group": "View more", + "pages": [ + "integrations/cloud/digital-ocean-app-platform", + "integrations/cloud/netlify", + "integrations/cloud/railway", + "integrations/cloud/flyio", + "integrations/cloud/laravel-forge", + "integrations/cloud/supabase", + "integrations/cloud/northflank", + "integrations/cloud/hasura-cloud", + "integrations/cloud/terraform-cloud", + "integrations/cloud/qovery", + "integrations/cloud/hashicorp-vault", + "integrations/cloud/cloud-66", + "integrations/cloud/windmill" + ] + } ] }, { "group": "CI/CD Integrations", "pages": [ - "integrations/cloud/teamcity", - "integrations/cloud/checkly", + "integrations/cicd/jenkins", "integrations/cicd/githubactions", "integrations/cicd/gitlab", - "integrations/cicd/circleci", - "integrations/cicd/travisci", "integrations/cicd/bitbucket", - "integrations/cicd/codefresh", - "integrations/cicd/jenkins" + "integrations/cloud/teamcity", + { + "group": "View more", + "pages": [ + "integrations/cicd/circleci", + "integrations/cicd/travisci", + "integrations/cicd/codefresh", + "integrations/cloud/checkly" + ] + } ] }, { @@ -292,29 +354,47 @@ "integrations/frameworks/react", "integrations/frameworks/vue", "integrations/frameworks/express", - "integrations/frameworks/nextjs", - "integrations/frameworks/nestjs", - "integrations/frameworks/sveltekit", - "integrations/frameworks/nuxt", - "integrations/frameworks/gatsby", - "integrations/frameworks/remix", - "integrations/frameworks/vite", - "integrations/frameworks/fiber", - "integrations/frameworks/django", - "integrations/frameworks/flask", - "integrations/frameworks/laravel", - "integrations/frameworks/rails", - "integrations/frameworks/dotnet", - "integrations/platforms/pm2" + { + "group": "View more", + "pages": [ + "integrations/frameworks/nextjs", + "integrations/frameworks/nestjs", + "integrations/frameworks/sveltekit", + "integrations/frameworks/nuxt", + "integrations/frameworks/gatsby", + "integrations/frameworks/remix", + "integrations/frameworks/vite", + "integrations/frameworks/fiber", + "integrations/frameworks/django", + "integrations/frameworks/flask", + "integrations/frameworks/laravel", + "integrations/frameworks/rails", + "integrations/frameworks/dotnet", + "integrations/platforms/pm2" + ] + } ] }, { "group": "Build Tool Integrations", - "pages": ["integrations/build-tools/gradle"] + "pages": [ + "integrations/build-tools/gradle" + ] }, { - "group": "Overview", - "pages": ["sdks/overview"] + "group": "", + "pages": [ + "sdks/overview" + ] + }, + { + "group": "SDK's", + "pages": [ + "sdks/languages/node", + "sdks/languages/python", + "sdks/languages/java", + "sdks/languages/csharp" + ] }, { "group": "Overview", @@ -324,9 +404,7 @@ { "group": "Examples", "pages": [ - "api-reference/overview/examples/note", - "api-reference/overview/examples/e2ee-disabled", - "api-reference/overview/examples/e2ee-enabled" + "api-reference/overview/examples/integration" ] } ] @@ -334,13 +412,6 @@ { "group": "Endpoints", "pages": [ - { - "group": "Users", - "pages": [ - "api-reference/endpoints/users/me", - "api-reference/endpoints/users/my-organizations" - ] - }, { "group": "Identities", "pages": [ @@ -359,7 +430,8 @@ "api-reference/endpoints/universal-auth/create-client-secret", "api-reference/endpoints/universal-auth/list-client-secrets", "api-reference/endpoints/universal-auth/revoke-client-secret", - "api-reference/endpoints/universal-auth/renew-access-token" + "api-reference/endpoints/universal-auth/renew-access-token", + "api-reference/endpoints/universal-auth/revoke-access-token" ] }, { @@ -375,17 +447,34 @@ { "group": "Projects", "pages": [ - "api-reference/endpoints/workspaces/memberships", - "api-reference/endpoints/workspaces/update-membership", - "api-reference/endpoints/workspaces/delete-membership", - "api-reference/endpoints/workspaces/list-identity-memberships", - "api-reference/endpoints/workspaces/update-identity-membership", - "api-reference/endpoints/workspaces/delete-identity-membership", - "api-reference/endpoints/workspaces/workspace-key", + "api-reference/endpoints/workspaces/create-workspace", + "api-reference/endpoints/workspaces/delete-workspace", + "api-reference/endpoints/workspaces/get-workspace", + "api-reference/endpoints/workspaces/update-workspace", "api-reference/endpoints/workspaces/secret-snapshots", "api-reference/endpoints/workspaces/rollback-snapshot" ] }, + { + "group": "Project Users", + "pages": [ + "api-reference/endpoints/project-users/invite-member-to-workspace", + "api-reference/endpoints/project-users/remove-member-from-workspace", + "api-reference/endpoints/project-users/memberships", + "api-reference/endpoints/project-users/get-by-username", + "api-reference/endpoints/project-users/update-membership" + ] + }, + { + "group": "Project Identities", + "pages": [ + "api-reference/endpoints/project-identities/add-identity-membership", + "api-reference/endpoints/project-identities/list-identity-memberships", + "api-reference/endpoints/project-identities/get-by-id", + "api-reference/endpoints/project-identities/update-identity-membership", + "api-reference/endpoints/project-identities/delete-identity-membership" + ] + }, { "group": "Environments", "pages": [ @@ -403,6 +492,14 @@ "api-reference/endpoints/folders/delete" ] }, + { + "group": "Secret Tags", + "pages": [ + "api-reference/endpoints/secret-tags/list", + "api-reference/endpoints/secret-tags/create", + "api-reference/endpoints/secret-tags/delete" + ] + }, { "group": "Secrets", "pages": [ @@ -410,11 +507,16 @@ "api-reference/endpoints/secrets/create", "api-reference/endpoints/secrets/read", "api-reference/endpoints/secrets/update", - "api-reference/endpoints/secrets/delete" + "api-reference/endpoints/secrets/delete", + "api-reference/endpoints/secrets/create-many", + "api-reference/endpoints/secrets/update-many", + "api-reference/endpoints/secrets/delete-many", + "api-reference/endpoints/secrets/attach-tags", + "api-reference/endpoints/secrets/detach-tags" ] }, { - "group": "Secret imports", + "group": "Secret Imports", "pages": [ "api-reference/endpoints/secret-imports/list", "api-reference/endpoints/secret-imports/create", @@ -422,13 +524,42 @@ "api-reference/endpoints/secret-imports/delete" ] }, + { + "group": "Identity Specific Privilege", + "pages": [ + "api-reference/endpoints/identity-specific-privilege/create-permanent", + "api-reference/endpoints/identity-specific-privilege/create-temporary", + "api-reference/endpoints/identity-specific-privilege/update", + "api-reference/endpoints/identity-specific-privilege/delete", + "api-reference/endpoints/identity-specific-privilege/find-by-slug", + "api-reference/endpoints/identity-specific-privilege/list" + ] + }, + { + "group": "Integrations", + "pages": [ + "api-reference/endpoints/integrations/create-auth", + "api-reference/endpoints/integrations/list-auth", + "api-reference/endpoints/integrations/find-auth", + "api-reference/endpoints/integrations/delete-auth", + "api-reference/endpoints/integrations/delete-auth-by-id", + "api-reference/endpoints/integrations/create", + "api-reference/endpoints/integrations/update", + "api-reference/endpoints/integrations/delete", + "api-reference/endpoints/integrations/list-project-integrations" + ] + }, { "group": "Service Tokens", - "pages": ["api-reference/endpoints/service-tokens/get"] + "pages": [ + "api-reference/endpoints/service-tokens/get" + ] }, { "group": "Audit Logs", - "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + "pages": [ + "api-reference/endpoints/audit-logs/export-audit-log" + ] } ] }, @@ -442,35 +573,38 @@ "internals/service-tokens" ] }, - { - "group": "Overview", - "pages": ["changelog/overview"] - }, { "group": "", "pages": [ - { - "group": "Getting Started", - "pages": [ - "contributing/getting-started/overview", - "contributing/getting-started/code-of-conduct", - "contributing/getting-started/pull-requests", - "contributing/getting-started/faq" - - ] - }, + "changelog/overview" + ] + }, + { + "group": "Contributing", + "pages": [ { - "group": "Contributing to platform", + "group": "Getting Started", "pages": [ - "contributing/platform/developing" + "contributing/getting-started/overview", + "contributing/getting-started/code-of-conduct", + "contributing/getting-started/pull-requests", + "contributing/getting-started/faq" ] }, { - "group": "Contributing to SDK", - "pages": [ - "contributing/sdk/developing" - ] - } + "group": "Contributing to platform", + "pages": [ + "contributing/platform/developing", + "contributing/platform/backend/how-to-create-a-feature", + "contributing/platform/backend/folder-structure" + ] + }, + { + "group": "Contributing to SDK", + "pages": [ + "contributing/sdk/developing" + ] + } ] } ], diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx index ecfd67c27..b3a1d2086 100644 --- a/docs/sdks/languages/csharp.mdx +++ b/docs/sdks/languages/csharp.mdx @@ -1,6 +1,7 @@ --- title: "Infisical .NET SDK" -icon: "C#" +sidebarTitle: ".NET" +icon: "bars" --- If you're working with C#, the official [Infisical C# SDK](https://github.com/Infisical/sdk/tree/main/languages/csharp) package is the easiest way to fetch and work with secrets for your application. diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 40d577926..5b8797b5d 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -1,5 +1,6 @@ --- title: "Infisical Java SDK" +sidebarTitle: "Java" icon: "java" --- diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index faabd794c..4816392ed 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -1,5 +1,6 @@ --- title: "Infisical Node.js SDK" +sidebarTitle: "Node.js" icon: "node" --- @@ -42,7 +43,7 @@ app.get("/", async (req, res) => { app.listen(PORT, async () => { // initialize client - console.log(`App listening on port ${port}`); + console.log(`App listening on port ${PORT}`); }); ``` diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index da92b1a6e..0ce221757 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -1,5 +1,6 @@ --- title: "Infisical Python SDK" +sidebarTitle: "Python" icon: "python" --- diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index d032311f2..578e8ad0f 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -1,5 +1,6 @@ --- -title: "Introduction" +title: "SDKs" +sidebarTitle: "Introduction" --- From local development to production, Infisical SDKs provide the easiest way for your app to fetch back secrets from Infisical on demand. diff --git a/docs/self-hosting/configuration/email.mdx b/docs/self-hosting/configuration/email.mdx deleted file mode 100644 index c26c20648..000000000 --- a/docs/self-hosting/configuration/email.mdx +++ /dev/null @@ -1,242 +0,0 @@ ---- -title: "Configure email service" -description: "How to configure your email when self-hosting Infisical." ---- - -By default, the core functions of Infisical work without any email service configuration. Without email service, basic sign up/login and secret operations will function without any issue. -However, the following functionality will be disabled. - -- Multi-factor authentication -- Sending invite links via email for projects to teammates -- Sending alerts such as suspicious login attempts - -## Configuration - -If you choose to setup email service, you need to configure the following SMTP [environment variables](https://infisical.com/docs/self-hosting/configuration/envars): - -- `SMTP_HOST`: Hostname to connect to for establishing SMTP connections. -- `SMTP_USERNAME`: Credential to connect to host (e.g. team@infisical.com) -- `SMTP_PASSWORD`: Credential to connect to host. -- `SMTP_PORT`: Port to connect to for establishing SMTP connections. -- `SMTP_SECURE`: If `true`, the connection will use TLS when connecting to server with special configs for SendGrid and Mailgun. If `false` (the default) then TLS is used if server supports the STARTTLS extension. -- `SMTP_FROM_ADDRESS`: Email address to be used for sending emails (e.g. team@infisical.com). -- `SMTP_FROM_NAME`: Name label to be used in `From` field (e.g. Team). - -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/self-hosting/configuration/email/email-resend-create-domain.png) - -3. Create an [API Key](https://resend.com/api-keys). - -![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/self-hosting/configuration/email/email-resend-smtp-settings.png) - -5. With the API Key, you can now set your SMTP environment variables variables: - -``` -SMTP_HOST=smtp.resend.com -SMTP_USERNAME=resend -SMTP_PASSWORD=YOUR_API_KEY -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails -SMTP_FROM_NAME=Infisical -``` - - Remember that you will need to restart Infisical for this to work properly. - - - - - -1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. -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/self-hosting/configuration/email/email-sendgrid-create-key.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: - -``` -SMTP_HOST=smtp.sendgrid.net -SMTP_USERNAME=apikey -SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails -SMTP_FROM_NAME=Infisical -``` - - - Remember that you will need to restart Infisical for this to work properly. - - - - - -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/self-hosting/configuration/email/email-mailhog-credentials.png) - -3. With your Mailgun credentials, you can now set up your SMTP environment variables: - -``` -SMTP_HOST=smtp.mailgun.org # obtained from credentials page -SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page -SMTP_PASSWORD=password # obtained from credentials page -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails -SMTP_FROM_NAME=Infisical -``` - - - - - -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/self-hosting/configuration/email/email-aws-ses-console.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: - -``` -SMTP_HOST=email-smtp.ap-northeast-1.amazonaws.com # SMTP endpoint obtained from SMTP settings -SMTP_USERNAME=xxx # your SMTP username -SMTP_PASSWORD=xxx # your SMTP password -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails -SMTP_FROM_NAME=Infisical -``` - - - Remember that you will need to restart Infisical for this to work properly. - - - - - -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/self-hosting/configuration/email/email-socketlabs-dashboard.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: - -``` -SMTP_HOST=smtp.socketlabs.com -SMTP_USERNAME=username # obtained from your credentials -SMTP_PASSWORD=password # obtained from your credentials -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails -SMTP_FROM_NAME=Infisical -``` - - - The `SMTP_FROM_ADDRESS` environment variable should be an email for an - authenticated domain under Configuration > Domain Management in SocketLabs. - For example, if you're using SocketLabs in sandbox mode, then you may use an - email like `team@sandbox.socketlabs.dev`. - - -![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. - - - - - -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/self-hosting/configuration/email/email-gmail-app-access.png) - -With your Gmail username and password, you can set your SMTP environment variables: - -``` -SMTP_HOST=smtp.gmail.com -SMTP_USERNAME=hey@gmail.com # your email -SMTP_PASSWORD=password # your password -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@gmail.com -SMTP_FROM_NAME=Infisical -``` - - - As per the [notice](https://support.google.com/accounts/answer/6010255?hl=en) by Google, you should note that using Gmail credentials for SMTP configuration - will only work for Google Workspace or Google Cloud Identity customers as of May 30, 2022. - -Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. - - - - - - - -1. Create an account and configure [Office365](https://www.office.com/) to send emails. - -2. With your login credentials, you can now set up your SMTP environment variables: - -``` -SMTP_HOST=smtp.office365.com -SMTP_USERNAME=username@yourdomain.com # your username -SMTP_PASSWORD=password # your password -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=username@yourdomain.com -SMTP_FROM_NAME=Infisical -``` - - - - - -1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails. - -2. With your email credentials, you can now set up your SMTP environment variables: - -``` -SMTP_HOST=smtp.zoho.com -SMTP_USERNAME=username # your email -SMTP_PASSWORD=password # your password -SMTP_PORT=587 -SMTP_SECURE=true -SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail -SMTP_FROM_NAME=Infisical -``` - - - You can use either your personal Zoho email address like `you@zohomail.com` or - a domain-based email address like `you@yourdomain.com`. If using a - domain-based email address, then please make sure that you've configured and - verified it with Zoho Mail. - - - - Remember that you will need to restart Infisical for this to work properly. - - - - diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 3ea1762e2..5233ae910 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -1,188 +1,469 @@ --- -title: "All environment variables" -description: "Configure your environment variables when self-hosting Infisical." +title: "Configurations" +description: "Read how to configure environment variables for self-hosted Infisical." --- -## Environment variables +Infisical accepts all configurations via environment variables. For a minimal self-hosted instance, at least `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI` and `REDIS_URL` must be defined. +However, you can configure additional settings to activate more features as needed. -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. +## General platform - - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - +Used to configure platform-specific security and operational settings - - Must be a random 32 byte base64 string. Can be generated with `openssl rand -base64 32` - - - - Mongo connection string. *TLS based connection string is not yet supported - - - - Redis connection string - - - - When email service is not configured, Infisical will have limited functionality - - - Hostname to connect to for establishing SMTP connections - - - - Credential to connect to host (e.g. team@infisical.com) - - - - Credential to connect to host - - - - Port to connect to for establishing SMTP connections - - - - If true, use TLS when connecting to host. If false, TLS will be used if STARTTLS is supported - - - - Email address to be used for sending emails - - - - Name label to be used in From field (e.g. Team) - - - - - To sync secret to third party services, provide value for the related services - - - OAuth2 client ID for Heroku integration - - - - OAuth2 client secret for Heroku integration - - - - OAuth2 client ID for Vercel integration - - - - OAuth2 client secret for Vercel integration - - - - OAuth2 client ID for Netlify integration - - - - OAuth2 client secret for Netlify integration - - - - OAuth2 client ID for GitHub integration - - - - OAuth2 client secret for GitHub integration - - - - OAuth2 slug for Vercel integration - - - - OAuth2 client ID for BitBucket integration - - - - OAuth2 client secret for BitBucket integration - - - - - To integrate with external auth providers, provide value for the related keys - - OAuth2 client ID for Google login - - - OAuth2 client secret for Google login - - - OAuth2 client ID for GitHub login - - - 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 - - JWT token lifetime expressed in seconds or a string describing a time span - - - - JWT token lifetime expressed in seconds or a string describing a time span - - - - JWT token lifetime expressed in seconds or a string describing a time span - - - - JWT token lifetime expressed in seconds or a string describing a time span - - - - JWT token lifetime expressed in seconds or a string describing a time span - - -#### Logging - -Infisical uses Sentry to report error logs - - - The minimum log level for application logging; can be one of `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. + + 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` + -#### Settings + + Must be an absolute URL including the protocol (e.g. + https://app.infisical.com). + + +## Data Layer + +The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks + + + Postgres database connection string. + + + + Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. + Use the command below to encode your certificate: + `echo "" | base64` + + + + Redis connection string. + + +## Email service + +Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. + + + + Hostname to connect to for establishing SMTP connections + {" "} - - Only allow users who are invited to sign up + + Credential to connect to host (e.g. team@infisical.com) - - Site URL - should be an absolute URL including the protocol (e.g. https://app.infisical.com) +{" "} + + + Credential to connect to host + + +{" "} + + + Port to connect to for establishing SMTP connections + + +{" "} + + + If true, use TLS when connecting to host. If false, TLS will be used if + STARTTLS is supported + + +{" "} + + + Email address to be used for sending emails + + + + Name label to be used in From field (e.g. Team) - - - + + + + +1. Create an account and configure [SendGrid](https://sendgrid.com) to send emails. +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/self-hosting/configuration/email/email-sendgrid-create-key.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: + +``` +SMTP_HOST=smtp.sendgrid.net +SMTP_USERNAME=apikey +SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` + + + Remember that you will need to restart Infisical for this to work properly. + + + + + 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/self-hosting/configuration/email/email-mailhog-credentials.png) + +3. With your Mailgun credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.mailgun.org # obtained from credentials page +SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page +SMTP_PASSWORD=password # obtained from credentials page +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` + + + + + + + This will be used to verify the email you are sending from. + ![Create SES identity](../../images/self-hosting/configuration/email/ses-create-identity.png) + + If you AWS SES is under sandbox mode, you will only be able to send emails to verified identies. + + + + Create an IAM user for SMTP authentication and obtain SMTP credentials in SMTP settings > Create SMTP credentials + + ![opening AWS SES console](../../images/self-hosting/configuration/email/email-aws-ses-console.png) + + ![creating AWS IAM SES user](../../images/self-hosting/configuration/email/email-aws-ses-user.png) + + + With your AWS SES SMTP credentials, you can now set up your SMTP environment variables for your Infisical instance. + + ``` + SMTP_HOST=email-smtp.ap-northeast-1.amazonaws.com # SMTP endpoint obtained from SMTP settings + SMTP_USERNAME=xxx # your SMTP username + SMTP_PASSWORD=xxx # your SMTP password + SMTP_PORT=465 + SMTP_SECURE=true + SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails + SMTP_FROM_NAME=Infisical + ``` + + + + + + Remember that you will need to restart Infisical for this to work properly. + + + + + 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/self-hosting/configuration/email/email-socketlabs-dashboard.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: + +``` +SMTP_HOST=smtp.socketlabs.com +SMTP_USERNAME=username # obtained from your credentials +SMTP_PASSWORD=password # obtained from your credentials +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` + +{" "} + + + The `SMTP_FROM_ADDRESS` environment variable should be an email for an + authenticated domain under Configuration > Domain Management in SocketLabs. + For example, if you're using SocketLabs in sandbox mode, then you may use an + email like `team@sandbox.socketlabs.dev`. + + +![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. + + + + + 1. Create an account on [Resend](https://resend.com). + 2. Add a [Domain](https://resend.com/domains). + +![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/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/self-hosting/configuration/email/email-resend-smtp-settings.png) + +5. With the API Key, you can now set your SMTP environment variables variables: + +``` +SMTP_HOST=smtp.resend.com +SMTP_USERNAME=resend +SMTP_PASSWORD=YOUR_API_KEY +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` + + + Remember that you will need to restart Infisical for this to work properly. + + + + + + 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/self-hosting/configuration/email/email-gmail-app-access.png) + +With your Gmail username and password, you can set your SMTP environment variables: + +``` +SMTP_HOST=smtp.gmail.com +SMTP_USERNAME=hey@gmail.com # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@gmail.com +SMTP_FROM_NAME=Infisical +``` + + + As per the [notice](https://support.google.com/accounts/answer/6010255?hl=en) by Google, you should note that using Gmail credentials for SMTP configuration + will only work for Google Workspace or Google Cloud Identity customers as of May 30, 2022. + +Put differently, the SMTP configuration is only possible with business (not personal) Gmail credentials. + + + + + + 1. Create an account and configure [Office365](https://www.office.com/) to send emails. + +2. With your login credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.office365.com +SMTP_USERNAME=username@yourdomain.com # your username +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=username@yourdomain.com +SMTP_FROM_NAME=Infisical +``` + + + + + 1. Create an account and configure [Zoho Mail](https://www.zoho.com/mail/) to send emails. + +2. With your email credentials, you can now set up your SMTP environment variables: + +``` +SMTP_HOST=smtp.zoho.com +SMTP_USERNAME=username # your email +SMTP_PASSWORD=password # your password +SMTP_PORT=587 +SMTP_SECURE=true +SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail +SMTP_FROM_NAME=Infisical +``` + +{" "} + + + You can use either your personal Zoho email address like `you@zohomail.com` or + a domain-based email address like `you@yourdomain.com`. If using a + domain-based email address, then please make sure that you've configured and + verified it with Zoho Mail. + + + + Remember that you will need to restart Infisical for this to work properly. + + + +## Authentication + +By default, users can only login via email/password based login method. +To login into Infisical with OAuth providers such as Google, configure the associated variables. + + + Follow detailed guide to configure [Google SSO](/documentation/platform/sso/google) + + + OAuth2 client ID for Google login + + + OAuth2 client secret for Google login + + + + + Follow detailed guide to configure [GitHub SSO](/documentation/platform/sso/github) + + + OAuth2 client ID for GitHub login + + + OAuth2 client secret for GitHub login + + + + + Follow detailed guide to configure [GitLab SSO](/documentation/platform/sso/gitlab) + + + 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 + + + + + Requires enterprise license. Please contact team@infisical.com to get more + information. + + + + Requires enterprise license. Please contact team@infisical.com to get more + information. + + + + Requires enterprise license. Please contact team@infisical.com to get more + information. + + + + Configure SAML organization slug to automatically redirect all users of your + Infisical instance to the identity provider. + + +## Native secret integrations + +To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. + + + + OAuth2 client ID for Heroku integration + + + OAuth2 client secret for Heroku integration + + + + + + OAuth2 client ID for Vercel integration + + +{" "} + + + OAuth2 client secret for Vercel integration + + + + OAuth2 slug for Vercel integration + + + + + + OAuth2 client ID for Netlify integration + + + + OAuth2 client secret for Netlify integration + + + + + + OAuth2 client ID for GitHub integration + + + + OAuth2 client secret for GitHub integration + + + + + + OAuth2 client ID for BitBucket integration + + + + OAuth2 client secret for BitBucket integration + + + + + + OAuth2 client id for GCP secrets manager integration + + + + OAuth2 client secret for GCP secrets manager integration + + + + + + OAuth2 client id for Azure integration + + + + OAuth2 client secret for Azure integration + + + + + + OAuth2 client id for Gitlab integration + + + + OAuth2 client secret for Gitlab integration + + diff --git a/docs/self-hosting/configuration/redis.mdx b/docs/self-hosting/configuration/redis.mdx deleted file mode 100644 index 6013cab87..000000000 --- a/docs/self-hosting/configuration/redis.mdx +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: "Configure Redis" -description: "Learn to configure Redis with your self hosted Infisical" ---- - -## Why Redis? -As the features and use case of Infisical have grown, the need for a fast and reliable in-memory data storage has become clear. -By adding Redis to Infisical, we can now support more complex workflows such as queuing system to run long running asynchronous tasks, cron jobs, and access reliable cache to speed up frequently used resources. - - - Starting with Infisical version v0.31.0, Redis will be required to fully use Infisical - - -### Adding Redis to your self hosted instance of Infisical -To add Redis to your self hosted instance, follow the instructions for the deployment method you used. - - - - ### In cluster Redis - By default, new versions of the Infisical Helm chart already comes with an in-cluster Redis instance. To deploy a in-cluster Redis instance along with your Infisical instance, update your Infisical chart then redeploy/upgrade your release. - This will spin up a Redis instance and automatically configure it with your Infisical backend. - - 1. Update Infisical Helm chart - ```bash - helm repo update - ``` - - 2. Upgrade Infisical release - ```bash - helm upgrade infisical-helm-charts/infisical --values - ``` - ### External Redis - If you want to use an external Redis instance, please add a Redis connection URL under the backend environments variables and then upgrade/redeploy your Infisical instance. - - 1. Update your helm values file - ```yaml your-values.yaml - backendEnvironmentVariables: - REDIS_URL= - ``` - - 2. Upgrade Infisical release - ```bash - helm upgrade infisical-helm-charts/infisical --values - ``` - - - ### Internal Redis service - By default, new versions of the docker compose file already comes with a Redis service. To use the pre-configured Redis service, please update your docker compose file to the latest version. - - 1. Download the new docker compose file - ``` - wget -O docker-compose.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.yml - ``` - 2. Add Redis environment variable to your .env file - ```.env .env - REDIS_URL=redis://redis:6379 - ``` - - 3. Restart your docker compose services - - - This standalone version of Infisical does not have an internal Redis service. To configure Redis with your Infisical instance, you must connect to a external Redis service by setting the connection string as an environment variable. - - Example: - - ```bash - docker run -p 80:80 \ - -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 REDIS_URL=<> \ - -e MONGO_URL="<>" \ - infisical/infisical:latest - ``` - - Redis environment variable name: `REDIS_URL` - - - -## Support -If you have questions or need support, please join our [slack channel](https://infisical-users.slack.com) and one of our teammates will be happy to guide you. \ No newline at end of file diff --git a/docs/self-hosting/configuration/requirements.mdx b/docs/self-hosting/configuration/requirements.mdx new file mode 100644 index 000000000..c0e9cab01 --- /dev/null +++ b/docs/self-hosting/configuration/requirements.mdx @@ -0,0 +1,71 @@ +--- +title: "Hardware requirements" +description: "Find out the minimal requirements for operating Infisical." +--- + +This page details the minimum requirements necessary for installing and using Infisical. +The actual resource requirements will vary in direct proportion to the operations performed by Infisical and the level of utilization by the end users. + + + +## Deployment Sizes + +**Small** suitable for most initial production setups, as well as development and testing scenarios. + +**Large** suitable for high-demand production environments, characterized by either a high volume of transactions, large number of secrets, or both. + + +## Hardware Requirements + +### Storage + +Infisical doesn’t require file storage as all persisted data is saved in the database. +However, its logs and metrics are saved to disk for later viewing. As a result, we recommend provisioning 1-2 GB of storage. + +### CPU + +CPU requirements vary heavily on the volume of secret operations (reads and writes) you anticipate. +Processing large volumes of secrets frequently and consistently will require higher CPU. + +Recommended minimum CPU hardware for different sizes of deployments: + +- **small:**Β 2-4 core is theΒ **recommended**Β minimum +- **large:** 4-8 cores are suitable for larger deployments + +### Memory Allocation + +Memory needs depend on expected workload, including factors like user activity, automation level, and the frequency of secret operations. + +Recommended minimum memory hardware for different sizes of deployments: +- **small:**Β 4-8 GB is theΒ **recommended**Β minimum +- **large:** 16-32 GB are suitable for larger deployments + +## Database & caching layer + +### Postgres + +PostgreSQL is the only database supported by Infisical. Infisical has been extensively tested with Postgres version 16. We recommend using versions 14 and up for optimal compatibility. + +Recommended resource allocation based on deployment size: +- **small:**Β 2 vCPU / 8 GB RAM / 20 GB Disk +- **large:** 4vCPU / 16 GB RAM / 100 GB Disk + +### Redis + +Redis is utilized for session management and background tasks in Infisical. + +Redis requirements: + +- Use Redis versions 6.x or 7.x. We advise upgrading to at least Redis 6.2. +- Redis Cluster mode is currently not supported; use Redis Standalone, with or without High Availability (HA). +- Redis storage needs are minimal: a setup with 2 vCPU, 4 GB RAM, and 30GB SSD will be sufficient for small deployments. + +## Supported Web Browsers + +Infisical supports a range of web browsers. However, features such as browser-based CLI login only work on Google Chrome and Firefox at the moment. + +- [Mozilla Firefox](https://www.mozilla.org/en-US/firefox/new/) +- [Google Chrome](https://www.google.com/chrome/) +- [Chromium](https://www.chromium.org/getting-involved/dev-channel/) +- [Apple Safari](https://www.apple.com/safari/) +- [Microsoft Edge](https://www.microsoft.com/en-us/edge?form=MA13FJ) diff --git a/docs/self-hosting/configuration/schema-migrations.mdx b/docs/self-hosting/configuration/schema-migrations.mdx new file mode 100644 index 000000000..5df52e713 --- /dev/null +++ b/docs/self-hosting/configuration/schema-migrations.mdx @@ -0,0 +1,60 @@ +--- +title: "Schema migration" +description: "Learn how to run Postgres schema migrations." +--- + +Running schema migrations is a requirement before deploying Infisical. +Each time you decide to upgrade your version of Infisical, it's necessary to run schema migrations for that specific version. +The guide below outlines a step-by-step guide to help you manually run schema migrations for Infisical. + +### Prerequisites +- Docker installed on your machine +- An active PostgreSQL database +- Postgres database connection string + + + + First, ensure you have the correct version of the Infisical Docker image. You can pull it from Docker Hub using the following command: + ```bash + docker pull infisical/infisical: + ``` + Replace `` with the specific version number you intend to deploy. View available versions [here](https://hub.docker.com/r/infisical/infisical/tags) + + + + The Docker image requires a `DB_CONNECTION_URI` environment variable. This connection string should point to your PostgreSQL database. The format generally looks like this: `postgresql://username:password@host:port/database`. + + + + To run the schema migration for the version of Infisical you want to deploy, use the following Docker command: + + ```bash + docker run --env DB_CONNECTION_URI= infisical/infisical: npm run migration:latest + ``` + Replace `` with your actual PostgreSQL connection string, and `` with the desired version number. + + + + After running the migration, it's good practice to check if the migration was successful. You can do this by checking the logs or accessing your database to ensure the schema has been updated accordingly. + + + If you need to rollback a migration by one step, use the following command: + + ```bash + docker run --env DB_CONNECTION_URI= infisical/infisical: npm run migration:rollback + ``` + + + + It's important to run schema migrations for each version of the Infisical you deploy. For instance, if you're updating from `infisical/infisical:1` to `infisical/infisical:2`, ensure you run the schema migrations for `infisical/infisical:2` before deploying it. + + + + + In a production setting, we recommend a more structured approach to deploying migrations prior to upgrading Infisical. This can be accomplished via CI automation. + + +### Additional discussion +- Always back up your database before running migrations, especially in a production environment. +- Test the migration process in a staging environment before applying it to production. +- Keep track of the versions and their corresponding migrations to avoid any inconsistencies. diff --git a/docs/self-hosting/configuration/sso.mdx b/docs/self-hosting/configuration/sso.mdx deleted file mode 100644 index 2d663790d..000000000 --- a/docs/self-hosting/configuration/sso.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -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/aws-ec2.mdx b/docs/self-hosting/deployment-options/aws-ec2.mdx deleted file mode 100644 index 303df2009..000000000 --- a/docs/self-hosting/deployment-options/aws-ec2.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: "AWS EC2" -description: "Learn to install Infisical on EC2 using Cloud Formation template" ---- - - -This deployment option will use AWS Cloudformation to auto deploy an instance of Infisical on a single EC2 via Docker Compose. - -**Resources that will be provisioned** -- 1 EC2 instance -- 1 DocumentDB cluster -- 1 DocumentDB instance -- Security groups - - -Once installation is complete, you will have to create the first account. No default account is provided. - - - - - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/aws-lightsail.mdx b/docs/self-hosting/deployment-options/aws-lightsail.mdx deleted file mode 100644 index b5cdb6c9d..000000000 --- a/docs/self-hosting/deployment-options/aws-lightsail.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: "AWS Lightsail" -description: "Deploy Infisical with AWS Lightsail" ---- - -Prerequisites: -- Have an account with [Amazon Web Services (AWS)](https://aws.amazon.com/) - - - - 1.1. In AWS, navigate to the **Lightsail** service and press **Create container service** under the **Containers** tab. - ![AWS Lightsail](/images/self-hosting/deployment-options/aws-lightsail/awsl-select-lightsail.png) - - ![AWS Lightsail create container service](/images/self-hosting/deployment-options/aws-lightsail/awsl-create-container-service.png) - - 1.2. In the **Container service location** section, select the AWS region that's closest to your infrastructure. - - Afterwards, in the **Container service capacity** section, set the power level and scale to fit your needs; you may opt for the default setting - and adjust accordingly in the future. - - ![AWS Lightsail container service capacity](/images/self-hosting/deployment-options/aws-lightsail/awsl-create-container-service-capacity.png) - - 1.3. In the **Set up your first deployment** section, select the **Specify a custom deployment** option. Give the container a friendly name like **infisical** and fill in your intended [Infisical public Docker image](https://hub.docker.com/r/infisical/infisical) in the **Image** field; this will pull the image from Docker Hub. - - For example, in order to opt for Infisical `v0.43.4`, you would input: `infisical/infisical:v0.43.4`. - - ![AWS Lightsail container service deployment](/images/self-hosting/deployment-options/aws-lightsail/awsl-create-container-service-deployment.png) - - 1.4. Running Infisical requires a few environment variables to be set for the container service. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). - - In the **Environment variables** section, fill in the required environment variables. - - - To use more features like emailing and single sign-on, you can set additional configuration options [here](/self-hosting/configuration/envars). - - - Also, under the **Open ports** section, add an entry for port `8080` and protocol `HTTP` since Infisical listens on port `8080`. - - ![AWS Lightsail container service environment variables](/images/self-hosting/deployment-options/aws-lightsail/awsl-create-container-service-envars.png) - - 1.5. In the **Public endpoint** section, select the container from the previous steps from the dropdown; this will make the container accessible over the public internet. - - ![AWS Lightsail container service public endpoint](/images/self-hosting/deployment-options/aws-lightsail/awsl-create-container-service-public-endpoint.png) - - 1.6. Finally, in the **Identify your service** section, give the container service a unique name like infisical and press **Create container service**. - - ![AWS Lightsail container service summary](/images/self-hosting/deployment-options/aws-lightsail/awsl-create-container-service-summary.png) - - - On the newly-created container service page, wait for the **Status** to turn to **Running** and check out the **Public domain** of the container service; you can access your instance of Infisical by this URL. - - ![AWS Lightsail container service overview](/images/self-hosting/deployment-options/aws-lightsail/awsl-container-service-overview.png) - - - - - - Yes, here are a few that come to mind: - - In step 1.3, we recommend pinning the Docker image to a specific [version of Infisical](https://hub.docker.com/r/infisical/infisical/tags) - instead of referring to the `latest` tag to avoid any unexpected version-to-version migration issues. - - We're working on putting together a fuller list of deployment best practices as well as minimum resource configuration requirements for running Infisical so stay tuned! - - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/azure-app-services.mdx b/docs/self-hosting/deployment-options/azure-app-services.mdx deleted file mode 100644 index a8472ae2b..000000000 --- a/docs/self-hosting/deployment-options/azure-app-services.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Azure App Services" -description: "Deploy Infisical with Azure App Service" ---- - -Prerequisites: - - Have an account with [Microsoft Azure](https://azure.microsoft.com/en-us) - - - - 1.1. In Azure, navigate to the **App Services** solution and press **Create > Web App**. - - ![Azure app services](/images/self-hosting/deployment-options/azure-app-services/aas-select-app-services.png) - - ![Azure create app service](/images/self-hosting/deployment-options/azure-app-services/aas-create-app-service.png) - - 1.2. In the **Basics** section, specify the **Subscription** and **Resource group** to manage the deployed resource. - - Also, give the container a friendly name like Infisical and specify a **Region** for it to be deployed to. - - ![Azure app service basics](/images/self-hosting/deployment-options/azure-app-services/aas-create-app-service-basics.png) - - 1.3. In the **Docker** section, select the **Single Container** option under **Options** and specify **Docker Hub** as the image source - - Next, under the **Docker hub options** sub-section, select the **Public** option under **Access Type** and fill in your intended [Infisical public Docker image](https://hub.docker.com/r/infisical/infisical) in the **Image and tag** field; this will pull the image from Docker Hub. - - For example, in order to opt for Infisical `v0.43.4`, you would input: `infisical/infisical:v0.43.4`. - - ![Azure app service docker](/images/self-hosting/deployment-options/azure-app-services/aas-create-app-service-docker.png) - - 1.4. Finally, in the **Review + create** section, double check the information from the previous steps and press **Create** to create the Azure app service. - - ![Azure app service review](/images/self-hosting/deployment-options/azure-app-services/aas-create-app-service-review.png) - - 1.5. Next, wait a minute or two on the deployment overview page for the app to be created. Once the deployment is complete, press **Go to resource** - to head to the **App Service dashboard** for the newly-created app. - - ![Azure app service deployment complete](/images/self-hosting/deployment-options/azure-app-services/aas-app-service-deployment-complete.png) - - 1.6. Running Infisical requires a few environment variables to be set for the Azure app service. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). - - - To use more features like emailing and single sign-on, you can set additional configuration options [here](/self-hosting/configuration/envars). - - - Additionally, you must set the variable `WEBSITES_PORT=8080` since - Infisical listens on port `8080`. - - In the **Settings > Configuration** section of the newly-created app service, fill in the required environment variables. - - ![Azure app service deployment complete](/images/self-hosting/deployment-options/azure-app-services/aas-app-service-configuration.png) - - - In the **Overview** section, check out the **Default domain** for your instance of Infisical; you can visit the instance at this URL. - - ![Azure app service deployment complete](/images/self-hosting/deployment-options/azure-app-services/aas-app-service-overview.png) - - - - - - Yes, here are a few that come to mind: - - In step 1.3, we recommend pinning the Docker image to a specific [version of Infisical](https://hub.docker.com/r/infisical/infisical/tags) - instead of referring to the `latest` tag to avoid any unexpected version-to-version migration issues. - - In step 1.2, we recommend selecting a **Region** option that is closest to your infrastructure/clients to reduce latency. - - We're working on putting together a fuller list of deployment best practices as well as minimum resource configuration requirements for running Infisical so stay tuned! - - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/azure-container-instances.mdx b/docs/self-hosting/deployment-options/azure-container-instances.mdx deleted file mode 100644 index 05e877f37..000000000 --- a/docs/self-hosting/deployment-options/azure-container-instances.mdx +++ /dev/null @@ -1,88 +0,0 @@ ---- -title: "Azure Container Instances" -description: "Deploy Infisical with Azure Container Instances" ---- - -Prerequisites: -- Have an account with [Microsoft Azure](https://azure.microsoft.com/en-us) - - - This brief goes over how to deploy an instance of Infisical with Azure Container Instances without TLS/SSL configuration. - - There are various options for enabling TLS/SSL with Azure Container Instances more suitable for production including: - - [Enabling a TLS endpoint in a sidecar container](https://learn.microsoft.com/en-us/azure/container-instances/container-instances-container-group-ssl). - - [Enabling automatic HTTPS with Caddy in a sidecar container](https://learn.microsoft.com/en-us/azure/container-instances/container-instances-container-group-automatic-ssl). - - Using Azure Function Proxies, Application Gateway, etc. - - For a simpler deployment experience with complete TLS/SSL setup, you may try [deploying Infisical with Azure App Services](/self-hosting/deployment-options/azure-app-services). - - - - - 1.1. In Azure, navigate to the **Container Instances** solution and press **Create**. - - ![Azure container instance](/images/self-hosting/deployment-options/azure-container-instances/aci-select-container-instances.png) - - ![Azure create container instance](/images/self-hosting/deployment-options/azure-container-instances/aci-create-container-instance.png) - - 1.2. In the **Basics** section, specify the **Subscription** and **Resource group** to manage the deployed resource. - - Also, give the container a friendly name like Infisical and specify a **Region** for it to be deployed to. - - ![Azure container instance basics](/images/self-hosting/deployment-options/azure-container-instances/aci-create-container-instance-basics-1.png) - - Next, select the **Public** option under **Image type** and fill in your intended [Infisical public Docker image](https://hub.docker.com/r/infisical/infisical) in the **Image** field; this will pull the image from Docker Hub. - - For example, in order to opt for Infisical `v0.43.4`, you would input: `infisical/infisical:v0.43.4`. - - ![Azure container instance basics](/images/self-hosting/deployment-options/azure-container-instances/aci-create-container-instance-basics-2.png) - - - Depending on your use-case and requirements, you may find it helpful to further configure your Azure container instance. - - For example, you may want to adjust the **Region** option to specify which region to deploy the container for your - instance of Infisical to minimize distance and therefore latency between the instance and your infrastructure. - - - 1.3. In the **Networking** section, select the **Public** option under **Networking type**; this will make the container accessible over the public internet. - - Next, under the **Ports** section, add an entry for port `8080` and protocol `TCP` since Infisical listens on port `8080`. - - ![Azure container instance networking](/images/self-hosting/deployment-options/azure-container-instances/aci-create-container-instance-networking.png) - - 1.4. Running Infisical requires a few environment variables to be set for the Azure container instance. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). - - In the **Advanced** section, fill in the required environment variables. - - - To use more features like emailing and single sign-on, you can set additional configuration options [here](/self-hosting/configuration/envars). - - - ![Azure container instance advanced](/images/self-hosting/deployment-options/azure-container-instances/aci-create-container-instance-advanced.png) - - 1.5. Finally, in the **Review + create** section, double check the information from the previous steps and press **Create** to create the Azure container instance. - - ![Azure container instance review](/images/self-hosting/deployment-options/azure-container-instances/aci-create-container-instance-review.png) - - - Head to the **Overview** page of the newly-created container instance to view its **IP address (Public)**; you can access your instance of Infisical by this IP address under the port `:8080`. - - For example, in the image below, the IP address of the sample deployed container instance is `4.255.87.109`; the instance would be accessible in the browser by heading to `4.255.87.109:8080`. - - ![Azure container instance overview](/images/self-hosting/deployment-options/azure-container-instances/aci-container-instance-overview.png) - - - - - - Yes, here are a few that come to mind: - - In step 1.2, we recommend pinning the Docker image to a specific [version of Infisical](https://hub.docker.com/r/infisical/infisical/tags) - instead of referring to the `latest` tag to avoid any unexpected version-to-version migration issues. - - In step 1.2, we recommend selecting a **Region** option that is closest to your infrastructure/clients to reduce latency. - - Enable TLS/SSL with Azure Container Instances. There are various options for doing so including [enabling a TLS endpoint in a sidecar container](https://learn.microsoft.com/en-us/azure/container-instances/container-instances-container-group-ssl), [enabling automatic HTTPS with Caddy in a sidecar container](https://learn.microsoft.com/en-us/azure/container-instances/container-instances-container-group-automatic-ssl), and using Azure Function Proxies, Application Gateway, etc. - - We're working on putting together a fuller list of deployment best practices as well as minimum resource configuration requirements for running Infisical so stay tuned! - - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx b/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx deleted file mode 100644 index f1e739f08..000000000 --- a/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Digital Ocean" -description: "Learn to install Infisical on Digital Ocean" ---- - -Infisical can be deployed on a Kubernetes cluster with a single click through our Digital Ocean marketplace application. -The initiation of the installation process triggers the creation of a Kubernetes cluster, followed by the installation of Infisical onto that cluster. - -This automated deployment method uses the same process under the hood as the manual [Kubernetes installation guide](./kubernetes-helm). - -### Initiate the installation - -To start the process, click the following button and follow the instructions there. - - - - - -### Access Infisical Web -Once the installation finishes, head to the `Networking` section via the sidebar and select `Load Balancers`. -Within this section, you'll find the newly created load balancer for Infisical. You can access Infisical at the IP address allocated to that load balancer. - -### Adjusting configurations -If you need to either upgrade or downgrade Infisical, or modify environment variables to alter its functionality, refer to our [Kubernetes installation](./kubernetes-helm) page for detailed instructions. - -Because Digital Ocean deploys the same Helm application as described in our [Kubernetes installation](./kubernetes-helm) guide, you can utilize that guide to implement the required changes. -It's important to note that any modifications requires familiarly with Helm package manager. diff --git a/docs/self-hosting/deployment-options/docker-compose.mdx b/docs/self-hosting/deployment-options/docker-compose.mdx index 304fffbe3..d61879255 100644 --- a/docs/self-hosting/deployment-options/docker-compose.mdx +++ b/docs/self-hosting/deployment-options/docker-compose.mdx @@ -1,54 +1,82 @@ --- title: "Docker Compose" -description: "Run Infisical with Docker Compose template" +description: "Read how to run Infisical with Docker Compose template." --- +This self hosting guide will walk you though the steps to self host Infisical using Docker compose. - - - ```bash - # Example in ubuntu - apt-get update - apt-get upgrade - apt install docker-compose - ``` - - - 2.1. Run the command below to download the `.env` file template. - - ```bash - wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example - ``` - - 2.2. Run the command below to download the docker compose template. - - ```bash - wget -O docker-compose.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.yml - ``` - - 2.3. Run the command below to download the `nginx` config file. - - ```bash - mkdir nginx && wget -O ./nginx/default.conf https://raw.githubusercontent.com/Infisical/infisical/main/nginx/default.dev.conf - ``` - - - - Running Infisical requires a few environment variables to be set. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` which you can read more about [here](/self-hosting/configuration/envars). +## Prerequisites +- [Docker](https://docs.docker.com/engine/install/) +- [Docker compose](https://docs.docker.com/compose/install/) - Tweak the `.env` accordingly. + +This Docker Compose configuration is not designed for high-availability production scenarios. +It includes just the essential components needed to set up an Infisical proof of concept (POC). +To run Infisical in a highly available manner, give the [Docker Swarm guide](/self-hosting/deployment-options/docker-swarm). + - ```bash - nano .env - ``` - - - Finally, run the command below to get Infisical up and running (in detached mode). +## Verify prerequisites + To verify that Docker compose and Docker are installed on the machine where you plan to install Infisical, run the following commands. + Check for docker installation ```bash - docker-compose -f docker-compose.yml up -d + docker ``` - Your Infisical installation is complete and should be running on port `80` or `http://localhost:80`. - - \ No newline at end of file + Check for docker compose installation + ```bash + docker-compose + ``` + +## Download docker compose file +You can obtain the Infisical docker compose file by using a command-line downloader such as `wget` or `curl`. +If your system doesn't have either of these, you can use a equivalent command that works with your machine. + + + + ```bash + curl -o docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + ``` + + + ```bash + wget -O docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml + ``` + + + +## Configure instance credentials +Infisical requires a set of credentials used for connecting to dependent services such as Postgres, Redis, etc. +The default credentials can be downloaded using the one of the commands listed below. + + + + ```bash + curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example + ``` + + + ```bash + wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example + ``` + + + +Once downloaded, the credentials file will be saved to your working directly as `.env` file. +View all available configurations [here](/self-hosting/configuration/envars). + + + The default .env file contains credentials that are intended solely for testing purposes. + Please generate a new `ENCRYPTION_KEY` and `AUTH_SECRET` for use outside of testing. + Instructions to do so, can be found [here](/self-hosting/configuration/envars). + + +## Start Infisical +Run the command below to start Infisical and all related services. + +```bash +docker-compose -f docker-compose.prod.yml up +``` + +Your Infisical instance should now be running on port `80`. To access your instance, visit `http://localhost:80`. + +![self host sign up](/images/self-hosting/applicable-to-all/selfhost-signup.png) diff --git a/docs/self-hosting/deployment-options/docker-swarm.mdx b/docs/self-hosting/deployment-options/docker-swarm.mdx new file mode 100644 index 000000000..c63aff23b --- /dev/null +++ b/docs/self-hosting/deployment-options/docker-swarm.mdx @@ -0,0 +1,216 @@ +--- +title: "Docker Swarm" +description: "How to self Infisical with Docker Swarm (HA)." +--- + +# Self-Hosting Infisical with Docker Swarm + +This guide will provide step-by-step instructions on how to self-host Infisical using Docker Swarm. This is particularly helpful for those wanting to self host Infisical on premise while still maintaining high availability (HA) for the core Infisical components. +The guide will demonstrate a setup with three nodes, ensuring that the cluster can tolerate the failure of one node while remaining fully operational. + +## Docker Swarm + +[Docker Swarm](https://docs.docker.com/engine/swarm/) is a native clustering and orchestration solution for Docker containers. +It simplifies the deployment and management of containerized applications across multiple nodes, making it a great choice for self-hosting Infisical. + +Unlike Kubernetes, which requires a deep understanding of the Kubernetes ecosystem, if you're accustomed to Docker and Docker Compose, you're already familiar with most of Docker Swarm. +For this reason, we suggest teams use Docker Swarm to deploy Infisical in a highly available and fault tolerant manner. + +## Prerequisites +- Understanding of Docker Swarm +- Bare/Virtual Machines with Docker installed on each VM. +- Docker Swarm initialized on the VMs. + +## Core Components for High Availability + +The provided Docker stack includes the following core components to achieve high availability: + +1. **Spilo**: [Spilo](https://github.com/zalando/spilo) is used to run PostgreSQL with [Patroni](https://github.com/zalando/patroni) for HA and automatic failover. It utilizes etcd for leader election of the PostgreSQL instances. + +2. **Redis**: Redis is used for caching and is set up with Redis Sentinel for HA. +The stack includes three Redis replicas and three Redis Sentinel instances for monitoring and failover. + +3. **Infisical**: Infisical is stateless, allowing for easy scaling and replication across multiple nodes. + +4. **HAProxy**: HAProxy is used as a load balancer to distribute traffic to the PostgreSQL and Redis instances. +It is configured to perform health checks and route requests to the appropriate backend services. + +## Node Failure Tolerance + +To ensure Infisical is highly available and fault tolerant, it's important to choose the number of nodes in the cluster. +The following table shows the relationship between the number of nodes and the maximum number of nodes that can be down while the cluster continues to function: + +| Total Nodes | Max Nodes Down | Min Nodes Required | +|-------------|----------------|-------------------| +| 1 | 0 | 1 | +| 2 | 0 | 2 | +| 3 | 1 | 2 | +| 4 | 1 | 3 | +| 5 | 2 | 3 | +| 6 | 2 | 4 | +| 7 | 3 | 4 | + +The formula for calculating the minimum number of nodes required is: `floor(n/2) + 1`, where `n` is the total number of nodes. + +This guide will demonstrate a setup with three nodes, which allows for one node to be down while the cluster remains operational. This fault tolerance applies to the following components: + +- Redis Sentinel: With three Sentinel instances, one instance can be down, and the remaining two can still form a quorum to make decisions. +- Redis: With three Redis instances (one master and two replicas), one instance can be down, and the remaining two can continue to provide caching services. +- PostgreSQL: With three PostgreSQL instances managed by Patroni and etcd, one instance can be down, and the remaining two can maintain data consistency and availability. +- Manager Nodes: In a Docker Swarm cluster with three manager nodes, one manager node can be down, and the remaining two can continue to manage the cluster. +For the sake of simplicity, the example in this guide only contains one manager node. + +It's important to note that while the cluster can tolerate the failure of one node in a three-node setup, it's recommended to have a minimum of three nodes to ensure high availability. +With two nodes, the failure of a single node can result in a loss of quorum and potential downtime. + +## Docker Deployment Stack Overview + +The [Docker stack file](https://github.com/Infisical/infisical/tree/main/docker-swarm) used in this guide defines the services and their configurations for deploying Infisical in a highly available manner. The main components of this stack are as follows. + +1. **HAProxy**: The HAProxy service is configured to expose ports for accessing PostgreSQL (5433 for the master, 5434 for replicas), Redis master (6379), and the Infisical backend (8080). It uses a config file (`haproxy.cfg`) to define the load balancing and health check rules. + +2. **Infisical**: The Infisical backend service is deployed with the latest PostgreSQL-compatible image. It is connected to the `infisical` network and uses secrets for environment variables. + +3. **etcd**: Three etcd instances (etcd1, etcd2, etcd3) are deployed, one on each node, to provide distributed key-value storage for leader election and configuration management. + +4. **Spilo**: Three Spilo instances (spolo1, spolo2, spolo3) are deployed, one on each node, to run PostgreSQL with Patroni for high availability. They are connected to the `infisical` network and use persistent volumes for data storage. + +5. **Redis**: Three Redis instances (redis_replica0, redis_replica1, redis_replica2) are deployed, one on each node, with redis_replica0 acting as the master. They are connected to the `infisical` network. + +6. **Redis Sentinel**: Three Redis Sentinel instances (redis_sentinel1, redis_sentinel2, redis_sentinel3) are deployed, one on each node, to monitor and manage the Redis instances. They are connected to the `infisical` network. + +## Deployment instructions + + + + ``` + docker swarm init + ``` + + Replace `` with the IP address of the VM that will serve as the manager node. Remember to copy the join token returned by the this init command. + + + For the sake of simplicity, we only use one manager node in this example deployment. However, in production settings, we recommended you have at least 3 manager nodes. + + + + + ``` + docker swarm join --token :2377 + ``` + + Replace `` with the token provided by the manager node during initialization. + + + + + Labels on nodes will help us select where stateful components such as Postgres and Redis are deployed on. To label nodes, follow the steps below. + + ``` + docker node update --label-add name=node1 + docker node update --label-add name=node2 + docker node update --label-add name=node3 + ``` + + Replace ``, ``, and `` with the respective node IDs. + To view the list of nodes and their ids, run the following on the manager node `docker node ls`. + + + + + Copy the Docker stack YAML file, HAProxy configuration file and example `.env` file to the manager node. Ensure that all 3 files are placed in the same file directory. + - [Docker stack file](https://github.com/Infisical/infisical/blob/main/docker-swarm/stack.yaml) (rename to infisical-stack.yaml) + - [HA configuration file](https://github.com/Infisical/infisical/blob/main/docker-swarm/haproxy.cfg) (rename to haproxy.cfg) + - [Example .env file](https://github.com/Infisical/infisical/blob/main/docker-swarm/.env-example) (rename to .env) + + + + + ``` + docker stack deploy -c infisical-stack.yaml infisical + ``` + + + + ```plain + $ docker service ls + ID NAME MODE REPLICAS IMAGE PORTS + 4kzq3ub8qgn9 infisical_etcd1 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + tqx9t82bn8d9 infisical_etcd2 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + t8vbkrasy8fz infisical_etcd3 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + 77iei42fcf6q infisical_haproxy global 4/4 haproxy:latest *:5002-5003->5433-5434/tcp, *:6379->6379/tcp, *:7001->7000/tcp, *:8080->8080/tcp + jaewzqy8md56 infisical_infisical replicated 5/5 infisical/infisical:v0.60.1-postgres + 58w4zablfbtb infisical_redis_replica0 replicated 1/1 bitnami/redis:6.2.10 + w4yag2whq0un infisical_redis_replica1 replicated 1/1 bitnami/redis:6.2.10 + w03mriy0jave infisical_redis_replica2 replicated 1/1 bitnami/redis:6.2.10 + ppo6rk47hc9t infisical_redis_sentinel1 replicated 1/1 bitnami/redis-sentinel:6.2.10 + ub29vd0lnq7f infisical_redis_sentinel2 replicated 1/1 bitnami/redis-sentinel:6.2.10 + szg3yky7yji2 infisical_redis_sentinel3 replicated 1/1 bitnami/redis-sentinel:6.2.10 + eqtocpf5tiy0 infisical_spolo1 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + 3lznscvk7k5t infisical_spolo2 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + v04ml7rz2j5q infisical_spolo3 replicated 1/1 ghcr.io/zalando/spilo-16:3.2-p2 + ``` + + + You'll notice that service `infisical_infisical` will not be in running state. + This is expected as the database does not yet have the desired schemas. + Once the database schema migrations have been successfully applied, this issue should be resolved. + + + + + Run the schema migration to initialize the database. Follow the [guide here](/self-hosting/configuration/schema-migrations) to learn how. + + To connect to the Postgres database, use the following default credentials defined in the Docker swarm: username: `postgres`, password: `postgres` and database: `postgres`. + + + + ![HA Proxy stats](/images/self-hosting/deployment-options/docker-swarm/ha-proxy-ha.png) + To view the health of services in your Infisical cluster, visit port `:7001` of any node in your Docker swarm. + This port will expose the HA Proxy stats. + + Run the following command to view the IPs of the nodes in your docker swarm. + + ```plain + $ docker node ls + ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION + 0jnegl4gpo235l66nglcwc07t localhost Ready Active 26.0.2 + no1a7zwj88057k73m196ulkq6 * localhost Ready Active Leader 26.0.2 + wcb2x27w3tq7ht4v1h7ke49qk localhost Ready Active 26.0.2 + zov5q7uop7wpxc2ndz712v9oa localhost Ready Active 26.0.2 + ``` + + + The stats page may take 1-2 minutes to become accessible. + + + + + ![self host sign up](/images/self-hosting/applicable-to-all/selfhost-signup.png) + Once all expected services are up and running, visit `:8080` of any node in the swarm. This will take you to the Infisical configuration page. + + + + +## FAQ + + To further scale and make the system more resilient, you can add more nodes to the Docker Swarm and update the stack configuration accordingly: + + 1. Add new VMs and join them to the Docker Swarm as worker nodes. + + 2. Update the Docker stack YAML file to include the new nodes in the `deploy` section of the relevant services, specifying the appropriate `node.labels.name` constraints. + + 3. Update the HAProxy configuration file (`haproxy.cfg`) to include the new nodes in the backend sections for PostgreSQL and Redis. + + 4. Redeploy the updated stack using the `docker stack deploy` command. + + Note that the database containers (PostgreSQL) are stateful and cannot be simply replicated. Instead, one database instance is deployed per node to ensure data consistency and avoid conflicts. + + + + +Native tooling for scheduled backups of Postgres and Redis is currently in development. +In the meantime, we recommend using a variety of open-source tools available for this purpose. +For Postgres, [Spilo](https://github.com/zalando/spilo) provides built-in support for scheduled data dumps. +You can explore other third party tools for managing db backups, one such tool is [docker-db-backup](https://github.com/tiredofit/docker-db-backup). + diff --git a/docs/self-hosting/deployment-options/fly.io.mdx b/docs/self-hosting/deployment-options/fly.io.mdx deleted file mode 100644 index dacd9476b..000000000 --- a/docs/self-hosting/deployment-options/fly.io.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: "Fly.io" -description: "Deploy Infisical with Fly.io" ---- - -Prerequisites: -- Have an account with [Fly.io](https://fly.io/) -- Have installed the [Fly.io CLI](https://fly.io/docs/hands-on/install-flyctl/) - - - - In your terminal, run the following command from the source directory of your project to create a new Fly.io app - with a `fly.toml` configuration file: - - ``` - fly launch - ``` - - - Add a **build** section to the `fly.toml` file to specify the [Infisical public Docker image](https://hub.docker.com/r/infisical/infisical): - - ``` - [build] - image = "infisical/infisical:v0.43.4" - ``` - - Afterwards, your `fly.toml` file should look similar to: - - ``` - app = "infisical" - primary_region = "lax" - - [http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = true - auto_start_machines = true - min_machines_running = 0 - processes = ["app"] - - [[vm]] - cpu_kind = "shared" - cpus = 1 - memory_mb = 1024 - - [build] - image = "infisical/infisical:v0.43.4" - ``` - - - Depending on your use-case and requirements, you may find it helpful to further configure your `fly.toml` file - with options [here](https://fly.io/docs/reference/configuration/). - - For example, you may want to adjust the `primary-region` option to specify which [region](https://fly.io/docs/reference/regions/) to create the new machine for your - instance of Infisical to minimize distance and therefore latency between the instance and your infrastructure. - - - - - Running Infisical requires a few environment variables to be set on the Fly.io machine. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). - - For this step, we recommend setting the variables as Fly.io [app secrets](https://fly.io/docs/reference/secrets/) which - are made available to the app as environment variables. You can set the variables either via the Fly.io CLI or project [dashboard](https://fly.io/dashboard). - - - - Run the following command (with each `VALUE` replaced) in the source directory of your project to set the required variables: - - ``` - flyctl secrets set ENCRYPTION_KEY=VALUE AUTH_SECRET=VALUE MONGO_URL=VALUE REDIS_URL=VALUE... - ``` - - - In Fly.io, head to your Project > Secrets and add the required variables. - - ![Fly.io deployment secrets](/images/self-hosting/deployment-options/flyio/flyio-secrets.png) - - - - - To use more features like emailing and single sign-on, you can set additional configuration options [here](/self-hosting/configuration/envars). - - - - Finally, run the following command in the source directory of your project to deploy your Infisical instance on Fly.io - with the updated `fly.toml` configuration file from step 2 and secrets from step 3: - - ``` - fly deploy - ``` - - - - - - Yes, here are a few that come to mind: - - In step 2, we recommend pinning the Docker image to a specific [version of Infisical](https://hub.docker.com/r/infisical/infisical/tags) - instead of referring to the `latest` tag to avoid any unexpected version-to-version migration issues. - - In step 2, we recommend selecting a `primary_region` option that is closest to your infrastructure/clients to reduce latency; a full list of regions supported by Fly.io can be found [here](https://fly.io/docs/reference/regions/). - - We're working on putting together a fuller list of deployment best practices as well as minimum resource configuration requirements for running Infisical so stay tuned! - - - -Resources: -- [Fly.io documentation](https://fly.io/docs/) \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/gcp-cloud-run.mdx b/docs/self-hosting/deployment-options/gcp-cloud-run.mdx deleted file mode 100644 index 67c9fcf57..000000000 --- a/docs/self-hosting/deployment-options/gcp-cloud-run.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "GCP Cloud Run" -description: "Deploy Infisical with GCP Cloud Run" ---- - -Prerequisites: -- Have an account with [Google Cloud Platform (GCP)](https://cloud.google.com/) - - - - In GCP, create a new project and give it a friendly name like Infisical. - - ![GCP create project](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-create-project.png) - - ![GCP create project](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-create-project-2.png) - - - 2.1. Inside the GCP project, navigate to the **Cloud Run** product and create a new service. - - ![GCP Cloud Run](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-select-cloud-run.png) - - ![GCP Cloud Run create service](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-create-service.png) - - 2.2. In the service creation form, select the **Deploy one revision from an existing container image** option and fill in your intended [Infisical public Docker image](https://hub.docker.com/r/infisical/infisical) in the container image URL. - - For example, in order to opt for Infisical `v0.43.4`, you would input: `docker.io/infisical/infisical:v0.43.4`. - - ![GCP Cloud Run create service docker image specification](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-create-service-docker-image.png) - - 2.3. Running Infisical requires a few environment variables to be set for the GCP Cloud Run service. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). - - For this step, fill in the required environment variables in the Edit Container > Variables & Secrets > Environment variables section. - - - To use more features like emailing and single sign-on, you can set additional configuration options [here](/self-hosting/configuration/envars). - - - ![GCP Cloud Run create service environment variable specification](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-create-service-envars.png) - - - Depending on your use-case and requirements, you may find it helpful to further configure your GCP Cloud Run service. - - For example, you may want to adjust the **Region** option to specify which region to deploy the underlying container for your - instance of Infisical to minimize distance and therefore latency between the instance and your infrastructure. - - - Finally, press **Create** to finish setting up the GCP Cloud Run service. - - - Head to the **Service details** of the newly-created service to view its URL; you can access your instance of Infisical by clicking on the URL. - - ![GCP Cloud Run service details](/images/self-hosting/deployment-options/gcp-cloud-run/gcp-cloud-run-service-details.png) - - - - - - Yes, here are a few that come to mind: - - In step 2, we recommend pinning the Docker image to a specific [version of Infisical](https://hub.docker.com/r/infisical/infisical/tags) - instead of referring to the `latest` tag to avoid any unexpected version-to-version migration issues. - - In step 2, we recommend selecting a **Region** option that is closest to your infrastructure/clients to reduce latency. - - We're working on putting together a fuller list of deployment best practices as well as minimum resource configuration requirements for running Infisical so stay tuned! - - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index 564e410f8..0ce979d06 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -1,163 +1,190 @@ --- title: "Kubernetes via Helm Chart" -description: "Use our Helm chart to Install Infisical on your Kubernetes cluster" +description: "Learn how to use Helm chart to install Infisical on your Kubernetes cluster." --- **Prerequisites** -- You have understanding of [Kubernetes](https://kubernetes.io/) +- You have extensive understanding of [Kubernetes](https://kubernetes.io/) - Installed [Helm package manager](https://helm.sh/) version v3.11.3 or greater - You have [kubectl](https://kubernetes.io/docs/reference/kubectl/kubectl/) installed and connected to your kubernetes cluster -By deploying Infisical on Kubernetes, you can take advantage of its features to ensure that the application is fault-tolerant, highly available, and scalable. -To make the installation process easier and more streamlined, we have created a Helm chart that you can use to install Infisical on Kubernetes. - -Helm is a package manager for Kubernetes that simplifies the installation and management of Kubernetes applications. -With our Helm chart, you can easily install Infisical on Kubernetes, configure it to your liking, and scale it up or down as needed. - -## Install Infisical Helm repository - -```bash -helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - -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. 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` 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, click [here](https://hub.docker.com/r/infisical/infisical/tags) - - -```yaml simple-values-example.yaml -backend: - replicaCount: 2 - image: - tag: "v0.39.5" # <--- update to the newest version found here https://hub.docker.com/r/infisical/infisical/tags - pullPolicy: Always -``` - -#### Configure environment variables - -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`_. - - -Each of the above environment variables can be generated by running the command `openssl rand -hex 16` in your terminal. - - -However, when the above environment variables are not defined, the Helm chart -will automatically generate these environment variables for you. The generated environment variables will be saved to a Kubernetes secret and will be preserved between upgrades or uninstalls. - -```yaml simple-values-example.yaml -... -backendEnvironmentVariables: - HTTPS_ENABLED: true - INVITE_ONLY_SIGNUP: false - ... -``` - - - Infisical assumes that you have configured HTTPS. If you didn't configure HTTPS, set `HTTPS_ENABLED` to `false` in the backend environment variable to avoid frequent logouts. - - -#### Routing external traffic -By default, Infisical takes all traffic coming to your external load balancer's IP address and routes them Infisical's services. -Infisical uses Nginx to route external traffic. You can install Nginx along with Infisical by setting `ingress.enabled` to `true` in the Helm values file. View all [properties for ingress](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical). - -```yaml simple-values-example.yaml -... -ingress: - nginx: - enabled: true #<-- if you would like to install nginx along with Infisical -``` - -#### Database -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: true - persistence: - enabled: false -``` - -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 -backend: - replicaCount: 2 - image: - tag: "v0.39.5" - pullPolicy: Always - -backendEnvironmentVariables: - HTTPS_ENABLED: true - -ingress: - nginx: - enabled: true - -``` - - - ```yaml values.yaml - ingress: - nginx: - enabled: true - - backend: - enabled: true - name: backend - podAnnotations: {} - deploymentAnnotations: {} - replicaCount: 4 - image: - tag: "v0.39.5" - pullPolicy: IfNotPresent - kubeSecretRef: null - service: - annotations: {} - type: ClusterIP - nodePort: "" - - # View all environment variables https://infisical.com/docs/self-hosting/configuration/envars - backendEnvironmentVariables: - MONGO_URL: <> - HTTPS_ENABLED: <> + + + ```bash + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + ``` + ``` + helm repo update + ``` + + + Create a `values.yaml` file. This will be used to configure settings for the Infisical Helm chart. + To explore all configurable properties for your values file, [visit this page](https://raw.githubusercontent.com/Infisical/infisical/main/helm-charts/infisical-standalone-postgres/values.yaml). + + + By default, the Infisical version set in your helm chart will likely be outdated. + Choose the latest Infisical docker image tag from [here](https://hub.docker.com/r/infisical/infisical/tags). - ## Mongo DB persistence - mongodb: - enabled: true - persistence: - enabled: true - ``` - + ```yaml values.yaml + infisical: + image: + repository: infisical/infisical + tag: "v0.46.2-postgres" #<-- update + pullPolicy: IfNotPresent + ``` + + Do you not use the latest docker image tag in production deployments as they can introduce unexpected changes + + -## Install the Helm chart + -By default, the helm chart will be installed on your default namespace. If you wish to install the Chart on a different namespace, you may specify -that by adding the `--namespace ` to your `helm install` command. + To deploy this Helm chart, a Kubernetes secret named `infisical-secrets` must be present in the same namespace where the chart is being deployed. -```bash -## Installs to default namespace -helm install infisical-helm-charts/infisical --generate-name --values /path/to/values.yaml -``` + For a minimal installation of Infisical, you need to configure `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI`, and `REDIS_URL`. [Learn more about configuration settings](/self-hosting/configuration/envars). -## Access Infisical -Allow 3-5 minutes for the deployment to complete. Once done, you should now be able to access Infisical on the IP address exposed via Ingress on your load balancer. If you are not sure what the IP address is run `kubectl get ingress` to view the external IP address exposing Infisical. - - -Once installation is complete, you will have to create the first account. No default account is provided. - -## Related blogs -- [Set up Infisical in a development cluster](https://iamunnip.hashnode.dev/infisical-open-source-secretops-kubernetes-setup) + + + For test or proof-of-concept purposes, you may omit `DB_CONNECTION_URI` and `REDIS_URL` from `infisical-secrets`. This is because the Helm chart will automatically provision and connect to the in-cluster instances of Postgres and Redis by default. + ```yaml simple-values-example.yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-secrets + type: Opaque + stringData: + AUTH_SECRET: <> + ENCRYPTION_KEY: <> + ``` + + + For production environments, we recommend using Cloud-based Platform as a Service (PaaS) solutions for PostgreSQL and Redis to ensure high availability. In on-premise setups, it's recommended to configure Redis and Postgres for high availability, either by using Bitnami charts or a custom configuration. + ```yaml simple-values-example.yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-secrets + type: Opaque + stringData: + AUTH_SECRET: <> + ENCRYPTION_KEY: <> + REDIS_URL: <> + DB_CONNECTION_URI: <> + ``` + + + + + + Infisical relies a relational database, which means that database schemas need to be migrated before the instance can become operational. + + To automate this process, the chart includes a option named `infisical.autoDatabaseSchemaMigration`. + When this option is enabled, a deployment/upgrade will only occur _after_ a successful schema migration. + + + If you are using in-cluster Postgres, you may notice the migration job failing initially. + This is expected as it is waiting for the database to be in ready state. + + + + + By default, this chart uses Nginx as its Ingress controller to direct traffic to Infisical services. + + ```yaml values.yaml + ingress: + nginx: + enabled: true + ``` + + + + Once you are done configuring your `values.yaml` file, run the command below. + + ```bash + helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml + ``` + + + ```yaml values.yaml + + nameOverride: "infisical" + fullnameOverride: "infisical" + + infisical: + enabled: true + name: infisical + autoDatabaseSchemaMigration: true + fullnameOverride: "" + podAnnotations: {} + deploymentAnnotations: {} + replicaCount: 6 + + image: + repository: infisical/infisical + tag: "v0.46.2-postgres" + pullPolicy: IfNotPresent + + affinity: {} + kubeSecretRef: "infisical-secrets" + service: + annotations: {} + type: ClusterIP + nodePort: "" + + resources: + limits: + memory: 210Mi + requests: + cpu: 200m + + ingress: + enabled: true + hostName: "" + ingressClassName: nginx + nginx: + enabled: true + annotations: {} + tls: [] + + postgresql: + enabled: true + name: "postgresql" + fullnameOverride: "postgresql" + auth: + username: infisical + password: root + database: infisicalDB + + redis: + enabled: true + name: "redis" + fullnameOverride: "redis" + cluster: + enabled: false + usePassword: true + auth: + password: "mysecretpassword" + architecture: standalone + ``` + + + + + After deployment, please wait for 2-5 minutes for all pods to reach a running state. Once a significant number of pods are operational, access the IP address revealed through Ingress by your load balancer. + You can find the IP address/hostname by executing the command `kubectl get ingress`. + ![infisical-selfhost](/images/self-hosting/applicable-to-all/selfhost-signup.png) + + + To upgrade your instance of Infisical simply update the docker image tag in your Helm values and rerun the command below. + + ```bash + helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml + ``` + + + Always back up your database before each upgrade, especially in a production environment. + + + + diff --git a/docs/self-hosting/deployment-options/railway.mdx b/docs/self-hosting/deployment-options/railway.mdx deleted file mode 100644 index 29d2ce293..000000000 --- a/docs/self-hosting/deployment-options/railway.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Railway" -description: "Deploy Infisical with Railway" ---- - -Prerequisites: -- Have an account with [Railway](https://railway.app/) - - - - 1.1. In Railway, create a new project and select **Deploy a template > Infisical**. - - ![Railway create project](/images/self-hosting/deployment-options/railway/railway-create-project.png) - - ![Railway deploy template](/images/self-hosting/deployment-options/railway/railway-deploy-template.png) - - ![Railway deploy template infisical](/images/self-hosting/deployment-options/railway/railway-deploy-template-infisical.png) - - ![Railway template overview](/images/self-hosting/deployment-options/railway/railway-template-overview.png) - - 1.2. At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). - - By default, the Infisical template on Railway pre-configures environment variables on each service in the deployment but requires you to supply two for the Redis and MongoDB services. - - On the MongoDB service, supply a value for the `MONGO_INITDB_ROOT_PASSWORD` variable. - - ![Railway template MongoDB configuration](/images/self-hosting/deployment-options/railway/railway-template-mongodb.png) - - On the Redis service, supply a value for the `REDIS_PASSWORD` variable. - - ![Railway template Redis configuration](/images/self-hosting/deployment-options/railway/railway-template-redis.png) - - ![Railway template Redis configuration](/images/self-hosting/deployment-options/railway/railway-template-redis.png) - - - To use more features like emailing and single sign-on, you can set additional configuration options on the Infisical service [here](/self-hosting/configuration/envars). - - - Finally, press **Deploy** to create the project and deploy the services within it. - - ![Railway template Infisical configuration](/images/self-hosting/deployment-options/railway/railway-template-infisical.png) - - ![Railway Infisical architecture](/images/self-hosting/deployment-options/railway/railway-infisical-architecture.png) - - - Head to the newly-created Infisical service to view its URL under Networking > Public Networking; you can access your instance of Infisical by clicking on the URL. - - ![Railway Infisical service](/images/self-hosting/deployment-options/railway/railway-infisical-service.png) - - - - - - Yes, here are a few that come to mind: - - While the Infisical template on Railway uses the `latest` tag to get the latest version of Infisical, we recommend creating a Railway deployment that pins the Docker image to a specific [version of Infisical](https://hub.docker.com/r/infisical/infisical/tags) to avoid any unexpected version-to-version migration issues. - - We recommend selecting **Deployment region** options for your Railway service deployments to be closest to your infrastructure/clients to reduce latency. - - We're working on putting together a fuller list of deployment best practices as well as minimum resource configuration requirements for running Infisical so stay tuned! - - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/render.mdx b/docs/self-hosting/deployment-options/render.mdx deleted file mode 100644 index 17faf060a..000000000 --- a/docs/self-hosting/deployment-options/render.mdx +++ /dev/null @@ -1,21 +0,0 @@ ---- -title: "Render.com" -description: "Learn to install Infisical Render.com" ---- - -**Prerequisites** -- An account at Render.com -- A document DB instance - -Deploying on Render is one of the quickest ways to have Infisical running in production. -Before you start deployment, you will need to obtain document db connection string. This will be used for `MONGO_URL` environment variable required during installation. - -You can create a document db database using services such as [MongoDB](https://www.mongodb.com/), [AWS DocumentDB](https://aws.amazon.com/documentdb/), and others. Once done, click the link below to start deployment. - -### **[Deploy to Render](https://render.com/deploy?repo=https://github.com/Infisical/infisical)** - -# - - -Once installation is complete, you will have to create the first account. No default account is provided. - \ No newline at end of file diff --git a/docs/self-hosting/deployment-options/standalone-infisical.mdx b/docs/self-hosting/deployment-options/standalone-infisical.mdx index 5da640337..ab1512612 100644 --- a/docs/self-hosting/deployment-options/standalone-infisical.mdx +++ b/docs/self-hosting/deployment-options/standalone-infisical.mdx @@ -1,25 +1,38 @@ --- title: "Docker" -description: "Run Infisical with Docker" +description: "Learn how to run Infisical with Docker." --- Prerequisites: - Basic knowledge of [Docker](https://www.docker.com/) - Have Docker installed on your system. If not, follow the installation guide [here](https://docs.docker.com/get-docker/). +Infisical is available as a single Docker image to ease deployment. +This Docker image only includes the application code, meaning you must supply a connection to a Postgres database and a Redis instance. +The following guide provides a detailed step-by-step walkthrough on how you can deploy Infisical with Docker. + - Run the following command in your terminal to pull the Infisical Docker image: + Visit [Docker Hub](https://hub.docker.com/r/infisical/infisical/tags) and select a version of Infisical image you would like to deploy. + Then run the following command in your terminal to pull the specific Infisical Docker image. ``` - docker pull infisical/infisical:latest + docker pull infisical/infisical: ``` + + Remember to replace `` with the docker image tag of your choice. + + + Before you can start the instance of Infisical, you need to run the database schema migrations. + Follow the step by [step guide here](/self-hosting/configuration/schema-migrations) on running schema migrations for Infisical. + - 2.1. Running Infisical requires a few environment variables to be set. - At minimum, Infisical requires that you set the variables `ENCRYPTION_KEY`, `AUTH_SECRET`, `MONGO_URL`, and `REDIS_URL` - which you can read more about [here](/self-hosting/configuration/envars). + For a minimal installation of Infisical, you must configure `ENCRYPTION_KEY`, `AUTH_SECRET`, `DB_CONNECTION_URI`, and `REDIS_URL`. [View all available configurations](/self-hosting/configuration/envars). + + We recommend using Cloud-based Platform as a Service (PaaS) solutions for PostgreSQL and Redis to ensure high availability. + Once you have added the required environment variables to your docker run command, execute it in your terminal to get Infisical up and running. For example: @@ -28,22 +41,22 @@ Prerequisites: docker run -p 80:8080 \ -e ENCRYPTION_KEY=f40c9178624764ad85a6830b37ce239a \ -e AUTH_SECRET="q6LRi7c717a3DQ8JUxlWYkZpMhG4+RHLoFUVt3Bvo2U=" \ - -e MONGO_URL="<>" \ - infisical/infisical:latest + -e DB_CONNECTION_URI="<>" \ + -e REDIS_URL="<>" \ + infisical/infisical: ``` The above environment variable values are only to be used as an example and should not be used in production - 2.2. Once the container is running, verify the installation by opening your web browser and navigating to `http://localhost:80`. + Once the container is running, verify the installation by opening your web browser and navigating to `http://localhost:80`. + + ![self host sign up](/images/self-hosting/applicable-to-all/selfhost-signup.png) - - - 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. - - \ No newline at end of file +### Additional discussion +It's important to note that the above is a basic example of deploying Infisical using Docker. +In practice, for production deployments, you may want to use container orchestration platforms such as AWS ECS, Google Cloud Run, or Kubernetes. +These platforms offer additional features like scalability, load balancing, and automated deployment, making them suitable for handling production-level traffic and providing high availability. \ No newline at end of file diff --git a/docs/self-hosting/deployments/kubernetes.mdx b/docs/self-hosting/deployments/kubernetes.mdx deleted file mode 100644 index cbba7a946..000000000 --- a/docs/self-hosting/deployments/kubernetes.mdx +++ /dev/null @@ -1,138 +0,0 @@ ---- -title: "Kubernetes" -description: "How to deploy Infisical with Kubernetes" ---- - - -Self-host vs. Infisical Cloud - -Self-hosting Infisical means managing the service yourself, taking care of upgrades, scaling, security, etc. - -If you're less technical and looking for a hands-free experience with minimal overhead then we recommend Infisical Cloud. - - - -**Prerequisites** -- You have understanding of [Kubernetes](https://kubernetes.io/) -- You have understanding of [Helm package manager](https://helm.sh/) -- You have [kubectl](https://kubernetes.io/docs/reference/kubectl/kubectl/) installed and connected to your kubernetes cluster - - -#### 1. Fill our environment variables - -Before you can deploy the Helm chart, you must fill out the required environment variables. To do so, please copy the below file to a `.yaml` file. -Refer to the available [environment variables](../../self-hosting/configuration/envars) to learn more - - -[View all available Helm chart values parameters](https://github.com/Infisical/infisical/tree/main/helm-charts/infisical) -```yaml -frontend: - enabled: true - name: frontend - podAnnotations: {} - deploymentAnnotations: {} - replicaCount: 2 - image: - repository: infisical/frontend - tag: "latest" - pullPolicy: IfNotPresent - kubeSecretRef: "" - service: - annotations: {} - type: ClusterIP - nodePort: "" - -frontendEnvironmentVariables: - SITE_URL: infisical.local - -backend: - enabled: true - name: backend - podAnnotations: {} - deploymentAnnotations: {} - replicaCount: 2 - image: - repository: infisical/backend - tag: "latest" - pullPolicy: IfNotPresent - kubeSecretRef: "" - service: - annotations: {} - type: ClusterIP - nodePort: "" - -backendEnvironmentVariables: - ENCRYPTION_KEY: MUST_REPLACE - JWT_SIGNUP_SECRET: MUST_REPLACE - JWT_REFRESH_SECRET: MUST_REPLACE - JWT_AUTH_SECRET: MUST_REPLACE - JWT_SERVICE_SECRET: MUST_REPLACE - SMTP_HOST: MUST_REPLACE - SMTP_PORT: 587 - SMTP_SECURE: false - SMTP_FROM_NAME: Infisical - SMTP_FROM_ADDRESS: MUST_REPLACE - SMTP_USERNAME: MUST_REPLACE - SMTP_PASSWORD: MUST_REPLACE - SITE_URL: infisical.local - -## Mongo DB persistence -mongodb: - enabled: true - -## By default the backend will be connected to a Mongo instance within the cluster -## However, it is recommended to add a managed document DB connection string for production-use (DBaaS) -## Learn about connection string type here https://www.mongodb.com/docs/manual/reference/connection-string/ -## e.g. "mongodb://:@:/" -mongodbConnection: - externalMongoDBConnectionString: "" - -ingress: - enabled: true - annotations: - kubernetes.io/ingress.class: "nginx" - # cert-manager.io/issuer: letsencrypt-nginx - hostName: infisical.local ## <- Replace with your own domain - frontend: - path: / - pathType: Prefix - backend: - path: /api - pathType: Prefix - tls: [] - # - secretName: letsencrypt-nginx - # hosts: - # - infisical.local - -mailhog: - enabled: false -``` - - -Once you have a local copy of the values file, fill our the required environment variables and save the file. - - -#### 2. Install Infisical Helm repository - -```bash -helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - -helm repo update -``` - -#### 3. Install the Helm chart - -By default, the helm chart will be installed on your default namespace. If you wish to install the Chart on a different namespace, you may specify -that by adding the `--namespace ` to your `helm install` command. - -```bash -## Installs to default namespace -helm install infisical-helm-charts/infisical --generate-name --values -``` - - -If you have not filled out all of the required environment variables, you will see an error message prompting you to -do so. - - -#### 4. Your Infisical installation is complete and should be running on the host name you specified in Ingress in `values.yaml`. \ No newline at end of file diff --git a/docs/self-hosting/deployments/linux.mdx b/docs/self-hosting/deployments/linux.mdx deleted file mode 100644 index d7490f870..000000000 --- a/docs/self-hosting/deployments/linux.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: "Linux VM" -description: "How to deploy Infisical with Docker-Compose" ---- - - -Self-host vs. Infisical Cloud - -Self-hosting Infisical means managing the service yourself, taking care of upgrades, scaling, security, etc. - -If you're less technical and looking for a hands-free experience with minimal overhead then we recommend Infisical Cloud. - - - -We provide a docker-compose deployment option for those who want to deploy Infisical onto a Linux VM easily. - -1. Install Docker on your VM - -```bash -# Example in ubuntu -apt-get update -apt-get upgrade -apt install docker-compose -``` - -2. Download the required files - -```bash -# Download env file template -wget -O .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example - -# Download docker compose template -wget -O docker-compose.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.yml - -# Download nginx config -mkdir nginx && wget -O ./nginx/default.conf https://raw.githubusercontent.com/Infisical/infisical/main/nginx/default.dev.conf -``` - -3. Tweak the `.env` according to your preferences. Refer to the available [environment variables](../../self-hosting/configuration/envars) - -```bash -# update environment variables like mongo login -nano .env -``` - -4. Get the service up and running. - -```bash -# Start up services in detached mode -docker-compose -f docker-compose.yml up -d -``` - -5. Your Infisical installation is complete and should be running on [http://localhost:80](http://localhost:80). Please note that the containers are not exposed to the internet and only bind to the localhost. It's up to you to configure a firewall, SSL certificates, and implement any additional security measures. diff --git a/docs/self-hosting/ee.mdx b/docs/self-hosting/ee.mdx new file mode 100644 index 000000000..a72bad908 --- /dev/null +++ b/docs/self-hosting/ee.mdx @@ -0,0 +1,29 @@ +--- +title: "Infisical Enterprise" +description: "Find out how to activate Infisical Enterprise edition (EE) features." +--- + +While most features in Infisical are free to use, others are paid and require purchasing an enterprise license to use them. + +This guide walks through how you can use these paid features on a self hosted instance of Infisical. + + + + Start by either signing up for a free demo [here](https://infisical.com/schedule-demo) or contacting sales@infisical.com to purchase a license. + + Once purchased, you will be issued a license key. + + + Depending on whether or not the environment where Infisical is deployed has internet access, you may be issued a regular license or an offline license. + + - If using a regular license, you should set the value of the environment variable `LICENSE_KEY` in Infisical to the issued license key. + - If using an offline license, you should set the value of the environment variable `LICENSE_KEY_OFFLINE` in Infisical to the issued license key. + + + How you set the environment variable will depend on the deployment method you used. Please refer to the documentation of your deployment method for specific instructions. + + + Once your instance starts up, the license key will be validated and you’ll be able to use the paid features. + However, when the license expires, Infisical will continue to run, but EE features will be disabled until the license is renewed or a new one is purchased. + + diff --git a/docs/self-hosting/faq.mdx b/docs/self-hosting/faq.mdx index 6cef86b9c..db98a23dc 100644 --- a/docs/self-hosting/faq.mdx +++ b/docs/self-hosting/faq.mdx @@ -1,10 +1,10 @@ --- title: "FAQ" -description: "Frequently Asked Questions about Infisical self hosting" +description: "Frequently Asked Questions about self-hosting Infisical." --- Frequently asked questions about self hosted instance of Infisical can be found on this page. -If you can't find the answer you are looking for, please create an issue on our GitHub repository or join our Slack channel for additional support. +If you can't find the answer you are looking for, please create an issue on our [GitHub repository](https://github.com/Infisical/infisical) or join our [Slack community](https://infisical.com/slack) for additional support. This issue is typically seen when you haven't set up SSL for your self hosted instance of Infisical. When SSL is not enabled, you can't receive secure cookies, preventing the session data to not be saved. @@ -15,13 +15,7 @@ However, in the event you choose to use Infisical without SSL, you can do so by [Learn more about secure cookies](https://really-simple-ssl.com/definition/what-are-secure-cookies/) - - Infisical leverages the robust container orchestration capabilities of Kubernetes and the inherent high availability features of Bitnami MongoDB to ensure resilience and fault tolerance. - By deploying multiple replicas of Infisical application on Kubernetes, operations can continue even if a single instance fails. - - Additionally, Bitnami MongoDB supports replica sets, which provide data redundancy and automatic failover for the underlying database. - Kubernetes Services facilitate load balancing, effectively distributing traffic across your application's instances and ensuring optimal performance. - The combination of Kubernetes' self-healing mechanisms and Bitnami MongoDB's failover capabilities work together to create a highly available and fault-tolerant application capable of recovering gracefully from unexpected failures. - - To further increase data redundancy, we recommend that you use a managed MongoDB service for your self hosted instance of Infisical. + + Follow the step by step guide [here](self-hosting/guides/mongo-to-postgres) to learn how. + diff --git a/docs/self-hosting/guides/mongo-to-postgres.mdx b/docs/self-hosting/guides/mongo-to-postgres.mdx new file mode 100644 index 000000000..b8781a19d --- /dev/null +++ b/docs/self-hosting/guides/mongo-to-postgres.mdx @@ -0,0 +1,201 @@ +--- +title: "Migrate Mongo to Postgres" +description: "Learn how to migrate Infisical from MongoDB to PostgreSQL." +--- + +This guide will provide step by step instructions on migrating your Infisical instance running on MongoDB to the newly released PostgreSQL version of Infisical. +The newly released Postgres version of Infisical is the only version of Infisical that will receive feature updates and patches going forward. + + + If you have a small set of secrets, we recommend you to download the secrets and upload them to your new instance of Infisical instead of running the migration script. + + +## Prerequisites + +Before starting the migration, ensure you have the following command line tools installed: + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [pg_dump](https://www.postgresql.org/docs/current/app-pgrestore.html) +- [pg_restore](https://www.postgresql.org/docs/current/app-pgdump.html) +- [mongodump](https://www.mongodb.com/docs/database-tools/mongodump/) +- [mongorestore](https://www.mongodb.com/docs/database-tools/mongorestore/) +- [Docker](https://docs.docker.com/engine/install/) + +## Prepare for migration + + + + While the migration script will not mutate any MongoDB production data, we recommend you to take a backup of your MongoDB instance if possible. + + + To prevent new data entries during the migration, set your Infisical instance to migration mode by setting the environment variable `MIGRATION_MODE=true` and redeploying your instance. + This mode will block all write operations, only allowing GET requests. It also disables user logins and sets up a migration page to prevent UI interactions. + ![migration mode](/images/self-hosting/guides/mongo-postgres/mongo-migration.png) + + + Start local instances of MongoDB and Postgres. This will be used in later steps to process and transform the data locally. + + To start local instances of the two databases, create a file called `docker-compose.yaml` as shown below. + + ```yaml docker-compose.yaml + version: '3.1' + + services: + mongodb: + image: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + + postgres: + image: postgres + restart: always + environment: + POSTGRES_PASSWORD: example + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + volumes: + mongodb_data: + postgres_data: + ``` + + Next, run the command below in the same working directory where the `docker-compose.yaml` file resides to start both services. + + ``` + docker-compose up + ``` + + + + +## Dump MongoDB +To speed up the data transformation process, the first step involves transferring the production data from Infisical's MongoDB to a local machine. +This is achieved by creating a dump of the production database and then uploading this dumped data into a local Mongo instance. +By having a running local instance of the production database, we will significantly reduce the time it takes to run the migration script. + + + + + ``` + mongodump --uri= --archive="mongodump-db" --db= --excludeCollection=auditlogs + ``` + + + + ``` + mongorestore --uri=mongodb://root:example@localhost:27017/ --archive="mongodump-db" + ``` + + + +## Start the migration + +Once started, the migration script will transform MongoDB data into an equivalent PostgreSQL format. + + + + Clone the Infisical MongoDB repository. + ``` + git clone -b infisical/v0.46.11-postgres https://github.com/Infisical/infisical.git + ``` + + + ``` + cd backend + ``` + + ``` + npm install + ``` + + + ``` + cd pg-migrator + ``` + + ``` + npm install + ``` + + + ``` + npm run migration + ``` + + When executing the above command, you'll be asked to provide the MongoDB connection string for the database containing your production Infisical data. Since our production Mongo data is transferred to a local Mongo instance, you should input the connection string for this local instance. + + ``` + mongodb://root:example@localhost:27017/?authSource=admin + ``` + + + Remember to replace `` with the name of the MongoDB database. If you are not sure the name, you can use [Compass](https://www.mongodb.com/products/tools/compass) to view the available databases. + + + + Next, you will be asked to enter the Postgres connection string for the database where the transformed data should be stored. + Input the connection string of the local Postgres instance that was set up earlier in the guide. + + ``` + postgres://infisical:infisical@localhost/infisical?sslmode=disable + ``` + + + + Once the script has completed, you will notice a new folder has been created called `db` in the `pg-migrator` folder. + This folder contains meta data for schema mapping and can be helpful when debugging migration related issues. + We highly recommend you to make a copy of this folder in case you need assistance from the Infisical team during your migration process. + + + The `db` folder does not contain any sensitive data + + + + +## Finalizing Migration +At this stage, the data from the Mongo instance of Infisical should have been successfully converted into its Postgres equivalent. +The remaining step involves transferring the local Postgres database, which now contains all the migrated data, to your chosen production Postgres environment. +Rather than transferring the data row-by-row from your local machine to the production Postgres database, we will first create a dump file from the local Postgres and then upload this file to your production Postgres instance. + + + + ``` + pg_dump -h localhost -U infisical -Fc -b -v -f dumpfilelocation.sql -d infisical + ``` + + + ``` + pg_restore --clean -v -h -U -d -j 2 dumpfilelocation.sql + ``` + + + Remember to replace ``, ``, `` with the corresponding details of your production Postgres database. + + + + Use a tool like Beekeeper Studio to confirm that the data has been successfully transferred to your production Postgres DB. + + + +## Post-Migration Steps + +Once the data migration to PostgreSQL is complete, you're ready to deploy Infisical using the deployment method of your choice. +For guidance on deployment options, please visit the [self-hosting documentation](/self-hosting/overview). +Remember to transfer the necessary [environment variables](/self-hosting/configuration/envars) from the MongoDB version of Infisical to the new Postgres based Infisical; rest assured, they are fully compatible. + + +The first deployment of Postgres based Infisical must be deployed with Docker image tag `v0.46.11-postgres`. +After deploying this version, you can proceed to update to any subsequent versions. + + +## Additional discussion +- When you visit Infisical's [docker hub](https://hub.docker.com/r/infisical/infisical) page, you will notice that image tags end with `-postgres`. +This is to indicate that this version of Infisical runs on the new Postgres backend. Any image tag that does not end in `postgres` runs on MongoDB. \ No newline at end of file diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index f5e754b1f..ccc4ae912 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -1,87 +1,35 @@ --- -title: "Introduction" -description: "Self-host Infisical on your own infrastructure" +title: "" +sidebarTitle: "Introduction" +description: "Learn how to self-host Infisical on your own infrastructure." --- Self-hosting Infisical lets you retain data on your own infrastructure and network. -Choose from a variety of deployment options listed below to get started. +Choose from a number of deployment options listed below to get started. - Use the fully packaged docker image to deploy Infisical anywhere + Use the fully packaged docker image to deploy Infisical anywhere. - - Automatically create and deploy Infisical on to a Kubernetes cluster - - Install Infisical using our Docker Compose template + Install Infisical using our Docker Compose template. - Use our Helm chart to Install Infisical on your Kubernetes cluster - - - Install infisical with just a few clicks using our Cloud Formation template - - - Deploy Infisical with AWS Lightsail - - - Deploy Infisical with GCP Cloud Run - - - Deploy Infisical with Azure App Services - - - Deploy Infisical with Azure Container Instances - - - Deploy Infisical with Fly.io - - - Deploy Infisical with Railway + Use our Helm chart to Install Infisical on your Kubernetes cluster. diff --git a/docs/self-hosting/reference-architectures/aws-ecs.mdx b/docs/self-hosting/reference-architectures/aws-ecs.mdx new file mode 100644 index 000000000..a4ce4a2b6 --- /dev/null +++ b/docs/self-hosting/reference-architectures/aws-ecs.mdx @@ -0,0 +1,56 @@ +--- +title: "AWS ECS" +description: "Reference architecture for self-hosting Infisical on AWS ECS" +--- + +This guide will provide high-level architecture design for deploying the Infisical on AWS ECS and give insights into the core components, high availability strategies, and secure credential management for Infisical's root secrets. + +## Overview + +In this guide, we'll focus on running Infisical on AWS Elastic Container Service (ECS) across multiple Availability Zones (AZs), ensuring high availability and resilience. +The architecture utilizes Amazon Relational Database Service (RDS) for persistent storage, ElastiCache for Redis as an in-memory data store for caching, and Amazon Simple Email Service (SES) to handle email based communications from Infisical. + + +![AWS ECS architecture](/images/self-hosting/reference-architectures/Infisical-AWS-ECS-architecture.jpeg) + +### Core Components + +- **ECS Fargate:** In this architecture, Infisical is deployed on ECS using Fargate launch type. The ECS services are deployed across multiple Availability Zones to ensure high availability. + +- **Amazon RDS:** Infisical uses Postgres as it's persistent layer. As such, RDS for PostgreSQL is used as the database engine. The setup includes a primary instance in one AZ and a read replica in another AZ. +This ensures that if there is a failure in one availability zone, the working replica will become the primary and continue processing workloads. + +- **Amazon ElastiCache for Redis:** To enhance performance, Infisical requires Redis. In this architecture, Redis is set up with a primary and standby replication group across two AZs to increase availability. + +- **Amazon Simple Email Service (SES):** Infisical requires email service to facilitate outbound communication. AWS SES is integrated into the architecture to handle such communication. + +### Network Setup + +- **Public Subnets:** Each Availability Zone contains a public subnet. There are two main reasons you might need internet access. First, if you intend to use Infisical to communicate with external secrets managers not located within your virtual private network, enabling internet access is necessary. Second, downloading the Docker image from Docker Hub requires internet access, though this can be avoided by utilizing AWS ECR with VPC Endpoints through AWS Private Link. + +- **NAT Gateway:** This is used to route outbound requests from Infisical to the internet and is only used to communicate with external secrets manager and or downloading container images. + +### Securing Infisical's root credential + +- **Parameter Store:** To secure Infisical's root credentials (database connection string, encryption key, etc), we highly recommend that you use AWS Parameter Store and only allow the tasks running Infisical to access them. +- **AWS Secrets Manager:** We strongly advise securing the master credentials for RDS by utilizing the latest AWS RDS integration with AWS Secrets Manager. This integration automatically stores the master database user's credentials in AWS Secrets Manager, thereby reducing the risk of misplacing the root RDS credential. + +### High Availability (HA) and Scalability + +- **Multi-AZ Deployment:** By spreading resources across multiple Availability Zones, we ensure that if one AZ experiences issues, traffic can be redirected to the remaining healthy AZ without service interruption. + +- **Auto Scaling:** AWS Auto Scaling is in place to adjust capacity to maintain steady and predictable performance at the lowest possible cost. + +- **Cross-Region Deployment:** For even greater high availability, you may deploy Infisical across multiple regions. This extends the HA capabilities of the architecture and protects against regional service disruptions. + + +### Frequently asked questions + + Yes, Infisical can function in an air-gapped environment. To do so, update your ECS task to use the publicly available AWS Elastic Container Registry (ECR) image instead of the default Docker Hub image. Additionally, it's necessary to configure VPC endpoints, which allows your system to access AWS ECR via a private network route instead of the internet, ensuring all connectivity remains within the secure, private network. + + + Since the Amazon RDS instance is housed within a private network to enhance security, it is not directly accessible from the internet. This means that in order to run the required [Postgres schema migrations](/self-hosting/configuration/schema-migrations), you need to connect to this instance of RDS. There are many approaches you can take: + - To automate schema migrations, you may setup CI/CD pipeline with access to the same RDS network to run the schema migrations before making deployment to ECS. This ensures that if migrations fail, your Infisical instances continues to run. + - If you would like to run the migrations manually, consider using AWS Systems Manager Session Manager to access the RDS within the VPC on your local machine. + - If your organization already has mechanisms in place for secure access to the VPC, such as VPNs or Direct Connect, these can also be utilized for performing database migrations manually. + diff --git a/docs/spec.yaml b/docs/spec.yaml deleted file mode 100644 index c3d050395..000000000 --- a/docs/spec.yaml +++ /dev/null @@ -1,5152 +0,0 @@ -openapi: 3.0.0 -info: - title: Infisical API - description: List of all available APIs that can be consumed - version: 1.0.0 -servers: - - url: https://app.infisical.com - description: Production server - - url: http://localhost:8080 - description: Local server -paths: - /api/v1/identities/: - post: - summary: Create identity - description: Create identity - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identity: - $ref: '#/components/schemas/Identity' - description: Details of the created identity - security: - - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: Name of entity to create - example: development - organizationId: - type: string - description: ID of organization where to create identity - example: dev-environment - role: - type: string - description: Role to assume for organization membership - example: no-access - required: - - name - - organizationId - - role - /api/v1/identities/{identityId}: - patch: - summary: Update identity - description: Update identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity to update - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identity: - $ref: '#/components/schemas/Identity' - description: Details of the updated identity - security: - - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: Name of entity to update to - example: development - role: - type: string - description: Role to update to for organization membership - example: no-access - delete: - summary: Delete identity - description: Delete identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identity: - $ref: '#/components/schemas/Identity' - description: Details of the deleted identity - security: - - bearerAuth: [] - /api/v1/secret/{secretId}/secret-versions: - get: - summary: Return secret versions - description: Return secret versions - parameters: - - name: secretId - in: path - required: true - schema: - type: string - description: ID of secret - - name: offset - description: Number of versions to skip - required: false - in: query - schema: - type: string - - name: limit - description: Maximum number of versions to return - required: false - in: query - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secretVersions: - type: array - items: - $ref: '#/components/schemas/SecretVersion' - description: Secret versions - security: - - apiKeyAuth: [] - /api/v1/secret/{secretId}/secret-versions/rollback: - post: - summary: Roll back secret to a version. - description: Roll back secret to a version. - parameters: - - name: secretId - in: path - required: true - schema: - type: string - description: ID of secret - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secret: - type: object - $ref: '#/components/schemas/Secret' - description: Secret rolled back to - security: - - apiKeyAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - version: - type: integer - description: Version of secret to roll back to - /api/v1/secret-snapshot/{secretSnapshotId}: - get: - description: '' - parameters: - - name: secretSnapshotId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/users/me/ip: - get: - description: '' - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/secret-snapshots: - get: - summary: Return project secret snapshot ids - description: Return project secret snapshots ids - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project where to get secret snapshots for - - name: environment - description: Slug of environment where to get secret snapshots for - required: true - in: query - schema: - type: string - - name: directory - description: >- - Path where to get secret snapshots for like / or /foo/bar. Default - is / - required: false - in: query - schema: - type: string - - name: offset - description: Number of secret snapshots to skip - required: false - in: query - schema: - type: string - - name: limit - description: Maximum number of secret snapshots to return - required: false - in: query - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secretSnapshots: - type: array - items: - $ref: '#/components/schemas/SecretSnapshot' - description: Project secret snapshots - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v1/workspace/{workspaceId}/secret-snapshots/count: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/secret-snapshots/rollback: - post: - summary: >- - Roll back project secrets to those captured in a secret snapshot - version. - description: >- - Roll back project secrets to those captured in a secret snapshot - version. - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project where to roll back - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secrets: - type: array - items: - $ref: '#/components/schemas/Secret' - description: Secrets rolled back to - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - environment: - type: string - description: Slug of environment where to roll back - directory: - type: string - description: Path where to roll back for like / or /foo/bar. Default is / - version: - type: integer - description: Version of secret snapshot to roll back to - /api/v1/workspace/{workspaceId}/audit-logs: - get: - summary: Return audit logs - description: Return audit logs - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of the workspace where to get folders from - - name: offset - description: Number of logs to skip before starting to return logs for pagination - required: false - in: query - schema: - type: string - - name: limit - description: Maximum number of logs to return for pagination - required: false - in: query - schema: - type: string - - name: startDate - description: Filter logs from this date in ISO-8601 format - required: false - in: query - schema: - type: string - - name: endDate - description: Filter logs till this date in ISO-8601 format - required: false - in: query - schema: - type: string - - name: eventType - description: >- - Filter by type of event such as get-secrets, get-secret, - create-secret, update-secret, delete-secret, etc. - required: false - in: query - schema: - type: string - - name: userAgentType - description: Filter by type of user agent such as web, cli, k8-operator, or other - required: false - in: query - schema: - type: string - - name: actor - description: Filter by actor such as user or service - required: false - in: query - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - auditLogs: - type: array - items: - $ref: '#/components/schemas/AuditLog' - description: List of audit log - security: - - apiKeyAuth: [] - /api/v1/workspace/{workspaceId}/audit-logs/filters/actors: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/trusted-ips: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/workspace/{workspaceId}/trusted-ips/{trustedIpId}: - patch: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: trustedIpId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - delete: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: trustedIpId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/organizations/{organizationId}/plans/table: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/plan: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/session/trial: - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/plan/billing: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/plan/table: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/billing-details: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - patch: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/billing-details/payment-methods: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/billing-details/payment-methods/{pmtMethodId}: - delete: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - - name: pmtMethodId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/billing-details/tax-ids: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/billing-details/tax-ids/{taxId}: - delete: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - - name: taxId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/invoices: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organizations/{organizationId}/licenses: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/sso/redirect/saml2/{ssoIdentifier}: - get: - description: '' - parameters: - - name: ssoIdentifier - in: path - required: true - schema: - type: string - - name: callback_port - in: query - schema: - type: string - responses: - default: - description: '' - /api/v1/sso/saml2/{ssoIdentifier}: - post: - description: '' - parameters: - - name: ssoIdentifier - in: path - required: true - schema: - type: string - responses: - default: - description: '' - /api/v1/sso/config: - get: - description: '' - responses: - '200': - description: OK - post: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - patch: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/cloud-products/: - get: - description: '' - responses: - '200': - description: OK - /api/v3/api-key/: - post: - description: '' - responses: - '200': - description: OK - /api/v3/api-key/{apiKeyDataId}: - patch: - description: '' - parameters: - - name: apiKeyDataId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: apiKeyDataId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-rotation-providers/{workspaceId}: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-rotations/: - post: - description: '' - responses: - '200': - description: OK - get: - description: '' - responses: - '200': - description: OK - /api/v1/secret-rotations/restart: - post: - description: '' - responses: - '200': - description: OK - /api/v1/secret-rotations/{id}: - delete: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/signup/email/signup: - post: - description: '' - responses: - '200': - description: OK - '403': - description: Forbidden - /api/v1/signup/email/verify: - post: - description: '' - responses: - '200': - description: OK - '403': - description: Forbidden - /api/v1/auth/token: - post: - description: '' - responses: - '200': - description: OK - /api/v1/auth/login1: - post: - description: '' - responses: - '200': - description: OK - /api/v1/auth/login2: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/auth/logout: - post: - description: '' - responses: - '200': - description: OK - /api/v1/auth/checkAuth: - post: - description: '' - responses: - '200': - description: OK - /api/v1/auth/sessions: - delete: - description: '' - responses: - '200': - description: OK - /api/v1/auth/token/renew: - post: - summary: Renew access token - description: Renew access token - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - accessToken: - type: string - description: (Same) Access token after successful renewal - expiresIn: - type: number - description: TTL of access token in seconds - tokenType: - type: string - description: Type of access token (e.g. Bearer) - description: Access token and its details - requestBody: - content: - application/json: - schema: - type: object - properties: - accessToken: - type: string - description: Access token to renew - example: ... - /api/v1/auth/universal-auth/login: - post: - summary: Login with Universal Auth - description: Login with Universal Auth - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - accessToken: - type: string - description: Access token issued after successful login - expiresIn: - type: number - description: TTL of access token in seconds - tokenType: - type: string - description: Type of access token (e.g. Bearer) - description: Access token and its details - requestBody: - content: - application/json: - schema: - type: object - properties: - clientId: - type: string - description: Client ID for identity to login with Universal Auth - example: ... - clientSecret: - type: string - description: Client Secret for identity to login with Universal Auth - example: ... - /api/v1/auth/universal-auth/identities/{identityId}: - post: - summary: Attach Universal Auth configuration onto identity - description: Attach Universal Auth configuration onto identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity to attach Universal Auth onto - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityUniversalAuth: - $ref: '#/components/schemas/IdentityUniversalAuth' - description: Details of attached Universal Auth - '400': - description: Bad Request - security: - - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - clientSecretTrustedIps: - type: array - items: - type: object - properties: - ipAddress: - type: string - description: IP address to trust - default: 0.0.0.0/0 - description: >- - List of IPs or CIDR ranges that the Client Secret can be - used from together with the Client ID to get back an access - token. By default, Client Secrets are given the 0.0.0.0/0 - entry representing all possible IPv4 addresses. - example: ... - default: - - ipAddress: 0.0.0.0/0 - accessTokenTTL: - type: number - description: >- - The incremental lifetime for an acccess token in seconds; a - value of 0 implies an infinite incremental lifetime. - example: ... - default: 100 - accessTokenMaxTTL: - type: number - description: >- - The maximum lifetime for an acccess token in seconds; a - value of 0 implies an infinite maximum lifetime. - example: ... - default: 2592000 - accessTokenNumUsesLimit: - type: number - description: >- - The maximum number of times that an access token can be - used; a value of 0 implies infinite number of uses. - example: ... - default: 0 - accessTokenTrustedIps: - type: array - items: - type: object - properties: - ipAddress: - type: string - description: IP address to trust - default: 0.0.0.0/0 - description: >- - List of IPs or CIDR ranges that access tokens can be used - from. By default, each token is given the 0.0.0.0/0 entry - representing all possible IPv4 addresses. - example: ... - default: - - ipAddress: 0.0.0.0/0 - patch: - summary: Update Universal Auth configuration on identity - description: Update Universal Auth configuration on identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity to update Universal Auth on - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityUniversalAuth: - $ref: '#/components/schemas/IdentityUniversalAuth' - description: Details of updated Universal Auth - '400': - description: Bad Request - security: - - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - clientSecretTrustedIps: - type: array - items: - type: object - properties: - ipAddress: - type: string - description: IP address to trust - description: >- - List of IPs or CIDR ranges that the Client Secret can be - used from together with the Client ID to get back an access - token. By default, Client Secrets are given the 0.0.0.0/0 - entry representing all possible IPv4 addresses. - example: ... - accessTokenTTL: - type: number - description: >- - The incremental lifetime for an acccess token in seconds; a - value of 0 implies an infinite incremental lifetime. - example: ... - accessTokenMaxTTL: - type: number - description: >- - The maximum lifetime for an acccess token in seconds; a - value of 0 implies an infinite maximum lifetime. - example: ... - accessTokenNumUsesLimit: - type: number - description: >- - The maximum number of times that an access token can be - used; a value of 0 implies infinite number of uses. - example: ... - accessTokenTrustedIps: - type: array - items: - type: object - properties: - ipAddress: - type: string - description: IP address to trust - description: >- - List of IPs or CIDR ranges that access tokens can be used - from. By default, each token is given the 0.0.0.0/0 entry - representing all possible IPv4 addresses. - example: ... - get: - summary: Retrieve Universal Auth configuration on identity - description: Retrieve Universal Auth configuration on identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity to retrieve Universal Auth on - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityUniversalAuth: - $ref: '#/components/schemas/IdentityUniversalAuth' - description: Details of retrieved Universal Auth - security: - - bearerAuth: [] - /api/v1/auth/universal-auth/identities/{identityId}/client-secrets: - post: - summary: Create Universal Auth Client Secret for identity - description: Create Universal Auth Client Secret for identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity to create Universal Auth Client Secret for - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - clientSecret: - type: string - description: The created Client Secret - clientSecretData: - $ref: '#/components/schemas/IdentityUniversalAuthClientSecretData' - description: Details of the created Client Secret - security: - - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - description: - type: string - description: A description for the Client Secret to create. - example: ... - ttl: - type: number - description: >- - The time-to-live for the Client Secret to create. By - default, the TTL will be set to 0 which implies that the - Client Secret will never expire; a value of 0 implies an - infinite lifetime. - example: ... - default: 0 - numUsesLimit: - type: number - description: >- - The maximum number of times that the Client Secret can be - used together with the Client ID to get back an access - token; a value of 0 implies infinite number of uses. - example: ... - default: 0 - get: - summary: List Universal Auth Client Secrets for identity - description: List Universal Auth Client Secrets for identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity for which to get Client Secrets for - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - clientSecretData: - type: array - items: - $ref: >- - #/components/schemas/IdentityUniversalAuthClientSecretData - description: Details of the Client Secrets - security: - - bearerAuth: [] - /api/v1/auth/universal-auth/identities/{identityId}/client-secrets/{clientSecretId}/revoke: - post: - summary: Revoke Universal Auth Client Secret for identity - description: Revoke Universal Auth Client Secret for identity - parameters: - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity under which Client Secret was issued for - - name: clientSecretId - in: path - required: true - schema: - type: string - description: ID of Client Secret to revoke - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - clientSecretData: - $ref: '#/components/schemas/IdentityUniversalAuthClientSecretData' - description: Details of the revoked Client Secret - security: - - bearerAuth: [] - /api/v1/admin/config: - get: - description: '' - responses: - '200': - description: OK - patch: - description: '' - responses: - '200': - description: OK - /api/v1/admin/signup: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - /api/v1/bot/{workspaceId}: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/bot/{botId}/active: - patch: - description: '' - parameters: - - name: botId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/user/: - get: - description: '' - responses: - '200': - description: OK - /api/v1/user-action/: - post: - description: '' - responses: - '200': - description: OK - get: - description: '' - responses: - '200': - description: OK - /api/v1/organization/: - get: - description: '' - responses: - '200': - description: OK - /api/v1/organization/{organizationId}: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organization/{organizationId}/users: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organization/{organizationId}/my-workspaces: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organization/{organizationId}/name: - patch: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organization/{organizationId}/incidentContactOrg: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organization/{organizationId}/customer-portal-session: - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/organization/{organizationId}/workspace-memberships: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/keys: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/users: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/: - get: - description: '' - responses: - '200': - description: OK - post: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/workspace/{workspaceId}: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/name: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/invite-signup: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/integrations: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/authorizations: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/workspace/{workspaceId}/service-tokens: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/membership-org/membershipOrg/{membershipOrgId}/change-role: - post: - description: '' - parameters: - - name: membershipOrgId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/membership-org/{membershipOrgId}: - delete: - description: '' - parameters: - - name: membershipOrgId - in: path - required: true - schema: - type: string - responses: - default: - description: '' - /api/v1/membership/{workspaceId}/connect: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/membership/{membershipId}: - delete: - description: '' - parameters: - - name: membershipId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/membership/{membershipId}/change-role: - post: - description: '' - parameters: - - name: membershipId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/key/{workspaceId}: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/key/{workspaceId}/latest: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/invite-org/signup: - post: - description: '' - parameters: - - name: host - in: header - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/invite-org/verify: - post: - description: '' - responses: - '200': - description: OK - /api/v1/secret/{workspaceId}: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secrets: - example: any - keys: - example: any - environment: - example: any - channel: - example: any - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environment - in: query - schema: - type: string - - name: channel - in: query - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret/{workspaceId}/service-token: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environment - in: query - schema: - type: string - - name: channel - in: query - schema: - type: string - responses: - '200': - description: OK - /api/v1/service-token/: - get: - description: '' - responses: - '200': - description: OK - post: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - example: any - workspaceId: - example: any - environment: - example: any - expiresIn: - example: any - publicKey: - example: any - encryptedKey: - example: any - nonce: - example: any - /api/v1/password/srp1: - post: - description: '' - responses: - '200': - description: OK - /api/v1/password/change-password: - post: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/password/email/password-reset: - post: - description: '' - responses: - '200': - description: OK - /api/v1/password/email/password-reset-verify: - post: - description: '' - responses: - '200': - description: OK - '403': - description: Forbidden - /api/v1/password/backup-private-key: - get: - description: '' - responses: - '200': - description: OK - post: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/password/password-reset: - post: - description: '' - responses: - '200': - description: OK - /api/v1/integration/: - post: - description: '' - responses: - '200': - description: OK - /api/v1/integration/{integrationId}: - patch: - description: '' - parameters: - - name: integrationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: integrationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration/manual-sync: - post: - description: '' - responses: - '200': - description: OK - /api/v1/integration-auth/integration-options: - get: - description: '' - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - delete: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/integration-auth/oauth-token: - post: - description: '' - responses: - '200': - description: OK - /api/v1/integration-auth/access-token: - post: - description: '' - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/apps: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/teams: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/vercel/branches: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/checkly/groups: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/qovery/orgs: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/qovery/projects: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/qovery/environments: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/qovery/apps: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/qovery/containers: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/qovery/jobs: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/railway/environments: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/railway/services: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/bitbucket/workspaces: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/northflank/secret-groups: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/integration-auth/{integrationAuthId}/teamcity/build-configs: - get: - description: '' - parameters: - - name: integrationAuthId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/folders/: - post: - summary: Create folder - description: Create folder - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - folder: - type: object - properties: - id: - type: string - description: ID of folder - example: someFolderId - name: - type: string - description: Name of folder - example: my_folder - version: - type: number - description: Version of folder - example: 1 - description: Details of created folder - '400': - description: >- - Bad Request. For example, 'Folder name cannot contain spaces. Only - underscore and dashes' - '401': - description: Unauthorized request. For example, 'Folder Permission Denied' - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of the workspace where to create folder - example: someWorkspaceId - environment: - type: string - description: Slug of environment where to create folder - example: production - folderName: - type: string - description: Name of folder to create - example: my_folder - directory: - type: string - description: Path where to create folder like / or /foo/bar. Default is / - example: /foo/bar - required: - - workspaceId - - environment - - folderName - get: - summary: Get folders - description: Get folders - parameters: - - name: workspaceId - description: ID of the workspace where to get folders from - required: true - in: query - schema: - type: string - - name: environment - description: Slug of environment where to get folders from - required: true - in: query - schema: - type: string - - name: directory - description: Path where to get fodlers from like / or /foo/bar. Default is / - required: false - in: query - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - folders: - type: array - items: - type: object - properties: - id: - type: string - example: someFolderId - name: - type: string - example: someFolderName - description: List of folders - '400': - description: Bad Request. For instance, 'The folder doesn't exist' - '401': - description: Unauthorized request. For example, 'Folder Permission Denied' - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v1/folders/{folderName}: - patch: - summary: Update folder - description: Update folder - parameters: - - name: folderName - in: path - required: true - schema: - type: string - description: Name of folder to update - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - description: Success message - example: Successfully updated folder - folder: - type: object - properties: - name: - type: string - description: Name of updated folder - example: updated_folder_name - id: - type: string - description: ID of created folder - example: abc123 - description: Details of the updated folder - '400': - description: >- - Bad Request. Reasons can include 'The folder doesn't exist' or - 'Folder name cannot contain spaces. Only underscore and dashes' - '401': - description: Unauthorized request. For example, 'Folder Permission Denied' - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of workspace where to update folder - example: someWorkspaceId - environment: - type: string - description: Slug of environment where to update folder - example: production - name: - type: string - description: Name of folder to update to - example: updated_folder_name - directory: - type: string - description: Path where to update folder like / or /foo/bar. Default is / - example: /foo/bar - required: - - workspaceId - - environment - - name - delete: - summary: Delete folder - description: Delete folder - parameters: - - name: folderName - in: path - required: true - schema: - type: string - description: Name of folder to delete - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - description: Success message - example: successfully deleted folders - folders: - type: array - items: - type: object - properties: - id: - type: string - description: ID of deleted folder - example: abc123 - name: - type: string - description: Name of deleted folder - example: someFolderName - description: List of IDs and names of deleted folders - '400': - description: Bad Request. Reasons can include 'The folder doesn't exist' - '401': - description: Unauthorized request. For example, 'Folder Permission Denied' - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of the workspace where to delete folder - example: someWorkspaceId - environment: - type: string - description: Slug of environment where to delete folder - example: production - directory: - type: string - description: Path where to delete folder like / or /foo/bar. Default is / - example: /foo/bar - required: - - workspaceId - - environment - /api/v1/secret-scanning/create-installation-session/organization/{organizationId}: - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-scanning/link-installation: - post: - description: '' - responses: - '200': - description: OK - /api/v1/secret-scanning/installation-status/organization/{organizationId}: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-scanning/organization/{organizationId}/risks: - get: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-scanning/organization/{organizationId}/risks/{riskId}/status: - post: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - - name: riskId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/webhooks/: - post: - description: '' - responses: - '200': - description: OK - get: - description: '' - responses: - '200': - description: OK - /api/v1/webhooks/{webhookId}: - patch: - description: '' - parameters: - - name: webhookId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: webhookId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/webhooks/{webhookId}/test: - post: - description: '' - parameters: - - name: webhookId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v1/secret-imports/: - post: - summary: Create secret import - description: Create secret import - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: successfully created secret import - description: Confirmation of secret import creation - '400': - description: Bad Request. For example, 'Secret import already exist' - '401': - description: Unauthorized request. For example, 'Folder Permission Denied' - '404': - description: Resource Not Found. For example, 'Failed to find folder' - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of workspace where to create secret import - example: someWorkspaceId - environment: - type: string - description: Slug of environment where to create secret import - example: dev - directory: - type: string - description: >- - Path where to create secret import like / or /foo/bar. - Default is / - example: /foo/bar - secretImport: - type: object - properties: - environment: - type: string - description: Slug of environment to import from - example: development - secretPath: - type: string - description: Path where to import from like / or /foo/bar. - example: /user/oauth - required: - - workspaceId - - environment - - directory - - secretImport - get: - summary: Get secret imports - description: Get secret imports - parameters: - - name: workspaceId - in: query - description: ID of workspace where to get secret imports from - required: true - example: workspace12345 - schema: - type: string - - name: environment - in: query - description: Slug of environment where to get secret imports from - required: true - example: production - schema: - type: string - - name: directory - in: query - description: >- - Path where to get secret imports from like / or /foo/bar. Default is - / - required: false - example: folder12345 - schema: - type: string - responses: - '200': - description: Successfully retrieved secret import - content: - application/json: - schema: - type: object - properties: - secretImport: - $ref: '#/components/schemas/SecretImport' - '401': - description: Unauthorized access due to invalid token or scope - '403': - description: Forbidden access due to insufficient permissions - /api/v1/secret-imports/{id}: - put: - summary: Update secret import - description: Update secret import - parameters: - - name: id - in: path - required: true - schema: - type: string - description: ID of secret import to update - example: import12345 - responses: - '200': - description: Successfully updated the secret import - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: successfully updated secret import - '400': - description: Bad Request - Import not found - '401': - description: Unauthorized access due to invalid token or scope - '403': - description: Forbidden access due to insufficient permissions - requestBody: - content: - application/json: - schema: - type: object - properties: - secretImports: - type: array - description: List of secret imports to update to - items: - type: object - properties: - environment: - type: string - description: Slug of environment to import from - example: dev - secretPath: - type: string - description: Path where to import secrets from like / or /foo/bar - example: /foo/bar - required: - - environment - - secretPath - required: - - secretImports - delete: - summary: Delete secret import - description: Delete secret import - parameters: - - name: id - in: path - required: true - schema: - type: string - description: >- - ID of parent secret import document from which to delete secret - import - example: 12345abcde - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: successfully delete secret import - description: Confirmation of secret import deletion - requestBody: - content: - application/json: - schema: - type: object - properties: - secretImportEnv: - type: string - description: Slug of environment of import to delete - example: someWorkspaceId - secretImportPath: - type: string - description: Path like / or /foo/bar of import to delete - example: production - required: - - id - - secretImportEnv - - secretImportPath - /api/v1/secret-imports/secrets: - get: - description: '' - responses: - '200': - description: OK - /api/v1/roles/: - post: - description: '' - responses: - '200': - description: OK - get: - description: '' - responses: - '200': - description: OK - /api/v1/roles/{id}: - patch: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/roles/organization/{orgId}/permissions: - get: - description: '' - parameters: - - name: orgId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/roles/workspace/{workspaceId}/permissions: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-approvals/: - get: - description: '' - responses: - '200': - description: OK - post: - description: '' - responses: - '200': - description: OK - /api/v1/secret-approvals/board: - get: - description: '' - responses: - '200': - description: OK - /api/v1/secret-approvals/{id}: - patch: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/sso/redirect/google: - get: - description: '' - parameters: - - name: callback_port - in: query - schema: - type: string - responses: - default: - description: '' - /api/v1/sso/google: - get: - description: '' - responses: - default: - description: '' - /api/v1/sso/redirect/github: - get: - description: '' - parameters: - - name: callback_port - in: query - schema: - type: string - responses: - default: - description: '' - /api/v1/sso/github: - get: - description: '' - responses: - default: - description: '' - /api/v1/sso/redirect/gitlab: - get: - description: '' - parameters: - - name: callback_port - in: query - schema: - type: string - responses: - default: - description: '' - /api/v1/sso/gitlab: - get: - description: '' - responses: - default: - description: '' - /api/v1/secret-approval-requests/: - get: - description: '' - responses: - '200': - description: OK - /api/v1/secret-approval-requests/count: - get: - description: '' - responses: - '200': - description: OK - /api/v1/secret-approval-requests/{id}: - get: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-approval-requests/{id}/merge: - post: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-approval-requests/{id}/review: - post: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v1/secret-approval-requests/{id}/status: - post: - description: '' - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/signup/complete-account/signup: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - '403': - description: Forbidden - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - firstName: - example: any - lastName: - example: any - protectedKey: - example: any - protectedKeyIV: - example: any - protectedKeyTag: - example: any - publicKey: - example: any - encryptedPrivateKey: - example: any - encryptedPrivateKeyIV: - example: any - encryptedPrivateKeyTag: - example: any - salt: - example: any - verifier: - example: any - organizationName: - example: any - /api/v2/signup/complete-account/invite: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - '403': - description: Forbidden - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - firstName: - example: any - lastName: - example: any - protectedKey: - example: any - protectedKeyIV: - example: any - protectedKeyTag: - example: any - publicKey: - example: any - encryptedPrivateKey: - example: any - encryptedPrivateKeyIV: - example: any - encryptedPrivateKeyTag: - example: any - salt: - example: any - verifier: - example: any - /api/v2/auth/login1: - post: - description: '' - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - clientPublicKey: - example: any - /api/v2/auth/login2: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - example: any - clientProof: - example: any - /api/v2/auth/mfa/send: - post: - description: '' - responses: - '200': - description: OK - /api/v2/auth/mfa/verify: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - /api/v2/users/me/mfa: - patch: - description: '' - responses: - '200': - description: OK - /api/v2/users/me/name: - patch: - description: '' - responses: - '200': - description: OK - /api/v2/users/me/auth-methods: - put: - description: '' - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v2/users/me/organizations: - get: - summary: Return organizations that current user is part of - description: Return organizations that current user is part of - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - organizations: - type: array - items: - $ref: '#/components/schemas/Organization' - description: Organizations that user is part of - security: - - apiKeyAuth: [] - /api/v2/users/me/api-keys: - get: - description: '' - responses: - '200': - description: OK - post: - description: '' - responses: - '200': - description: OK - /api/v2/users/me/api-keys/{apiKeyDataId}: - delete: - description: '' - parameters: - - name: apiKeyDataId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/users/me/sessions: - get: - description: '' - responses: - '200': - description: OK - delete: - description: '' - responses: - '200': - description: OK - /api/v2/users/me: - get: - summary: Retrieve the current user on the request - description: Retrieve the current user on the request - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - user: - type: object - $ref: '#/components/schemas/CurrentUser' - description: Current user on request - security: - - apiKeyAuth: [] - delete: - description: '' - responses: - '200': - description: OK - /api/v2/organizations/{organizationId}/memberships: - get: - summary: Return organization user memberships - description: Return organization user memberships - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - description: ID of organization - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - memberships: - type: array - items: - $ref: '#/components/schemas/MembershipOrg' - description: Memberships of organization - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v2/organizations/{organizationId}/memberships/{membershipId}: - patch: - summary: Update organization user membership - description: Update organization user membership - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - description: ID of organization - - name: membershipId - in: path - required: true - schema: - type: string - description: ID of organization membership to update - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - membership: - $ref: '#/components/schemas/MembershipOrg' - description: Updated organization membership - '400': - description: Bad Request - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - role: - type: string - description: >- - Role of organization membership - either owner, admin, or - member - delete: - summary: Delete organization user membership - description: Delete organization user membership - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - description: ID of organization - - name: membershipId - in: path - required: true - schema: - type: string - description: ID of organization membership to delete - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - membership: - $ref: '#/components/schemas/MembershipOrg' - description: Deleted organization membership - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v2/organizations/{organizationId}/workspaces: - get: - summary: Return projects in organization that user is part of - description: Return projects in organization that user is part of - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - description: ID of organization - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - workspaces: - type: array - items: - $ref: '#/components/schemas/Project' - description: Projects of organization - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v2/organizations/: - post: - description: '' - responses: - '200': - description: OK - /api/v2/organizations/{organizationId}: - delete: - description: '' - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/organizations/{organizationId}/identity-memberships: - get: - summary: Return organization identity memberships - description: Return organization identity memberships - parameters: - - name: organizationId - in: path - required: true - schema: - type: string - description: ID of organization - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityMemberships: - type: array - items: - $ref: '#/components/schemas/IdentityMembershipOrg' - description: Identity memberships of organization - security: - - bearerAuth: [] - /api/v2/workspace/{workspaceId}/memberships: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - get: - summary: Return project user memberships - description: Return project user memberships - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - memberships: - type: array - items: - $ref: '#/components/schemas/Membership' - description: Memberships of project - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v2/workspace/{workspaceId}/environments: - post: - summary: Create environment - description: Create environment - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of workspace where to create environment - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - description: Sucess message - example: Successfully created environment - workspace: - type: string - description: ID of workspace where environment was created - example: abc123 - environment: - type: object - properties: - name: - type: string - description: Name of created environment - example: Staging - slug: - type: string - description: Slug of created environment - example: staging - description: Details of the created environment - '400': - description: Bad Request - security: - - apiKeyAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - environmentName: - type: string - description: Name of the environment to create - example: development - environmentSlug: - type: string - description: Slug of environment to create - example: dev-environment - required: - - environmentName - - environmentSlug - put: - summary: Update environment - description: Update environment - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of workspace where to update environment - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - description: Success message - example: Successfully update environment - workspace: - type: string - description: ID of workspace where environment was updated - example: abc123 - environment: - type: object - properties: - name: - type: string - description: Name of updated environment - example: Staging-Renamed - slug: - type: string - description: Slug of updated environment - example: staging-renamed - description: Details of the renamed environment - security: - - apiKeyAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - environmentName: - type: string - description: Name of environment to update to - example: Staging-Renamed - environmentSlug: - type: string - description: Slug of environment to update to - example: staging-renamed - oldEnvironmentSlug: - type: string - description: Current slug of environment - example: staging-old - required: - - environmentName - - environmentSlug - - oldEnvironmentSlug - patch: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - summary: Delete environment - description: Delete environment - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of workspace where to delete environment - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - message: - type: string - description: Success message - example: Successfully deleted environment - workspace: - type: string - description: ID of workspace where environment was deleted - example: abc123 - environment: - type: string - description: Slug of deleted environment - example: dev - description: Response after deleting an environment from a workspace - security: - - apiKeyAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - environmentSlug: - type: string - description: Slug of environment to delete - example: dev - required: - - environmentSlug - /api/v2/workspace/{workspaceId}/tags: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/workspace/tags/{tagId}: - delete: - description: '' - parameters: - - name: tagId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/workspace/{workspaceId}/secrets: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secrets: - example: any - keys: - example: any - environment: - example: any - channel: - example: any - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environment - in: query - schema: - type: string - - name: channel - in: query - schema: - type: string - responses: - '200': - description: OK - /api/v2/workspace/{workspaceId}/encrypted-key: - get: - summary: Return encrypted project key - description: Return encrypted project key - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/ProjectKey' - description: Encrypted project key for the given project - security: - - apiKeyAuth: [] - /api/v2/workspace/{workspaceId}/service-token-data: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/workspace/{workspaceId}/memberships/{membershipId}: - patch: - summary: Update project user membership - description: Update project user membership - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - - name: membershipId - in: path - required: true - schema: - type: string - description: ID of project membership to update - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - membership: - $ref: '#/components/schemas/Membership' - description: Updated membership - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - role: - type: string - description: Role to update to for project membership - delete: - summary: Delete project user membership - description: Delete project user membership - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - - name: membershipId - in: path - required: true - schema: - type: string - description: ID of project membership to delete - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - membership: - $ref: '#/components/schemas/Membership' - description: Deleted membership - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v2/workspace/{workspaceId}/auto-capitalization: - patch: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/workspace/{workspaceId}/identity-memberships/{identityId}: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: identityId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - patch: - summary: Update project identity membership - description: Update project identity membership - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity whose membership to update in project - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityMembership: - $ref: '#/components/schemas/IdentityMembership' - description: Updated identity membership - security: - - bearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - role: - type: string - description: Role to update to for identity project membership - delete: - summary: Delete project identity membership - description: Delete project identity membership - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - - name: identityId - in: path - required: true - schema: - type: string - description: ID of identity whose membership to delete in project - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityMembership: - $ref: '#/components/schemas/IdentityMembership' - description: Deleted identity membership - security: - - bearerAuth: [] - /api/v2/workspace/{workspaceId}/identity-memberships: - get: - summary: Return project identity memberships - description: Return project identity memberships - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - description: ID of project - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - identityMemberships: - type: array - items: - $ref: '#/components/schemas/IdentityMembership' - description: Identity memberships of project - security: - - bearerAuth: [] - /api/v2/secret/batch-create/workspace/{workspaceId}/environment/{environment}: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environment - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secrets: - example: any - /api/v2/secret/workspace/{workspaceId}/environment/{environment}: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environment - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secret: - example: any - /api/v2/secret/workspace/{workspaceId}: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environment - in: query - schema: - type: string - responses: - '200': - description: OK - /api/v2/secret/{secretId}: - get: - description: '' - parameters: - - name: secretId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: secretId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v2/secret/batch/workspace/{workspaceId}/environment/{environmentName}: - delete: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environmentName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secretIds: - example: any - /api/v2/secret/batch-modify/workspace/{workspaceId}/environment/{environmentName}: - patch: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environmentName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secrets: - example: any - /api/v2/secret/workspace/{workspaceId}/environment/{environmentName}: - patch: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - - name: environmentName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - requestBody: - content: - application/json: - schema: - type: object - properties: - secret: - example: any - /api/v2/secrets/batch: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - /api/v2/secrets/: - post: - summary: Create new secret(s) - description: Create one or many secrets for a given project and environment. - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secrets: - type: array - items: - $ref: '#/components/schemas/Secret' - description: >- - Newly-created secrets for the given project and - environment - security: - - apiKeyAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of project - environment: - type: string - description: Environment within project - secrets: - $ref: '#/components/schemas/CreateSecret' - description: Secret(s) to create - object or array of objects - get: - summary: Read secrets - description: Read secrets from a project and environment - parameters: - - name: workspaceId - description: ID of project - required: true - in: query - schema: - type: string - - name: environment - description: Environment within project - required: true - in: query - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secrets: - type: array - items: - $ref: '#/components/schemas/Secret' - description: Secrets for the given project and environment - security: - - apiKeyAuth: [] - patch: - summary: Update secret(s) - description: Update secret(s) - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secrets: - type: array - items: - $ref: '#/components/schemas/Secret' - description: Updated secrets - security: - - apiKeyAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - secrets: - $ref: '#/components/schemas/UpdateSecret' - description: Secret(s) to update - object or array of objects - delete: - summary: Delete secret(s) - description: Delete one or many secrets by their ID(s) - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secrets: - type: array - items: - $ref: '#/components/schemas/Secret' - description: Deleted secrets - security: - - apiKeyAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - secretIds: - type: string - description: ID(s) of secrets - string or array of strings - /api/v2/service-token/: - get: - summary: Return Infisical Token data - description: Return Infisical Token data - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - serviceTokenData: - type: object - $ref: '#/components/schemas/ServiceTokenData' - description: Details of service token - security: - - bearerAuth: [] - post: - description: '' - responses: - '200': - description: OK - /api/v2/service-token/{serviceTokenDataId}: - delete: - description: '' - parameters: - - name: serviceTokenDataId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v3/auth/login1: - post: - description: '' - responses: - '200': - description: OK - /api/v3/auth/login2: - post: - description: '' - parameters: - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - /api/v3/secrets/raw: - get: - summary: List secrets - description: List secrets - parameters: - - name: workspaceId - description: ID of workspace where to get secrets from - required: true - in: query - schema: - type: string - - name: environment - description: Slug of environment where to get secrets from - required: true - in: query - schema: - type: string - - name: secretPath - description: Path where to update secret like / or /foo/bar. Default is / - required: false - in: query - schema: - type: string - - name: include_imports - description: Whether or not to include imported secrets. Default is false - required: false - in: query - schema: - type: boolean - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secrets: - type: array - items: - $ref: '#/components/schemas/RawSecret' - description: List of secrets - security: - - apiKeyAuth: [] - bearerAuth: [] - /api/v3/secrets/raw/{secretName}: - get: - summary: Get secret - description: Get secret - parameters: - - name: secretName - in: path - required: true - schema: - type: string - description: Name of secret to get - - name: workspaceId - description: ID of workspace where to get secret - required: true - in: query - schema: - type: string - - name: environment - description: Slug of environment where to get secret - required: true - in: query - schema: - type: string - - name: secretPath - description: Path where to update secret like / or /foo/bar. Default is / - required: false - in: query - schema: - type: string - - name: type - description: Type of secret to get; either shared or personal. Default is shared. - required: true - in: query - schema: - type: string - - name: include_imports - description: Whether or not to include imported secrets. Default is false - required: false - in: query - schema: - type: boolean - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secret: - $ref: '#/components/schemas/RawSecret' - security: - - apiKeyAuth: [] - bearerAuth: [] - post: - summary: Create secret - description: Create secret - parameters: - - name: secretName - in: path - required: true - schema: - type: string - description: Name of secret to create - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/RawSecret' - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of the workspace where to create secret - example: someWorkspaceId - environment: - type: string - description: Slug of environment where to create secret - example: dev - secretPath: - type: string - description: Path where to create secret. Default is / - example: /foo/bar - secretValue: - type: string - description: Value of secret to create - example: Some value - secretComment: - type: string - description: Comment for secret to create - example: Some comment - type: - type: string - description: >- - Type of secret to create; either shared or personal. Default - is shared. - example: shared - skipMultilineEncoding: - type: boolean - description: Convert multi line secrets into one line by wrapping - example: 'true' - required: - - workspaceId - - environment - - secretValue - patch: - summary: Update secret - description: Update secret - parameters: - - name: secretName - in: path - required: true - schema: - type: string - description: Name of secret to update - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/RawSecret' - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of the workspace where to update secret - example: someWorkspaceId - environment: - type: string - description: Slug of environment where to update secret - example: dev - secretPath: - type: string - description: Path where to update secret like / or /foo/bar. Default is / - example: /foo/bar - secretValue: - type: string - description: Value of secret to update to - example: Some value - type: - type: string - description: >- - Type of secret to update; either shared or personal. Default - is shared. - example: shared - skipMultilineEncoding: - type: boolean - description: Convert multi line secrets into one line by wrapping - example: 'true' - required: - - workspaceId - - environment - - secretValue - delete: - summary: Delete secret - description: Delete secret - parameters: - - name: secretName - in: path - required: true - schema: - type: string - description: Name of secret to delete - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - secret: - $ref: '#/components/schemas/RawSecret' - description: The deleted secret - security: - - apiKeyAuth: [] - bearerAuth: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - workspaceId: - type: string - description: ID of workspace where to delete secret - example: someWorkspaceId - environment: - type: string - description: Slug of Environment where to delete secret - example: dev - secretPath: - type: string - description: Path where to delete secret. Default is / - example: /foo/bar - type: - type: string - description: >- - Type of secret to delete; either shared or personal. Default - is shared - example: shared - required: - - workspaceId - - environment - /api/v3/secrets/: - get: - description: '' - responses: - '200': - description: OK - /api/v3/secrets/batch: - post: - description: '' - responses: - '200': - description: OK - patch: - description: '' - responses: - '200': - description: OK - delete: - description: '' - responses: - '200': - description: OK - /api/v3/secrets/{secretName}: - post: - description: '' - parameters: - - name: secretName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - get: - description: '' - parameters: - - name: secretName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - patch: - description: '' - parameters: - - name: secretName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - delete: - description: '' - parameters: - - name: secretName - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v3/workspaces/{workspaceId}/secrets/blind-index-status: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v3/workspaces/{workspaceId}/secrets: - get: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v3/workspaces/{workspaceId}/secrets/names: - post: - description: '' - parameters: - - name: workspaceId - in: path - required: true - schema: - type: string - responses: - '200': - description: OK - /api/v3/signup/complete-account/signup: - post: - description: '' - parameters: - - name: authorization - in: header - schema: - type: string - - name: user-agent - in: header - schema: - type: string - responses: - '200': - description: OK - '400': - description: Bad Request - '403': - description: Forbidden - /api/v3/us/me/api-keys: - get: - description: '' - responses: - '200': - description: OK - /api/status: - get: - description: '' - responses: - '200': - description: OK -components: - schemas: - CurrentUser: - type: object - properties: - _id: - type: string - example: '' - email: - type: string - example: johndoe@gmail.com - firstName: - type: string - example: John - lastName: - type: string - example: Doe - publicKey: - type: string - example: johns_nacl_public_key - encryptedPrivateKey: - type: string - example: johns_enc_nacl_private_key - iv: - type: string - example: iv_of_enc_nacl_private_key - tag: - type: string - example: tag_of_enc_nacl_private_key - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - Identity: - type: object - properties: - _id: - type: string - example: '' - name: - type: string - example: Machine 1 - authMethod: - type: string - example: universal-auth - IdentityUniversalAuth: - type: object - properties: - _id: - type: string - example: '' - identity: - type: string - example: '' - clientId: - type: string - example: ... - clientSecretTrustedIps: - type: array - items: - type: object - properties: - ipAddress: - type: string - example: 0.0.0.0 - type: - type: string - example: ipv4 - prefix: - type: string - example: '0' - accessTokenTTL: - type: number - example: 7200 - accessTokenMaxTTL: - type: number - example: 2592000 - accessTokenNumUsesLimit: - type: number - example: 0 - accessTokenTrustedIps: - type: array - items: - type: object - properties: - ipAddress: - type: string - example: 0.0.0.0 - type: - type: string - example: ipv4 - prefix: - type: string - example: '0' - IdentityUniversalAuthClientSecretData: - type: object - properties: - _id: - type: string - example: '' - identityUniversalAuth: - type: string - example: '' - isClientSecretRevoked: - type: boolean - example: false - description: - type: string - example: '' - clientSecretPrefix: - type: string - example: abc - clientSecretNumUses: - type: number - example: 0 - clientSecretNumUsesLimit: - type: number - example: 0 - clientSecretTTL: - type: number - example: 0 - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - Membership: - type: object - properties: - user: - type: object - properties: - _id: - type: string - example: '' - email: - type: string - example: johndoe@gmail.com - firstName: - type: string - example: John - lastName: - type: string - example: Doe - publicKey: - type: string - example: johns_nacl_public_key - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - workspace: - type: string - example: '' - role: - type: string - example: admin - MembershipOrg: - type: object - properties: - user: - type: object - properties: - _id: - type: string - example: '' - email: - type: string - example: johndoe@gmail.com - firstName: - type: string - example: John - lastName: - type: string - example: Doe - publicKey: - type: string - example: johns_nacl_public_key - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - organization: - type: string - example: '' - role: - type: string - example: owner - status: - type: string - example: accepted - IdentityMembership: - type: object - properties: - identity: - type: object - properties: - _id: - type: string - example: '' - name: - type: string - example: Machine 1 - authMethod: - type: string - example: universal-auth - workspace: - type: string - example: '' - role: - type: string - example: member - IdentityMembershipOrg: - type: object - properties: - identity: - type: object - properties: - _id: - type: string - example: '' - name: - type: string - example: Machine 1 - authMethod: - type: string - example: universal-auth - organization: - type: string - example: '' - role: - type: string - example: member - status: - type: string - example: accepted - Organization: - type: object - properties: - _id: - type: string - example: '' - name: - type: string - example: Acme Corp. - customerId: - type: string - example: '' - Project: - type: object - properties: - name: - type: string - example: My Project - organization: - type: string - example: '' - environments: - type: array - items: - type: object - properties: - name: - type: string - example: development - slug: - type: string - example: dev - ProjectKey: - type: object - properties: - encryptedkey: - type: string - example: '' - nonce: - type: string - example: '' - sender: - type: object - properties: - publicKey: - type: string - example: senders_nacl_public_key - receiver: - type: string - example: '' - workspace: - type: string - example: '' - CreateSecret: - type: object - properties: - type: - type: string - example: shared - secretKeyCiphertext: - type: string - example: '' - secretKeyIV: - type: string - example: '' - secretKeyTag: - type: string - example: '' - secretValueCiphertext: - type: string - example: '' - secretValueIV: - type: string - example: '' - secretValueTag: - type: string - example: '' - secretCommentCiphertext: - type: string - example: '' - secretCommentIV: - type: string - example: '' - secretCommentTag: - type: string - example: '' - UpdateSecret: - type: object - properties: - id: - type: string - example: '' - secretKeyCiphertext: - type: string - example: '' - secretKeyIV: - type: string - example: '' - secretKeyTag: - type: string - example: '' - secretValueCiphertext: - type: string - example: '' - secretValueIV: - type: string - example: '' - secretValueTag: - type: string - example: '' - secretCommentCiphertext: - type: string - example: '' - secretCommentIV: - type: string - example: '' - secretCommentTag: - type: string - example: '' - Secret: - type: object - properties: - _id: - type: string - example: '' - version: - type: number - example: 1 - workspace: - type: string - example: '' - type: - type: string - example: shared - user: {} - secretKeyCiphertext: - type: string - example: '' - secretKeyIV: - type: string - example: '' - secretKeyTag: - type: string - example: '' - secretValueCiphertext: - type: string - example: '' - secretValueIV: - type: string - example: '' - secretValueTag: - type: string - example: '' - secretCommentCiphertext: - type: string - example: '' - secretCommentIV: - type: string - example: '' - secretCommentTag: - type: string - example: '' - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - RawSecret: - type: object - properties: - _id: - type: string - example: abc123 - version: - type: number - example: 1 - workspace: - type: string - example: abc123 - environment: - type: string - example: dev - secretKey: - type: string - example: STRIPE_KEY - secretValue: - type: string - example: abc123 - secretComment: - type: string - example: Lorem ipsum - SecretImport: - type: object - properties: - _id: - type: string - example: '' - workspace: - type: string - example: abc123 - environment: - type: string - example: dev - folderId: - type: string - example: root - imports: - type: array - example: [] - items: {} - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - Log: - type: object - properties: - _id: - type: string - example: '' - user: - type: object - properties: - _id: - type: string - example: '' - email: - type: string - example: johndoe@gmail.com - firstName: - type: string - example: John - lastName: - type: string - example: Doe - workspace: - type: string - example: '' - actionNames: - type: array - example: - - addSecrets - items: - type: string - actions: - type: array - items: - type: object - properties: - name: - type: string - example: addSecrets - user: - type: string - example: '' - workspace: - type: string - example: '' - payload: - type: array - items: - type: object - properties: - oldSecretVersion: - type: string - example: '' - newSecretVersion: - type: string - example: '' - channel: - type: string - example: cli - ipAddress: - type: string - example: 192.168.0.1 - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - SecretSnapshot: - type: object - properties: - workspace: - type: string - example: '' - version: - type: number - example: 1 - secretVersions: - type: array - items: - type: object - properties: - _id: - type: string - example: '' - SecretVersion: - type: object - properties: - _id: - type: string - example: '' - secret: - type: string - example: '' - version: - type: number - example: 1 - workspace: - type: string - example: '' - type: - type: string - example: shared - user: - type: string - example: '' - environment: - type: string - example: dev - isDeleted: - type: string - example: '' - secretKeyCiphertext: - type: string - example: '' - secretKeyIV: - type: string - example: '' - secretKeyTag: - type: string - example: '' - secretValueCiphertext: - type: string - example: '' - secretValueIV: - type: string - example: '' - secretValueTag: - type: string - example: '' - ServiceTokenData: - type: object - properties: - _id: - type: string - example: '' - name: - type: string - example: '' - workspace: - type: string - example: '' - environment: - type: string - example: '' - user: - type: object - properties: - _id: - type: string - example: '' - firstName: - type: string - example: '' - lastName: - type: string - example: '' - expiresAt: - type: string - example: '2023-01-13T14:16:12.210Z' - encryptedKey: - type: string - example: '' - iv: - type: string - example: '' - tag: - type: string - example: '' - updatedAt: - type: string - example: '2023-01-13T14:16:12.210Z' - createdAt: - type: string - example: '2023-01-13T14:16:12.210Z' - AuditLog: - type: object - properties: - actor: - type: object - properties: - type: - type: string - example: '' - metadata: - type: object - properties: {} - organization: - type: string - example: '' - workspace: - type: string - example: '' - ipAddress: - type: string - example: '' - event: - type: object - properties: - type: - type: string - example: '' - metadata: - type: object - properties: {} - userAgent: - type: string - example: '' - userAgentType: - type: string - example: '' - expiresAt: - type: string - example: '' - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: An access token in Infisical - apiKeyAuth: - type: apiKey - in: header - name: X-API-Key - description: An API Key in Infisical diff --git a/docs/style.css b/docs/style.css new file mode 100644 index 000000000..b76d06450 --- /dev/null +++ b/docs/style.css @@ -0,0 +1,142 @@ +#navbar .max-w-8xl { + max-width: 100%; + border-bottom: 1px solid #ebebeb; + background-color: #fcfcfc; +} + +.max-w-8xl { + /* background-color: #f5f5f5; */ +} + +#sidebar { + left: 0; + padding-left: 48px; + padding-right: 30px; + border-right: 1px; + border-color: #cdd64b; + background-color: #fcfcfc; + border-right: 1px solid #ebebeb; +} + +#sidebar .relative .sticky { + opacity: 0; +} + +#sidebar li > div.mt-2 { + border-radius: 0; + padding: 5px; +} + +#sidebar li > a.mt-2 { + border-radius: 0; + padding: 5px; +} + +#sidebar li > a.leading-6 { + border-radius: 0; + padding: 0px; +} + +/* #sidebar ul > div.mt-12 { + padding-top: 30px; + position: relative; +} + +#sidebar ul > div.mt-12 h5 { + position: absolute; + left: -12px; + top: -0px; +} */ + +#header { + border-left: 1px solid #26272b; + padding-left: 16px; + padding-right: 16px; + background-color: #f5f5f5; + padding-bottom: 10px; + padding-top: 10px; +} + +#content-area .mt-8 .block{ + border-radius: 0; + border-width: 1px; + border-color: #ebebeb; +} + +#content-area .mt-8 .rounded-xl{ + border-radius: 0; +} + +#content-area .mt-8 .rounded-lg{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-xl{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-lg{ + border-radius: 0; +} + +#content-area .mt-6 .rounded-md{ + border-radius: 0; +} + +#content-area .mt-8 .rounded-md{ + border-radius: 0; +} + +#content-area div.my-4{ + border-radius: 0; + border-width: 1px; +} + +#content-area div.flex-1 { + /* text-transform: uppercase; */ + opacity: 0.8; + font-weight: 400; +} + +#content-area button { + border-radius: 0; +} + +#content-area a { + border-radius: 0; +} + +#content-area .not-prose { + border-radius: 0; +} + +/* .eyebrow { + text-transform: uppercase; + font-weight: 400; + color: red; +} */ + +#content-container { + /* background-color: #f5f5f5; */ + margin-top: 2rem; +} + +#topbar-cta-button .group .absolute { + background-color: black; + border-radius: 0px; +} + +/* #topbar-cta-button .group .absolute:hover { + background-color: white; + border-radius: 0px; +} */ + +#topbar-cta-button .group .flex { + margin-top: 5px; + margin-bottom: 5px; + font-size: medium; +} + +.flex-1 .flex .items-center { + /* background-color: #f5f5f5; */ +} \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 6666aaabf..13e8e5ab7 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { }, plugins: ["react", "prettier", "simple-import-sort", "import"], rules: { + "@typescript-eslint/no-empty-function": "off", quotes: ["error", "double", { avoidEscape: true }], "comma-dangle": ["error", "only-multiline"], "react/react-in-jsx-scope": "off", @@ -72,7 +73,6 @@ module.exports = { ], "@typescript-eslint/no-non-null-assertion": "off", "simple-import-sort/exports": "warn", - "@typescript-eslint/no-empty-function": "off", "simple-import-sort/imports": [ "warn", { diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index 83c1ca9e3..1a68699d2 100644 --- a/frontend/.storybook/main.js +++ b/frontend/.storybook/main.js @@ -1,28 +1,28 @@ -const path = require('path'); +const path = require("path"); module.exports = { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], addons: [ - '@storybook/addon-links', - '@storybook/addon-essentials', - '@storybook/addon-interactions', - 'storybook-dark-mode', + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + "storybook-dark-mode", { - name: '@storybook/addon-styling', + name: "@storybook/addon-styling", options: { postCss: { - implementation: require('postcss') + implementation: require("postcss") } } } ], framework: { - name: '@storybook/nextjs', + name: "@storybook/nextjs", options: {} }, core: { disableTelemetry: true }, docs: { - autodocs: 'tag' + autodocs: "tag" } }; diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2060c214a..9090fc603 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -52,6 +52,9 @@ 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 SAML_ORG_SLUG +ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ + BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG ARG NEXT_INFISICAL_PLATFORM_VERSION ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION=$NEXT_INFISICAL_PLATFORM_VERSION diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f28920f06..c33c9dc36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -31,12 +32,14 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popper": "^1.1.3", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.8.3", + "@sindresorhus/slugify": "^2.2.1", "@stripe/react-stripe-js": "^1.16.3", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", @@ -48,7 +51,7 @@ "axios-auth-refresh": "^3.3.6", "base64-loader": "^1.0.0", "classnames": "^2.3.1", - "cookies": "^0.8.0", + "cookies": "^0.9.1", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", "file-saver": "^2.0.5", @@ -65,10 +68,11 @@ "jwt-decode": "^3.1.2", "lottie-react": "^2.4.0", "markdown-it": "^13.0.1", + "ms": "^2.1.3", "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", - "posthog-js": "^1.58.0", + "posthog-js": "^1.105.6", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", @@ -82,9 +86,10 @@ "react-markdown": "^8.0.3", "react-redux": "^8.0.2", "react-table": "^7.8.0", - "sanitize-html": "^2.11.0", + "react-toastify": "^9.1.3", + "sanitize-html": "^2.12.1", "set-cookie-parser": "^2.5.1", - "sharp": "^0.32.6", + "sharp": "^0.33.2", "styled-components": "^5.3.7", "tailwind-merge": "^1.8.1", "tweetnacl": "^1.0.3", @@ -94,7 +99,7 @@ "yaml": "^2.2.2", "yup": "^0.32.11", "zod": "^3.22.3", - "zustand": "^4.4.1" + "zustand": "^4.5.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.5.2", @@ -2487,6 +2492,15 @@ "react": ">=16.8.0" } }, + "node_modules/@emnapi/runtime": { + "version": "0.45.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", + "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -3243,6 +3257,437 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.2.tgz", + "integrity": "sha512-itHBs1rPmsmGF9p4qRe++CzCgd+kFYktnsoR1sbIAfsRMrJZau0Tt1AH9KVnufc2/tU02Gf6Ibujx+15qRE03w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.2.tgz", + "integrity": "sha512-/rK/69Rrp9x5kaWBjVN07KixZanRr+W1OiyKdXcbjQD6KbW+obaTeBBtLUAtbBsnlTTmWthw99xqoOS7SsySDg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-kQyrSNd6lmBV7O0BUiyu/OEw9yeNGFbQhbxswS1i6rMDwBBSX+e+rPzu3S+MwAiGU3HdLze3PanQ4Xkfemgzcw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.1.tgz", + "integrity": "sha512-eVU/JYLPVjhhrd8Tk6gosl5pVlvsqiFlt50wotCvdkFGf+mDNBJxMh+bvav+Wt3EBnNZWq8Sp2I7XfSjm8siog==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.1.tgz", + "integrity": "sha512-FtdMvR4R99FTsD53IA3LxYGghQ82t3yt0ZQ93WMZ2xV3dqrb0E8zq4VHaTOuLEAuA83oDawHV3fd+BsAPadHIQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.1.tgz", + "integrity": "sha512-bnGG+MJjdX70mAQcSLxgeJco11G+MxTz+ebxlz8Y3dxyeb3Nkl7LgLI0mXupoO+u1wRNx/iRj5yHtzA4sde1yA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.1.tgz", + "integrity": "sha512-3+rzfAR1YpMOeA2zZNp+aYEzGNWK4zF3+sdMxuCS3ey9HhDbJ66w6hDSHDMoap32DueFwhhs3vwooAB2MaK4XQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.1.tgz", + "integrity": "sha512-3NR1mxFsaSgMMzz1bAnnKbSAI+lHXVTqAHgc1bgzjHuXjo4hlscpUxc0vFSAPKI3yuzdzcZOkq7nDPrP2F8Jgw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.1.tgz", + "integrity": "sha512-5aBRcjHDG/T6jwC3Edl3lP8nl9U2Yo8+oTl5drd1dh9Z1EBfzUKAJFUDTDisDjUwc7N4AjnPGfCA3jl3hY8uDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.1.tgz", + "integrity": "sha512-dcT7inI9DBFK6ovfeWRe3hG30h51cBAP5JXlZfx6pzc/Mnf9HFCQDLtYf4MCBjxaaTfjCCjkBxcy3XzOAo5txw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.2.tgz", + "integrity": "sha512-Fndk/4Zq3vAc4G/qyfXASbS3HBZbKrlnKZLEJzPLrXoJuipFNNwTes71+Ki1hwYW5lch26niRYoZFAtZVf3EGA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.2.tgz", + "integrity": "sha512-pz0NNo882vVfqJ0yNInuG9YH71smP4gRSdeL09ukC2YLE6ZyZePAlWKEHgAzJGTiOh8Qkaov6mMIMlEhmLdKew==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.2.tgz", + "integrity": "sha512-MBoInDXDppMfhSzbMmOQtGfloVAflS2rP1qPcUIiITMi36Mm5YR7r0ASND99razjQUpHTzjrU1flO76hKvP5RA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.2.tgz", + "integrity": "sha512-xUT82H5IbXewKkeF5aiooajoO1tQV4PnKfS/OZtb5DDdxS/FCI/uXTVZ35GQ97RZXsycojz/AJ0asoz6p2/H/A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.2.tgz", + "integrity": "sha512-F+0z8JCu/UnMzg8IYW1TMeiViIWBVg7IWP6nE0p5S5EPQxlLd76c8jYemG21X99UzFwgkRo5yz2DS+zbrnxZeA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.2.tgz", + "integrity": "sha512-+ZLE3SQmSL+Fn1gmSaM8uFusW5Y3J9VOf+wMGNnTtJUMUxFhv+P4UPaYEYT8tqnyYVaOVGgMN/zsOxn9pSsO2A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.2.tgz", + "integrity": "sha512-fLbTaESVKuQcpm8ffgBD7jLb/CQLcATju/jxtTXR1XCLwbOQt+OL5zPHSDMmp2JZIeq82e18yE0Vv7zh6+6BfQ==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.45.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.2.tgz", + "integrity": "sha512-okBpql96hIGuZ4lN3+nsAjGeggxKm7hIRu9zyec0lnfB8E7Z6p95BuRZzDDXZOl2e8UmR4RhYt631i7mfmKU8g==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.2.tgz", + "integrity": "sha512-E4magOks77DK47FwHUIGH0RYWSgRBfGdK56kIHSVeB9uIS4pPFr4N2kIVsXdQQo4LzOsENKV5KAhRlRL7eMAdg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4783,6 +5228,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", @@ -5301,6 +5778,57 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@storybook/addon-actions": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.8.tgz", @@ -6574,6 +7102,29 @@ "node": ">=10" } }, + "node_modules/@storybook/nextjs/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@storybook/nextjs/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -8939,9 +9490,10 @@ } }, "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==", + "dev": true }, "node_modules/babel-core": { "version": "7.0.0-bridge.0", @@ -9240,6 +9792,43 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bare-events": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.0.tgz", + "integrity": "sha512-Yyyqff4PIFfSuthCZqLlPISTWHmnQxoPuAvkmgzsJEmG3CesdIv6Xweayl0JkCZJSB2yYIdJyEz97tpxNhgjbg==", + "dev": true, + "optional": true + }, + "node_modules/bare-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.1.5.tgz", + "integrity": "sha512-5t0nlecX+N2uJqdxe9d18A98cp2u9BETelbjKpiVgQqzzmVNFYWEAjQHqS+2Khgto1vcwhik9cXucaj5ve2WWA==", + "dev": true, + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-os": "^2.0.0", + "bare-path": "^2.0.0", + "streamx": "^2.13.0" + } + }, + "node_modules/bare-os": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.2.0.tgz", + "integrity": "sha512-hD0rOPfYWOMpVirTACt4/nK8mC55La12K5fY1ij8HAdfQakD62M+H4o4tpfKzVGLgRDTuk3vjA4GqGXXCeFbag==", + "dev": true, + "optional": true + }, + "node_modules/bare-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.0.tgz", + "integrity": "sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==", + "dev": true, + "optional": true, + "dependencies": { + "bare-os": "^2.1.0" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -9253,6 +9842,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -9336,6 +9926,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -9346,6 +9937,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -9374,13 +9966,13 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dev": true, "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -9388,7 +9980,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -9624,6 +10216,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -10336,9 +10929,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true, "engines": { "node": ">= 0.6" @@ -10351,9 +10944,9 @@ "dev": true }, "node_modules/cookies": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", - "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", "dependencies": { "depd": "~2.0.0", "keygrip": "~1.1.0" @@ -10917,6 +11510,11 @@ } } }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -10941,6 +11539,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -10993,6 +11592,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, "engines": { "node": ">=4.0.0" } @@ -11566,9 +12166,9 @@ "dev": true }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -11634,6 +12234,7 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, "dependencies": { "once": "^1.4.0" } @@ -12708,22 +13309,23 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, "engines": { "node": ">=6" } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -12849,7 +13451,8 @@ "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==" + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true }, "node_modules/fast-glob": { "version": "3.3.2", @@ -13216,9 +13819,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -13435,7 +14038,8 @@ "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true }, "node_modules/fs-extra": { "version": "11.2.0", @@ -13686,7 +14290,8 @@ "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true }, "node_modules/github-slugger": { "version": "1.5.0", @@ -14412,6 +15017,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -14580,9 +15186,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/ipaddr.js": { @@ -16856,6 +17462,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "engines": { "node": ">=10" }, @@ -16959,7 +17566,8 @@ "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true }, "node_modules/mri": { "version": "1.2.0", @@ -16970,9 +17578,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multipipe": { "version": "1.0.2", @@ -17008,7 +17616,8 @@ "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "dev": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -17185,6 +17794,7 @@ "version": "3.54.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.54.0.tgz", "integrity": "sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==", + "dev": true, "dependencies": { "semver": "^7.3.5" }, @@ -17196,6 +17806,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -17204,9 +17815,10 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -17220,7 +17832,8 @@ "node_modules/node-abi/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/node-abort-controller": { "version": "3.1.1", @@ -17231,7 +17844,8 @@ "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true }, "node_modules/node-dir": { "version": "0.1.17", @@ -18544,17 +19158,28 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, "node_modules/posthog-js": { - "version": "1.100.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.100.0.tgz", - "integrity": "sha512-r2XZEiHQ9mBK7D1G9k57I8uYZ2kZTAJ0OCX6K/OOdCWN8jKPhw3h5F9No5weilP6eVAn+hrsy7NvPV7SCX7gMg==", + "version": "1.105.6", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.6.tgz", + "integrity": "sha512-5ITXsh29XIuNohHLy21nawGnfFZDpyt+yfnWge9sJl5yv0nNuoUmLiDgw1tJafoqGrfd5CUasKyzSI21HxsSeQ==", "dependencies": { - "fflate": "^0.4.1" + "fflate": "^0.4.8", + "preact": "^10.19.3" + } + }, + "node_modules/preact": { + "version": "10.19.5", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.5.tgz", + "integrity": "sha512-OPELkDmSVbKjbFqF9tgvOowiiQ9TmsJljIzXRyNE8nGiis94pwv1siF78rQkAP1Q1738Ce6pellRg/Ns/CtHqQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" } }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "dev": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -18579,12 +19204,14 @@ "node_modules/prebuild-install/node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true }, "node_modules/prebuild-install/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -18598,6 +19225,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -18609,6 +19237,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -18911,6 +19540,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -19149,7 +19779,8 @@ "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==" + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true }, "node_modules/quick-lru": { "version": "5.1.1", @@ -19215,9 +19846,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, "dependencies": { "bytes": "3.1.2", @@ -19233,6 +19864,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -19246,12 +19878,14 @@ "node_modules/rc/node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -19719,6 +20353,26 @@ "react": "^16.8.3 || ^17.0.0-0 || ^18.0.0" } }, + "node_modules/react-toastify": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz", + "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-toastify/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -20570,9 +21224,9 @@ "dev": true }, "node_modules/sanitize-html": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz", - "integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz", + "integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", @@ -20713,12 +21367,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -20820,25 +21468,42 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "version": "0.33.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.2.tgz", + "integrity": "sha512-WlYOPyyPDiiM07j/UO+E720ju6gtNtHjEGg5vovUk1Lgxyjm2LFO+37Nt/UI3MMh2l6hxTWQWi7qk3cXJTutcQ==", "hasInstallScript": true, "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" + "semver": "^7.5.4" }, "engines": { - "node": ">=14.15.0" + "libvips": ">=8.15.1", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.2", + "@img/sharp-darwin-x64": "0.33.2", + "@img/sharp-libvips-darwin-arm64": "1.0.1", + "@img/sharp-libvips-darwin-x64": "1.0.1", + "@img/sharp-libvips-linux-arm": "1.0.1", + "@img/sharp-libvips-linux-arm64": "1.0.1", + "@img/sharp-libvips-linux-s390x": "1.0.1", + "@img/sharp-libvips-linux-x64": "1.0.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.1", + "@img/sharp-libvips-linuxmusl-x64": "1.0.1", + "@img/sharp-linux-arm": "0.33.2", + "@img/sharp-linux-arm64": "0.33.2", + "@img/sharp-linux-s390x": "0.33.2", + "@img/sharp-linux-x64": "0.33.2", + "@img/sharp-linuxmusl-arm64": "0.33.2", + "@img/sharp-linuxmusl-x64": "0.33.2", + "@img/sharp-wasm32": "0.33.2", + "@img/sharp-win32-ia32": "0.33.2", + "@img/sharp-win32-x64": "0.33.2" } }, "node_modules/sharp/node_modules/lru-cache": { @@ -20916,6 +21581,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, "funding": [ { "type": "github", @@ -20935,6 +21601,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, "funding": [ { "type": "github", @@ -21318,12 +21985,16 @@ "dev": true }, "node_modules/streamx": { - "version": "2.15.6", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.6.tgz", - "integrity": "sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw==", + "version": "2.15.8", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.8.tgz", + "integrity": "sha512-6pwMeMY/SuISiRsuS8TeIrAzyFbG5gGPHFQsYjUr/pbBadaL1PCWmzKw+CHZSwainfvcF6Si6cVLq4XTEwswFQ==", + "dev": true, "dependencies": { "fast-fifo": "^1.1.0", "queue-tick": "^1.0.1" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" } }, "node_modules/strict-uri-encode": { @@ -21769,9 +22440,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, "dependencies": { "chownr": "^2.0.0", @@ -21786,19 +22457,24 @@ } }, "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==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.5.tgz", + "integrity": "sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==", + "dev": true, "dependencies": { - "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0" } }, "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==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", @@ -22322,6 +22998,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -23097,9 +23774,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.1.tgz", - "integrity": "sha512-y51HrHaFeeWir0YO4f0g+9GwZawuigzcAdRNon6jErXy/SqV/+O6eaVAzDqE6t3e3NpGeR5CS+cCDaTC+V3yEQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.2.tgz", + "integrity": "sha512-Wu+EHmX326YPYUpQLKmKbTyZZJIB8/n6R09pTmB03kJmnMsVPTo9COzHZFr01txwaCAuZvfBJE4ZCHRcKs5JaQ==", "dev": true, "dependencies": { "colorette": "^2.0.10", @@ -23536,9 +24213,9 @@ } }, "node_modules/zustand": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.7.tgz", - "integrity": "sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.0.tgz", + "integrity": "sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==", "dependencies": { "use-sync-external-store": "1.2.0" }, @@ -23547,7 +24224,7 @@ }, "peerDependencies": { "@types/react": ">=16.8", - "immer": ">=9.0", + "immer": ">=9.0.6", "react": ">=16.8" }, "peerDependenciesMeta": { diff --git a/frontend/package.json b/frontend/package.json index 53e383f42..e01ef945e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,12 +39,14 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popper": "^1.1.3", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.8.3", + "@sindresorhus/slugify": "^2.2.1", "@stripe/react-stripe-js": "^1.16.3", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", @@ -56,7 +58,7 @@ "axios-auth-refresh": "^3.3.6", "base64-loader": "^1.0.0", "classnames": "^2.3.1", - "cookies": "^0.8.0", + "cookies": "^0.9.1", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", "file-saver": "^2.0.5", @@ -73,10 +75,11 @@ "jwt-decode": "^3.1.2", "lottie-react": "^2.4.0", "markdown-it": "^13.0.1", + "ms": "^2.1.3", "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", - "posthog-js": "^1.58.0", + "posthog-js": "^1.105.6", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", @@ -90,9 +93,10 @@ "react-markdown": "^8.0.3", "react-redux": "^8.0.2", "react-table": "^7.8.0", - "sanitize-html": "^2.11.0", + "react-toastify": "^9.1.3", + "sanitize-html": "^2.12.1", "set-cookie-parser": "^2.5.1", - "sharp": "^0.32.6", + "sharp": "^0.33.2", "styled-components": "^5.3.7", "tailwind-merge": "^1.8.1", "tweetnacl": "^1.0.3", @@ -102,7 +106,7 @@ "yaml": "^2.2.2", "yup": "^0.32.11", "zod": "^3.22.3", - "zustand": "^4.4.1" + "zustand": "^4.5.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.5.2", diff --git a/frontend/public/images/integrations/Agent.png b/frontend/public/images/integrations/Agent.png new file mode 100644 index 000000000..662c1b273 Binary files /dev/null and b/frontend/public/images/integrations/Agent.png differ diff --git a/frontend/public/images/integrations/Ansible.png b/frontend/public/images/integrations/Ansible.png new file mode 100644 index 000000000..925e48ca6 Binary files /dev/null and b/frontend/public/images/integrations/Ansible.png differ diff --git a/frontend/public/images/integrations/ECS.png b/frontend/public/images/integrations/ECS.png new file mode 100644 index 000000000..92e0d23ca Binary files /dev/null and b/frontend/public/images/integrations/ECS.png differ diff --git a/frontend/public/images/integrations/Jenkins.png b/frontend/public/images/integrations/Jenkins.png new file mode 100644 index 000000000..9df069a18 Binary files /dev/null and b/frontend/public/images/integrations/Jenkins.png differ diff --git a/frontend/public/images/secretRotation/aws-iam.svg b/frontend/public/images/secretRotation/aws-iam.svg new file mode 100644 index 000000000..0b3b2387b --- /dev/null +++ b/frontend/public/images/secretRotation/aws-iam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/json/frameworkIntegrations.json b/frontend/public/json/frameworkIntegrations.json index 1a6ff94f2..c96c0a0b4 100644 --- a/frontend/public/json/frameworkIntegrations.json +++ b/frontend/public/json/frameworkIntegrations.json @@ -1,28 +1,4 @@ [ - { - "name": "Docker", - "slug": "docker", - "image": "Docker", - "docsLink": "https://infisical.com/docs/integrations/platforms/docker" - }, - { - "name": "Docker Compose", - "slug": "docker-compose", - "image": "Docker Compose", - "docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose" - }, - { - "name": "Kubernetes", - "slug": "kubernetes", - "image": "Kubernetes", - "docsLink": "https://infisical.com/docs/integrations/platforms/kubernetes" - }, - { - "name": "Terraform", - "slug": "terraform", - "image": "Terraform", - "docsLink": "https://infisical.com/docs/integrations/frameworks/terraform" - }, { "name": "React", "slug": "react", diff --git a/frontend/public/json/infrastructureIntegrations.json b/frontend/public/json/infrastructureIntegrations.json new file mode 100644 index 000000000..657cd17eb --- /dev/null +++ b/frontend/public/json/infrastructureIntegrations.json @@ -0,0 +1,50 @@ +[ + { + "name": "Docker", + "slug": "docker", + "image": "Docker", + "docsLink": "https://infisical.com/docs/integrations/platforms/docker" + }, + { + "name": "Docker Compose", + "slug": "docker-compose", + "image": "Docker Compose", + "docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose" + }, + { + "name": "Kubernetes", + "slug": "kubernetes", + "image": "Kubernetes", + "docsLink": "https://infisical.com/docs/integrations/platforms/kubernetes" + }, + { + "name": "Terraform", + "slug": "terraform", + "image": "Terraform", + "docsLink": "https://infisical.com/docs/integrations/frameworks/terraform" + }, + { + "name": "Jenkins", + "slug": "jenkins", + "image": "Jenkins", + "docsLink": "https://infisical.com/docs/integrations/cicd/jenkins" + }, + { + "name": "Infisical Agent", + "slug": "agent", + "image": "Agent", + "docsLink": "https://infisical.com/docs/integrations/platforms/infisical-agent" + }, + { + "name": "Amazon ECS", + "slug": "ecs", + "image": "ECS", + "docsLink": "https://infisical.com/docs/integrations/platforms/ecs-with-agent" + }, + { + "name": "Ansible", + "slug": "ansible", + "image": "Ansible", + "docsLink": "https://infisical.com/docs/integrations/platforms/ansible" + } +] \ No newline at end of file diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 8b0487292..b1465f04d 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -120,7 +120,7 @@ "available": "Platform & Cloud Integrations", "available-text1": "Click on the integration you want to connect. This will let your environment variables flow automatically into selected third-party services.", "available-text2": "Note: during an integration with Heroku, for security reasons, it is impossible to maintain end-to-end encryption. In theory, this lets Infisical decrypt yor environment variables. In practice, we can assure you that this will never be done, and it allows us to protect your secrets from bad actors online. The core Infisical service will always stay end-to-end encrypted. With any questions, reach out support@infisical.com.", - "cloud-integrations": "Cloud Integrations", + "cloud-integrations": "Native Integrations", "framework-integrations": "Framework Integrations", "click-to-start": "Click on an integration to begin syncing secrets to it.", "click-to-setup": "Click on a framework to get the setup instructions.", diff --git a/frontend/scripts/initialize-standalone-build.sh b/frontend/scripts/initialize-standalone-build.sh index d9138bb77..859814eda 100755 --- a/frontend/scripts/initialize-standalone-build.sh +++ b/frontend/scripts/initialize-standalone-build.sh @@ -4,6 +4,8 @@ scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_POSTHOG_API_KEY scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTERCOM_ID" +scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" + if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" scripts/set-standalone-build-telemetry.sh true diff --git a/frontend/scripts/start.sh b/frontend/scripts/start.sh index 1db867e15..1488ad328 100644 --- a/frontend/scripts/start.sh +++ b/frontend/scripts/start.sh @@ -4,6 +4,8 @@ scripts/replace-variable.sh "$BAKED_NEXT_PUBLIC_POSTHOG_API_KEY" "$NEXT_PUBLIC_P scripts/replace-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTERCOM_ID" +scripts/replace-variable.sh "$BAKED_NEXT_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" + if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" scripts/set-telemetry.sh true diff --git a/frontend/src/components/analytics/posthog.ts b/frontend/src/components/analytics/posthog.ts index 246d5c9cc..cc26e5512 100644 --- a/frontend/src/components/analytics/posthog.ts +++ b/frontend/src/components/analytics/posthog.ts @@ -6,7 +6,7 @@ import { ENV, POSTHOG_API_KEY, POSTHOG_HOST } from "../utilities/config"; export const initPostHog = () => { // @ts-ignore - console.log("Hi there πŸ‘‹") + console.log("Hi there πŸ‘‹"); try { if (typeof window !== "undefined") { // @ts-ignore @@ -19,7 +19,7 @@ export const initPostHog = () => { return posthog; } catch (e) { - console.log("posthog err", e) + console.log("posthog err", e); } return undefined; diff --git a/frontend/src/components/basic/Error.tsx b/frontend/src/components/basic/Error.tsx index bf892e03d..1ef937e4a 100644 --- a/frontend/src/components/basic/Error.tsx +++ b/frontend/src/components/basic/Error.tsx @@ -3,9 +3,9 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; const Error = ({ text }: { text: string }): JSX.Element => { return ( -
- - {text &&

{text}

} +
+ + {text &&

{text}

}
); }; diff --git a/frontend/src/components/basic/InputField.tsx b/frontend/src/components/basic/InputField.tsx index 0bc01defc..346294e56 100644 --- a/frontend/src/components/basic/InputField.tsx +++ b/frontend/src/components/basic/InputField.tsx @@ -39,16 +39,16 @@ const InputField = ({ if (isStatic === true) { return ( -
-

{label}

- {text &&

{text}

} +
+

{label}

+ {text &&

{text}

} onChangeHandler(e.target.value)} type={type} placeholder={placeholder} value={value} required={isRequired} - className="bg-bunker-800 text-gray-400 border border-gray-600 rounded-md text-md p-2 w-full min-w-16 outline-none" + className="text-md min-w-16 w-full rounded-md border border-gray-600 bg-bunker-800 p-2 text-gray-400 outline-none" name={name} readOnly autoComplete={autoComplete} @@ -58,12 +58,12 @@ const InputField = ({ ); } return ( -
-
-

{label}

+
+
+

{label}

@@ -75,11 +75,11 @@ const InputField = ({ required={isRequired} className={`${ blurred - ? "text-bunker-800 group-hover:text-gray-400 focus:text-gray-400 active:text-gray-400" + ? "text-bunker-800 focus:text-gray-400 active:text-gray-400 group-hover:text-gray-400" : "" } ${ error ? "focus:ring-red/50" : "focus:ring-primary/50" - } relative peer bg-mineshaft-900 rounded-md text-gray-400 text-md p-2 w-full min-w-16 outline-none focus:ring-4 duration-200`} + } text-md min-w-16 peer relative w-full rounded-md bg-mineshaft-900 p-2 text-gray-400 outline-none duration-200 focus:ring-4`} name={name} spellCheck="false" autoComplete={autoComplete} @@ -91,7 +91,7 @@ const InputField = ({ onClick={() => { setPasswordVisible(!passwordVisible); }} - className="absolute self-end mr-3 text-gray-400 cursor-pointer" + className="absolute mr-3 cursor-pointer self-end text-gray-400" > {passwordVisible ? ( @@ -101,7 +101,7 @@ const InputField = ({ )} {blurred && ( -
+

{value .split("") @@ -109,7 +109,7 @@ const InputField = ({ .map(() => ( ))} @@ -121,7 +121,7 @@ const InputField = ({

)} */}
- {error &&

{errorText}

} + {error &&

{errorText}

}
); }; diff --git a/frontend/src/components/basic/Listbox.tsx b/frontend/src/components/basic/Listbox.tsx index 5cdeb26d9..cad9aaab4 100644 --- a/frontend/src/components/basic/Listbox.tsx +++ b/frontend/src/components/basic/Listbox.tsx @@ -34,19 +34,19 @@ const ListBox = ({
{text} - + {" "} {isSelected}
{data && ( -
+
)} @@ -58,16 +58,16 @@ const ListBox = ({ leaveFrom="opacity-100" leaveTo="opacity-0" > - + {data.map((person, personIdx) => ( - `my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md ${ - selected ? "bg-white/10 text-gray-400 font-bold" : "" + `relative my-0.5 cursor-default select-none rounded-md py-2 pl-10 pr-4 ${ + selected ? "bg-white/10 font-bold text-gray-400" : "" } ${ active && !selected - ? "bg-white/5 text-mineshaft-200 cursor-pointer" + ? "cursor-pointer bg-white/5 text-mineshaft-200" : "text-gray-400" } ` } @@ -83,7 +83,7 @@ const ListBox = ({ {person} {selected ? ( - + ) : null} @@ -92,9 +92,9 @@ const ListBox = ({ ))} {buttonAction && ( -
diff --git a/frontend/src/components/basic/dialog/AddUserDialog.tsx b/frontend/src/components/basic/dialog/AddUserDialog.tsx index b36c31d6c..dd2aede0a 100644 --- a/frontend/src/components/basic/dialog/AddUserDialog.tsx +++ b/frontend/src/components/basic/dialog/AddUserDialog.tsx @@ -13,76 +13,63 @@ type Props = { orgName: string; }; -const AddUserDialog = ({ - isOpen, - closeModal, - submitModal, - email, - setEmail, - orgName, -}: Props) => { +const AddUserDialog = ({ isOpen, closeModal, submitModal, email, setEmail, orgName }: Props) => { const submit = () => { submitModal(email); }; return ( -
+
- + -
+
-
-
+
+
- + Invite others to {orgName} -
-

- An invite is specific to an email address and expires - after 1 day. For security reasons, you will need to - separately add members to projects. +

+

+ An invite is specific to an email address and expires after 1 day. For + security reasons, you will need to separately add members to projects.

-
+
-
-
{/* diff --git a/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx b/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx index 88dc7cb78..b1cc1fd07 100644 --- a/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx +++ b/frontend/src/components/basic/dialog/AddWorkspaceDialog.tsx @@ -5,7 +5,6 @@ import Button from "../buttons/Button"; import InputField from "../InputField"; import { Checkbox } from "../table/Checkbox"; - type Props = { isOpen: boolean; closeModal: () => void; @@ -26,8 +25,8 @@ const AddWorkspaceDialog = ({ workspaceName, setWorkspaceName, error, - loading, -}:Props) => { + loading +}: Props) => { const [addAllUsers, setAddAllUsers] = useState(true); const submit = () => { submitModal(workspaceName, addAllUsers); @@ -60,11 +59,8 @@ const AddWorkspaceDialog = ({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - - + + Create a new project
@@ -72,7 +68,7 @@ const AddWorkspaceDialog = ({ This project will contain your secrets and configs.

-
+
- +
-
{textLine1}
-
{textLine2}
-
+
{textLine1}
+
{textLine2}
+ diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx deleted file mode 100644 index 9f109b931..000000000 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons"; - -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { Select, SelectItem } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; -import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission"; -import { - useDeleteUserFromWorkspace, - useGetUserWsKey, - useUpdateUserWorkspaceRole, - useUploadWsKey -} from "@app/hooks/api"; - -import { decryptAssymmetric, encryptAssymmetric } from "../../utilities/cryptography/crypto"; -import guidGenerator from "../../utilities/randomId"; -import Button from "../buttons/Button"; -import UpgradePlanModal from "../dialog/UpgradePlan"; - -// const roles = ['admin', 'user']; -// TODO: Set type for this -type Props = { - userData: any[]; - changeData: (users: any[]) => void; - myUser: string; - filter: string; - isUserListLoading: boolean; -}; - -type EnvironmentProps = { - name: string; - slug: string; -}; - -/** - * This is the component that shows the users of a certin project - * #TODO: add the possibility of choosing and doing operations on multiple users. - * @param {*} props - * @returns - */ -const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => { - const { currentWorkspace } = useWorkspace(); - const { subscription } = useSubscription(); - const { data: wsKey } = useGetUserWsKey(currentWorkspace?.id ?? ""); - - const { mutateAsync: deleteUserFromWorkspaceMutateAsync } = useDeleteUserFromWorkspace(); - const { mutateAsync: uploadWsKeyMutateAsync } = useUploadWsKey(); - const { mutateAsync: updateUserWorkspaceRoleMutateAsync } = useUpdateUserWorkspaceRole(); - // const [roleSelected, setRoleSelected] = useState( - // Array(userData?.length).fill(userData.map((user) => user.role)) - // ); - const router = useRouter(); - const [myRole, setMyRole] = useState("member"); - const [workspaceEnvs, setWorkspaceEnvs] = useState([]); - const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false); - const { createNotification } = useNotificationContext(); - - const workspaceId = router.query.id as string; - // Delete the row in the table (e.g. a user) - // #TODO: Add a pop-up that warns you that the user is going to be deleted. - const handleDelete = async (membershipId: string) => { - await deleteUserFromWorkspaceMutateAsync({ membershipId, workspaceId }); - }; - - const handleRoleUpdate = async (index: number, e: string) => { - await updateUserWorkspaceRoleMutateAsync({ - workspaceId, - membershipId: userData[index].membershipId, - role: e.toLowerCase() - }); - createNotification({ - text: "Successfully changed user role.", - type: "success" - }); - }; - - const handlePermissionUpdate = ( - index: number, - val: string, - membershipId: string, - slug: string - ) => { - let denials: { ability: string; environmentSlug: string }[]; - if (val === "Read Only") { - denials = [ - { - ability: "write", - environmentSlug: slug - } - ]; - } else if (val === "No Access") { - denials = [ - { - ability: "write", - environmentSlug: slug - }, - { - ability: "read", - environmentSlug: slug - } - ]; - } else if (val === "Add Only") { - denials = [ - { - ability: "read", - environmentSlug: slug - } - ]; - } else { - denials = []; - } - - if (subscription?.rbac === false) { - setIsUpgradeModalOpen(true); - } else { - const allDenials = userData[index].deniedPermissions - .filter( - (perm: { ability: string; environmentSlug: string }) => perm.environmentSlug !== slug - ) - .concat(denials); - updateUserProjectPermission({ membershipId, denials: allDenials }); - changeData([ - ...userData.slice(0, index), - ...[ - { - key: userData[index].key, - firstName: userData[index].firstName, - lastName: userData[index].lastName, - email: userData[index].email, - role: userData[index].role, - status: userData[index].status, - userId: userData[index].userId, - membershipId: userData[index].membershipId, - publicKey: userData[index].publicKey, - deniedPermissions: allDenials - } - ], - ...userData.slice(index + 1, userData?.length) - ]); - createNotification({ - text: "Successfully changed user permissions.", - type: "success" - }); - } - }; - - useEffect(() => { - setMyRole(userData.filter((user) => user.email === myUser)[0]?.role); - (async () => { - if (currentWorkspace) { - setWorkspaceEnvs(currentWorkspace.environments); - } - })(); - }, [userData, myUser, currentWorkspace]); - - const grantAccess = async (id: string, publicKey: string) => { - if (wsKey) { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - - // assymmetrically decrypt symmetric key with local private key - const key = decryptAssymmetric({ - ciphertext: wsKey.encryptedKey, - nonce: wsKey.nonce, - publicKey: wsKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: key, - publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKeyMutateAsync({ - workspaceId, - userId: id, - encryptedKey: ciphertext, - nonce - }); - router.reload(); - } - }; - - const closeUpgradeModal = () => { - setIsUpgradeModalOpen(false); - }; - - return ( -
-
- {subscription && ( - - )} - - - - - - - {workspaceEnvs.map((env) => ( - - ))} - - - - {!isUserListLoading && - userData?.filter( - (user) => - user.firstName?.toLowerCase().includes(filter) || - user.lastName?.toLowerCase().includes(filter) || - user.email?.toLowerCase().includes(filter) - ).length > 0 && - userData - ?.filter( - (user) => - user.firstName?.toLowerCase().includes(filter) || - user.lastName?.toLowerCase().includes(filter) || - user.email?.toLowerCase().includes(filter) - ) - .map((row, index) => ( - - - - - {workspaceEnvs.map((env) => ( - - ))} - - - ))} - {isUserListLoading && ( - <> - - - - )} - -
NAMEEMAILROLE - - {env.slug.toUpperCase()} -
-
- {/* PERMISSION */} -
-
- {row.firstName} {row.lastName} - - {row.email} - -
- - {row.status === "completed" && myUser !== row.email && ( -
-
- )} -
-
- - - {myUser !== row.email && - // row.role !== "admin" && - myRole !== "member" ? ( -
-
- ) : ( -
- )} -
-
- ); -}; - -export default ProjectUsersTable; diff --git a/frontend/src/components/context/Notifications/Notification.tsx b/frontend/src/components/context/Notifications/Notification.tsx deleted file mode 100644 index 806b68ad2..000000000 --- a/frontend/src/components/context/Notifications/Notification.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useRef } from "react"; -import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -type NotificationType = "success" | "error" | "info"; - -export type TNotification = { - text: string; - type?: NotificationType; - timeoutMs?: number; -}; - -interface NotificationProps { - notification: Required; - clearNotification: (text: string) => void; -} - -const Notification = ({ notification, clearNotification }: NotificationProps) => { - const timeout = useRef(); - - const handleClearNotification = () => clearNotification(notification.text); - - const setNotifTimeout = () => { - timeout.current = window.setTimeout(handleClearNotification, notification.timeoutMs); - }; - - const cancelNotifTimeout = () => { - clearTimeout(timeout.current); - }; - - useEffect(() => { - setNotifTimeout(); - - return cancelNotifTimeout; - }, []); - - return ( -
- {notification.type === "error" && ( -
- )} - {notification.type === "success" && ( -
- )} - {notification.type === "info" && ( -
- )} -

{notification.text}

- -
- ); -}; - -export default Notification; diff --git a/frontend/src/components/context/Notifications/NotificationProvider.tsx b/frontend/src/components/context/Notifications/NotificationProvider.tsx deleted file mode 100644 index 0c10d1711..000000000 --- a/frontend/src/components/context/Notifications/NotificationProvider.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from "react"; - -import { TNotification } from "./Notification"; -import Notifications from "./Notifications"; - -type NotificationContextState = { - createNotification: (newNotification: TNotification) => void; -}; - -const NotificationContext = createContext({ - createNotification: () => console.log("createNotification not set!") -}); - -export const useNotificationContext = () => useContext(NotificationContext); - -interface NotificationProviderProps { - children: ReactNode; -} - -// TODO: Migration to radix toast -const NotificationProvider = ({ children }: NotificationProviderProps) => { - const [notifications, setNotifications] = useState[]>([]); - - const clearNotification = (text: string) => - setNotifications((state) => state.filter((notif) => notif.text !== text)); - - const createNotification = useCallback( - ({ text, type = "success", timeoutMs = 4000 }: TNotification) => { - const doesNotifExist = notifications.some((notif) => notif.text === text); - - if (doesNotifExist) { - return; - } - - const newNotification: Required = { text, type, timeoutMs }; - - setNotifications((state) => [...state, newNotification]); - }, - [notifications] - ); - - const value = useMemo(() => ({ createNotification }), [createNotification]); - - return ( - - - {children} - - ); -}; - -export default NotificationProvider; diff --git a/frontend/src/components/context/Notifications/Notifications.tsx b/frontend/src/components/context/Notifications/Notifications.tsx deleted file mode 100644 index 81802fff6..000000000 --- a/frontend/src/components/context/Notifications/Notifications.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Notification, { TNotification } from "./Notification"; - -interface NoticationsProps { - notifications: Required[]; - clearNotification: (text: string) => void; -} - -const Notifications = ({ notifications, clearNotification }: NoticationsProps) => { - if (!notifications.length) { - return null; - } - - return ( -
- {notifications.map((notif) => ( - - ))} -
- ); -}; - -export default Notifications; diff --git a/frontend/src/components/dashboard/ConfirmEnvOverwriteModal.tsx b/frontend/src/components/dashboard/ConfirmEnvOverwriteModal.tsx index 9c8582d6a..1926e7d37 100644 --- a/frontend/src/components/dashboard/ConfirmEnvOverwriteModal.tsx +++ b/frontend/src/components/dashboard/ConfirmEnvOverwriteModal.tsx @@ -30,9 +30,9 @@ const ConfirmEnvOverwriteModal = ({ onClose={onClose} >
-

Your file contains the following duplicate secrets:

+

Your file contains the following duplicate secrets:

{duplicateKeys.join(", ")}

-

Are you sure you want to overwrite these secrets?

+

Are you sure you want to overwrite these secrets?

diff --git a/frontend/src/components/dashboard/DashboardInputField.tsx b/frontend/src/components/dashboard/DashboardInputField.tsx index 3cf87924e..60b92a51e 100644 --- a/frontend/src/components/dashboard/DashboardInputField.tsx +++ b/frontend/src/components/dashboard/DashboardInputField.tsx @@ -1,5 +1,10 @@ import { memo, SyntheticEvent, useRef } from "react"; -import { faCircle, faCodeBranch, faExclamationCircle, faEye } from "@fortawesome/free-solid-svg-icons"; +import { + faCircle, + faCodeBranch, + faExclamationCircle, + faEye +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import guidGenerator from "../utilities/randomId"; @@ -31,8 +36,8 @@ interface DashboardInputFieldProps { * @param {boolean} obj.blurred - whether the input field should be blurred (behind the gray dots) or not; this can be turned on/off in the dashboard * @param {boolean} obj.isDuplicate - if the key name is duplicated * @param {boolean} obj.override - whether a secret/row should be displalyed as overriden - * - * + * + * * @returns */ @@ -61,29 +66,31 @@ const DashboardInputField = ({ const error = startsWithNumber || isDuplicate; return ( -
+
onChangeHandler(isCapitalized ? e.target.value.toUpperCase() : e.target.value, id)} + onChange={(e) => + onChangeHandler(isCapitalized ? e.target.value.toUpperCase() : e.target.value, id) + } type={type} value={value} - className={`z-10 peer font-mono ph-no-capture bg-transparent h-full caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none ${ + className={`ph-no-capture min-w-16 peer z-10 h-full w-full bg-transparent px-2 font-mono text-sm caret-bunker-200 outline-none ${ error ? "text-red-600 focus:text-red-500" : "text-bunker-300 focus:text-bunker-100" } duration-200`} spellCheck="false" />
{startsWithNumber && ( -
- + )} {isDuplicate && value !== "" && !startsWithNumber && ( -
- +
)} - {!error &&
- -
} + {!error && ( +
+ +
+ )}
); } @@ -127,20 +145,29 @@ const DashboardInputField = ({ return ( -
+
- {value?.split("\n")[0] ? - {value?.split("\n")[0]} - : - } - {value?.split("\n")[1] && - {value?.split("\n")[1]} - } + {value?.split("\n")[0] ? ( + + {value?.split("\n")[0]} + + ) : ( + - + )} + {value?.split("\n")[1] && ( + + {value?.split("\n")[1]} + + )}
@@ -148,10 +175,10 @@ const DashboardInputField = ({ } if (type === "value") { return ( -
-
+
+
{overrideEnabled === true && ( -
+
Override enabled
)} @@ -160,20 +187,20 @@ const DashboardInputField = ({ onChange={(e) => onChangeHandler(e.target.value, id)} onScroll={syncScroll} className={`${ - blurred - ? "text-transparent focus:text-transparent active:text-transparent" - : "" - } z-10 peer font-mono ph-no-capture bg-transparent caret-white text-transparent text-sm px-2 py-2 w-full min-w-16 outline-none duration-200 no-scrollbar no-scrollbar::-webkit-scrollbar`} + blurred ? "text-transparent focus:text-transparent active:text-transparent" : "" + } ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none duration-200 no-scrollbar`} spellCheck="false" />
{value?.split(REGEX).map((word) => { if (word.match(REGEX) !== null) { @@ -203,20 +230,24 @@ const DashboardInputField = ({ })}
{blurred && ( -
-
+
+
{value?.split("").map(() => ( ))} - {value?.split("").length === 0 && EMPTY} + {value?.split("").length === 0 && EMPTY} +
+
+
-
)}
diff --git a/frontend/src/components/dashboard/DeleteActionButton.tsx b/frontend/src/components/dashboard/DeleteActionButton.tsx index 530ee3749..da7357fe6 100644 --- a/frontend/src/components/dashboard/DeleteActionButton.tsx +++ b/frontend/src/components/dashboard/DeleteActionButton.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React from "react"; import { useTranslation } from "react-i18next"; import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -8,32 +8,35 @@ import Button from "../basic/buttons/Button"; type Props = { onSubmit: () => void; isPlain?: boolean; -} +}; export const DeleteActionButton = ({ onSubmit, isPlain }: Props) => { const { t } = useTranslation(); return ( -
- {isPlain - ?
null} - role="button" - tabIndex={0} - onClick={onSubmit} - className="invisible group-hover:visible" - > - -
- :
- ) -} + ); +}; diff --git a/frontend/src/components/dashboard/DownloadSecretsMenu.tsx b/frontend/src/components/dashboard/DownloadSecretsMenu.tsx index a921798ca..29942abf0 100644 --- a/frontend/src/components/dashboard/DownloadSecretsMenu.tsx +++ b/frontend/src/components/dashboard/DownloadSecretsMenu.tsx @@ -18,7 +18,7 @@ const DownloadSecretMenu = ({ data, env }: { data: SecretDataProps[]; env: strin +
+
+ ); +}; diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 2bf596915..cecae5287 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -56,18 +56,20 @@ export default function NavHeader({ return (
-
+
{currentOrg?.name?.charAt(0)}
-
+ {currentOrg?.name} {isProjectRelated && ( <> -
{currentWorkspace?.name}
+
+ {currentWorkspace?.name} +
)} {isOrganizationRelated && ( @@ -118,7 +120,7 @@ export default function NavHeader({ passHref legacyBehavior href={{ - pathname: "/project/[id]/secrets/v2/[env]", + pathname: "/project/[id]/secrets/[env]", query: { id: router.query.id, env: router.query.env } }} > diff --git a/frontend/src/components/navigation/NavHeaderSecrets.tsx b/frontend/src/components/navigation/NavHeaderSecrets.tsx index ef29ff312..39f0d7183 100644 --- a/frontend/src/components/navigation/NavHeaderSecrets.tsx +++ b/frontend/src/components/navigation/NavHeaderSecrets.tsx @@ -6,7 +6,6 @@ import { useOrganization, useWorkspace } from "@app/context"; import { Select, SelectItem, Tooltip } from "../v2"; - /** * This is the component at the top of almost every page. * It shows how to navigate to a certain page. @@ -39,10 +38,12 @@ export default function NavHeaderSecrets({ }): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); - const router = useRouter() + const router = useRouter(); return ( -
+
{currentOrg?.name?.charAt(0)}
@@ -60,31 +61,39 @@ export default function NavHeaderSecrets({ )} - {pageName === "Secrets" - ? {pageName} - :
{pageName}
} - {currentEnv && - <> - -
- - - -
- } + {pageName === "Secrets" ? ( + + {pageName} + + ) : ( +
{pageName}
+ )} + {currentEnv && ( + <> + +
+ + + +
+ + )}
); } diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx new file mode 100644 index 000000000..0d6b0d061 --- /dev/null +++ b/frontend/src/components/notifications/Notifications.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; +import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify"; + +export type TNotification = { + title?: string; + text: ReactNode; +}; + +export const NotificationContent = ({ title, text }: TNotification) => { + return ( +
+ {title &&
{title}
} +
{text}
+
+ ); +}; + +export const createNotification = ( + myProps: TNotification & { type?: TypeOptions }, + toastProps: ToastOptions = {} +): Id => + toast(, { + position: "bottom-right", + ...toastProps, + theme: "dark", + type: myProps?.type || "info", + }); + +export const NotificationContainer = () => ; diff --git a/frontend/src/components/notifications/index.tsx b/frontend/src/components/notifications/index.tsx new file mode 100644 index 000000000..e752e6d26 --- /dev/null +++ b/frontend/src/components/notifications/index.tsx @@ -0,0 +1 @@ +export { createNotification, NotificationContainer } from "./Notifications"; diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index 40e17577f..b3c7a4f53 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -13,26 +13,24 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children return (
-
-
- -
-
-
Access Restricted
- {children || ( -
- Your role has limited permissions, please
contact your administrator to gain access -
- )} +
+
+
+ +
+
+
Access Restricted
+ {children || ( +
+ Your role has limited permissions, please
contact your administrator to gain + access +
+ )} +
diff --git a/frontend/src/components/signup/CodeInputStep.tsx b/frontend/src/components/signup/CodeInputStep.tsx index de831bbd2..a959f8594 100644 --- a/frontend/src/components/signup/CodeInputStep.tsx +++ b/frontend/src/components/signup/CodeInputStep.tsx @@ -3,9 +3,7 @@ import React, { useState } from "react"; import ReactCodeInput from "react-code-input"; import { useTranslation } from "react-i18next"; -import { - useSendVerificationEmail -} from "@app/hooks/api"; +import { useSendVerificationEmail } from "@app/hooks/api"; import Error from "../basic/Error"; import { Button } from "../v2"; @@ -90,8 +88,8 @@ export default function CodeInputStep({ return (

{t("signup.step2-message")}

-

{email}

-
+

{email}

+
-
+
{codeError && } -
-
+
+
+ > + {" "} + {String(t("signup.verify"))}{" "} +
-
+
{t("signup.step2-resend-alert")} -
+
-

{t("signup.step2-spam-alert")}

+

{t("signup.step2-spam-alert")}

); diff --git a/frontend/src/components/signup/DonwloadBackupPDFStep.tsx b/frontend/src/components/signup/DonwloadBackupPDFStep.tsx index e211c8494..bf9aabcff 100644 --- a/frontend/src/components/signup/DonwloadBackupPDFStep.tsx +++ b/frontend/src/components/signup/DonwloadBackupPDFStep.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useToggle } from "@app/hooks"; import { generateUserBackupKey } from "@app/lib/crypto"; -import { useNotificationContext } from "../context/Notifications/NotificationProvider"; +import { createNotification } from "../notifications"; import { generateBackupPDFAsync } from "../utilities/generateBackupPDF"; import { Button } from "../v2"; @@ -32,7 +32,7 @@ export default function DonwloadBackupPDFStep({ name }: DownloadBackupPDFStepProps): JSX.Element { const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); + const [isLoading, setIsLoading] = useToggle(); const handleBackupKeyGenerate = async () => { @@ -57,19 +57,22 @@ export default function DonwloadBackupPDFStep({ }; return ( -
-

- +

+

+ {t("signup.step4-message")}

-
-
+
+
{t("signup.step4-description1")} {t("signup.step4-description3")}
-
-
+
+
+ isLoading={isLoading} + isDisabled={isLoading} + > + {" "} + {String(t("signup.step1-submit"))}{" "} +
diff --git a/frontend/src/components/signup/InitialSignupStep.tsx b/frontend/src/components/signup/InitialSignupStep.tsx index 7953ff0df..e5c23f333 100644 --- a/frontend/src/components/signup/InitialSignupStep.tsx +++ b/frontend/src/components/signup/InitialSignupStep.tsx @@ -1,9 +1,7 @@ import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { useRouter } from "next/router"; import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faEnvelope } from "@fortawesome/free-regular-svg-icons"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Button } from "../v2"; @@ -14,7 +12,6 @@ export default function InitialSignupStep({ setIsSignupWithEmail: (value: boolean) => void; }) { const { t } = useTranslation(); - const router = useRouter(); return (
@@ -76,17 +73,6 @@ export default function InitialSignupStep({ Continue with Email
-
- -
{t("signup.create-policy")}
diff --git a/frontend/src/components/signup/TeamInviteStep.tsx b/frontend/src/components/signup/TeamInviteStep.tsx index d1cc6ef7d..a06fc3568 100644 --- a/frontend/src/components/signup/TeamInviteStep.tsx +++ b/frontend/src/components/signup/TeamInviteStep.tsx @@ -16,7 +16,7 @@ export default function TeamInviteStep(): JSX.Element { const router = useRouter(); const [emails, setEmails] = useState(""); const { data: serverDetails } = useFetchServerStatus(); - + const { mutateAsync } = useAddUserToOrg(); const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const); @@ -40,55 +40,61 @@ export default function TeamInviteStep(): JSX.Element { }; return ( -
-

+

+

{t("signup.step5-invite-team")}

-

+

{t("signup.step5-subtitle")}

-
+
-
+
Emails