diff --git a/.env.example b/.env.example index 4029cb141..8463fea92 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 # Required -DB_CONNECTION_URI=postgres://infisical:infisical@db:5432/infisical +DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} # JWT # Required secrets to sign JWT tokens @@ -19,10 +19,6 @@ POSTGRES_DB=infisical # 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.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/values.yaml b/.github/values.yaml index 90bf2ce0a..d5f0202b5 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: @@ -50,3 +49,9 @@ ingress: - secretName: letsencrypt-prod hosts: - gamma.infisical.com + +postgresql: + enabled: false + +redis: + enabled: false diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index 29275abd1..7fba6c321 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -72,4 +72,4 @@ jobs: 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/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/.gitignore b/.gitignore index 07322c82f..af3d457e0 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ yarn-error.log* .vscode/* frontend-build + +*.tgz diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1c5db8f00..054359527 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -197,3 +197,14 @@ dockers: - "infisical/cli:{{ .Major }}.{{ .Minor }}" - "infisical/cli:{{ .Major }}" - "infisical/cli:latest" + + - dockerfile: docker/alpine + goos: linux + goarch: arm64 + ids: + - all-other-builds + image_templates: + - "infisical/cli:{{ .Version }}" + - "infisical/cli:{{ .Major }}.{{ .Minor }}" + - "infisical/cli:{{ .Major }}" + - "infisical/cli:latest" 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/Makefile b/Makefile index 2b7f43c85..2bddeec0e 100644 --- a/Makefile +++ b/Makefile @@ -11,4 +11,4 @@ up-prod: docker-compose -f docker-compose.prod.yml up --build down: - docker-compose down + docker compose -f docker-compose.dev.yml down 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..9c558919b 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -21,6 +21,18 @@ module.exports = { tsconfigRootDir: __dirname }, root: true, + overrides: [ + { + files: ["./e2e-test/**/*"], + 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..ba37b5f42 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: "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("/", "dev"); + 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 devImportDetails = { path: "/", envSlug: "dev" }; + const stagingImportDetails = { path: "/", envSlug: "staging" }; + + const createdImport1 = await createSecretImport(devImportDetails.path, devImportDetails.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(devImportDetails.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("/", "dev"); + 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..03e1c2f50 100644 --- a/backend/e2e-test/routes/v3/secrets.spec.ts +++ b/backend/e2e-test/routes/v3/secrets.spec.ts @@ -0,0 +1,1008 @@ +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([]); + }); + } +); + +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..c1c750225 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 { 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 @@ -54,6 +58,7 @@ export default { { 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 1d4aeb2bd..a829cf346 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,17 +9,17 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@aws-sdk/client-secrets-manager": "^3.485.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": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", @@ -29,13 +29,13 @@ "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", - "aws-sdk": "^2.1532.0", - "axios": "^1.6.4", + "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.3.3", + "dotenv": "^16.4.1", + "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", "handlebars": "^4.7.8", "ioredis": "^5.3.2", @@ -45,9 +45,8 @@ "knex": "^3.0.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", - "mysql2": "^3.6.5", + "mysql2": "^3.9.1", "nanoid": "^5.0.4", - "node-cache": "^5.1.2", "nodemailer": "^6.9.9", "ora": "^7.0.1", "passport-github": "^1.1.0", @@ -56,14 +55,14 @@ "pg": "^8.11.3", "picomatch": "^3.0.1", "pino": "^8.16.2", - "posthog-node": "^3.6.0", + "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", @@ -102,7 +101,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": { @@ -356,22 +355,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", @@ -677,49 +660,49 @@ } }, "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==", + "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.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.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" }, @@ -736,112 +719,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-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": { @@ -849,13 +886,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": { @@ -863,19 +919,20 @@ } }, "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": { @@ -883,20 +940,21 @@ } }, "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==", + "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.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-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": { @@ -904,14 +962,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": { @@ -919,16 +977,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": { @@ -936,13 +994,14 @@ } }, "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": { @@ -950,13 +1009,13 @@ } }, "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==", + "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.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": { @@ -964,12 +1023,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": { @@ -977,13 +1036,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": { @@ -991,16 +1050,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": { @@ -1008,14 +1067,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": { @@ -1023,14 +1082,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": { @@ -1038,46 +1098,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": { @@ -1085,11 +1114,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": { @@ -1097,12 +1126,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": { @@ -1121,24 +1151,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": { @@ -1656,21 +1686,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": { @@ -1779,9 +1809,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", @@ -2162,7 +2192,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2175,7 +2204,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "engines": { "node": ">= 8" } @@ -2184,7 +2212,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -3881,6 +3908,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", @@ -4543,13 +4576,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": { @@ -4557,12 +4590,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" }, @@ -4598,9 +4631,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", @@ -4612,9 +4645,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" @@ -4624,12 +4657,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" }, @@ -4700,9 +4734,9 @@ } }, "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" @@ -4787,7 +4821,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" }, @@ -5152,9 +5185,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", @@ -5165,7 +5198,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" @@ -5212,10 +5245,30 @@ "uuid": "dist/bin/uuid" } }, + "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": { + "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.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.4.tgz", - "integrity": "sha512-heJnIs6N4aa1eSthhN9M5ioILu8Wi8vmQW9iHQ9NUvfkJb0lEEDUiIdQNAuBtfUt3FxReaKdpQA5DbmMOqzF/A==", + "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", @@ -5292,6 +5345,29 @@ "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", @@ -5362,7 +5438,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -5412,21 +5487,44 @@ } }, "node_modules/bullmq": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.1.1.tgz", - "integrity": "sha512-j3zbNEQWsyHjpqGWiem2XBfmxAjYcArbwsmGlkM1E9MAVcrqB5hQUsXmyy9gEBAdL+PVotMICr7xTquR4Y2sKQ==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.3.3.tgz", + "integrity": "sha512-Gc/68HxiCHLMPBiGIqtINxcf8HER/5wvBYMY/6x3tFejlvldUBFaAErMTLDv4TnPsTyzNPrfBKmFCEM58uVnJg==", "dependencies": { "cron-parser": "^4.6.0", - "glob": "^8.0.3", + "fast-glob": "^3.3.2", "ioredis": "^5.3.2", "lodash": "^4.17.21", - "msgpackr": "^1.6.2", + "minimatch": "^9.0.3", + "msgpackr": "^1.10.1", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, + "node_modules/bullmq/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/bullmq/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/bundle-require": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.0.2.tgz", @@ -5490,9 +5588,9 @@ } }, "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", @@ -5507,6 +5605,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", @@ -5571,12 +5680,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": { @@ -5899,9 +6025,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" }, @@ -5936,8 +6062,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", @@ -5952,6 +6077,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", @@ -6646,6 +6776,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", @@ -6777,7 +6916,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -6863,9 +7001,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", @@ -6874,10 +7022,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", @@ -6890,6 +7038,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", @@ -6914,7 +7067,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -6953,9 +7105,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", @@ -7366,7 +7518,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -7967,7 +8118,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7998,7 +8148,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -8006,6 +8155,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", @@ -8022,7 +8182,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -8151,6 +8310,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", @@ -8642,6 +8812,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", @@ -8681,9 +8866,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" @@ -8753,7 +8938,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "engines": { "node": ">= 8" } @@ -8770,7 +8954,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -8783,7 +8966,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -8825,7 +9007,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" } @@ -8904,9 +9085,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" } @@ -8967,9 +9148,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.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.1.tgz", + "integrity": "sha512-3njoWAAhGBYy0tWBabqUQcLtczZUxrmmtc2vszQUekg3kTJyZ5/IeLC3Fo04u6y6Iy5Sba7pIIa2P/gs8D3ZeQ==", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -9068,17 +9249,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", @@ -9396,7 +9566,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" }, @@ -9429,6 +9598,28 @@ "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/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9458,6 +9649,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-throttle": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-5.1.0.tgz", + "integrity": "sha512-+N+s2g01w1Zch4D0K3OpnPDqLOKmLcQ4BvIFq3JC0K29R28vUOjWpO+OJZBNt8X9i3pFCksZJZ0YXkUGjaFE6g==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -10096,9 +10298,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" @@ -10345,7 +10547,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -10549,6 +10750,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", @@ -10677,7 +10893,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -11071,6 +11286,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", @@ -11084,6 +11313,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", @@ -11175,7 +11420,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" }, @@ -11428,18 +11672,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" @@ -11449,7 +11693,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -12572,9 +12815,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", @@ -13048,17 +13291,17 @@ } }, "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", @@ -13070,9 +13313,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": { @@ -13591,9 +13834,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 4ec33aa45..ad44dc2da 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,20 +67,20 @@ "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-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": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", @@ -90,13 +90,13 @@ "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", - "aws-sdk": "^2.1532.0", - "axios": "^1.6.4", + "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.3.3", + "dotenv": "^16.4.1", + "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", "handlebars": "^4.7.8", "ioredis": "^5.3.2", @@ -106,9 +106,8 @@ "knex": "^3.0.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", - "mysql2": "^3.6.5", + "mysql2": "^3.9.1", "nanoid": "^5.0.4", - "node-cache": "^5.1.2", "nodemailer": "^6.9.9", "ora": "^7.0.1", "passport-github": "^1.1.0", @@ -117,13 +116,13 @@ "pg": "^8.11.3", "picomatch": "^3.0.1", "pino": "^8.16.2", - "posthog-node": "^3.6.0", + "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" } -} \ No newline at end of file +} 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 68330613c..8c913991f 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -44,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("::")) { @@ -100,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) { @@ -120,6 +121,7 @@ const main = async () => { .split("_") .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. @@ -134,8 +136,8 @@ 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>>; ` ); } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index ef850bb8c..bd7a09d86 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -6,6 +6,7 @@ import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-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"; @@ -105,6 +106,7 @@ declare module "fastify" { secretRotation: TSecretRotationServiceFactory; snapshot: TSecretSnapshotServiceFactory; saml: TSamlConfigServiceFactory; + scim: TScimServiceFactory; auditLog: TAuditLogServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5bf563810..5b5961558 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -83,6 +83,9 @@ import { TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, + TScimTokens, + TScimTokensInsert, + TScimTokensUpdate, TSecretApprovalPolicies, TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, @@ -262,6 +265,7 @@ declare module "knex/types/tables" { TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate >; + [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, TSecretApprovalPoliciesInsert, 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/knexfile.ts b/backend/src/db/knexfile.ts index 73eb507f4..532ab496d 100644 --- a/backend/src/db/knexfile.ts +++ b/backend/src/db/knexfile.ts @@ -10,10 +10,22 @@ dotenv.config({ path: path.join(__dirname, "../../../.env.migration"), debug: true }); +dotenv.config({ + path: path.join(__dirname, "../../../.env"), + debug: true +}); export default { development: { client: "postgres", - connection: process.env.DB_CONNECTION_URI, + connection: { + connectionString: process.env.DB_CONNECTION_URI, + 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 +39,15 @@ export default { }, production: { client: "postgres", - connection: process.env.DB_CONNECTION_URI, + connection: { + connectionString: process.env.DB_CONNECTION_URI, + 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/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/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-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/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/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-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-memberships.ts b/backend/src/db/schemas/identity-project-memberships.ts index 866324c8b..276c9581e 100644 --- a/backend/src/db/schemas/identity-project-memberships.ts +++ b/backend/src/db/schemas/identity-project-memberships.ts @@ -18,5 +18,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..b330c90b7 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -26,6 +26,7 @@ export * from "./project-memberships"; export * from "./project-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"; 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..cf8c88154 100644 --- a/backend/src/db/schemas/integrations.ts +++ b/backend/src/db/schemas/integrations.ts @@ -31,5 +31,5 @@ export const IntegrationsSchema = z.object({ }); 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/models.ts b/backend/src/db/schemas/models.ts index 4ef943bbe..abfb67949 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -40,6 +40,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", + ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalRequest = "secret_approval_requests", @@ -111,6 +112,17 @@ export enum SecretType { Personal = "personal" } +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" } 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 087a1b7e0..f2933af86 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -14,9 +14,10 @@ export const OrganizationsSchema = z.object({ slug: z.string(), createdAt: z.date(), updatedAt: z.date(), - authEnforced: z.boolean().default(false).nullable().optional() + 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..8576a318e 100644 --- a/backend/src/db/schemas/project-memberships.ts +++ b/backend/src/db/schemas/project-memberships.ts @@ -18,5 +18,5 @@ export const ProjectMembershipsSchema = z.object({ }); 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/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 6891d8add..67171469a 100644 --- a/backend/src/db/schemas/saml-configs.ts +++ b/backend/src/db/schemas/saml-configs.ts @@ -27,5 +27,5 @@ export const SamlConfigsSchema = z.object({ }); 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-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..958fed0ab 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -12,9 +12,11 @@ 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") }); 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-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/users.ts b/backend/src/db/schemas/users.ts index 4a29de510..8f9758b4f 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -19,9 +19,10 @@ 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) }); 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..6406e44ab 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,21 @@ 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", + 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 +36,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 +92,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 +100,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 +126,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..5a5ff5404 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -9,12 +9,18 @@ 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, email: seedData1.email, superAdmin: true, @@ -48,7 +54,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..fea71b557 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 { OrgMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; +import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ { name: "Development", slug: "dev" }, @@ -20,21 +24,32 @@ 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 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 +61,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..149fba40a --- /dev/null +++ b/backend/src/db/seeds/4-machine-identity.ts @@ -0,0 +1,83 @@ +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 + } + ]); + + await knex(TableName.IdentityProjectMembership).insert({ + identityId: seedData1.machineIdentity.id, + role: ProjectMembershipRole.Admin, + projectId: seedData1.project.id + }); +} diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2ed439316..fb92aa3c5 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -3,6 +3,7 @@ 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"; @@ -33,6 +34,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { prefix: "/secret-rotation-providers" }); await server.register(registerSamlRouter, { prefix: "/sso" }); + await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 00dd09c33..6ed079a61 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -27,6 +27,7 @@ type TSAMLConfig = { cert: string; audience: string; wantAuthnResponseSigned?: boolean; + wantAssertionsSigned?: boolean; disableRequestedAuthnContext?: boolean; }; @@ -82,6 +83,10 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { 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) { 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..80eed5231 --- /dev/null +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -0,0 +1,331 @@ +import { z } from "zod"; + +import { ScimTokensSchema } from "@app/db/schemas"; +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", + 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, + description: req.body.description, + ttlDays: req.body.ttlDays + }); + + return { scimToken }; + } + }); + + server.route({ + url: "/scim-tokens", + method: "GET", + 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, + actorOrgId: req.permission.orgId, + orgId: req.query.organizationId + }); + + return { scimTokens }; + } + }); + + server.route({ + url: "/scim-tokens/:scimTokenId", + method: "DELETE", + 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, + 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().email(), + 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({ + offset: req.query.startIndex, + limit: req.query.count, + filter: req.query.filter, + orgId: req.permission.orgId as string + }); + return users; + } + }); + + server.route({ + url: "/Users/:userId", + method: "GET", + schema: { + params: z.object({ + userId: 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().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.getScimUser({ + userId: req.params.userId, + orgId: req.permission.orgId as string + }); + return user; + } + }); + + server.route({ + url: "/Users", + method: "POST", + schema: { + body: z.object({ + schemas: z.array(z.string()), + userName: z.string().trim().email(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + // emails: z.array( // optional? + // z.object({ + // primary: z.boolean(), + // value: z.string().email(), + // type: 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().email(), + 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.createScimUser({ + email: req.body.userName, + firstName: req.body.name.givenName, + lastName: req.body.name.familyName, + orgId: req.permission.orgId as string + }); + + return user; + } + }); + + server.route({ + url: "/Users/:userId", + method: "PATCH", + schema: { + params: z.object({ + userId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + Operations: z.array( + z.object({ + op: z.string().trim(), + path: z.string().trim().optional(), + value: z.union([ + z.object({ + active: z.boolean() + }), + z.string().trim() + ]) + }) + ) + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.updateScimUser({ + userId: req.params.userId, + orgId: req.permission.orgId as string, + operations: req.body.Operations + }); + return user; + } + }); + + server.route({ + url: "/Users/:userId", + method: "PUT", + schema: { + params: z.object({ + userId: 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({ + userId: req.params.userId, + orgId: req.permission.orgId as string, + active: req.body.active + }); + return user; + } + }); +}; 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..afffd463d 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -24,7 +24,7 @@ export const auditLogQueueServiceFactory = ({ const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { removeOnFail: { - count: 5 + count: 3 }, removeOnComplete: true }); @@ -46,6 +46,7 @@ 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({ actor: actor.type, actorMetadata: actor.metadata, 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 1c7868fc2..c4d4aabc0 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -58,6 +58,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..2aaaf7562 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; @@ -105,6 +105,8 @@ interface IdentityActorMetadata { name: string; } +interface ScimClientActorMetadata {} + export interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -120,7 +122,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; 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..e404aaace --- /dev/null +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -0,0 +1,27 @@ +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, + 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..9b1d4c203 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,6 +24,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogs: false, auditLogsRetentionDays: 0, samlSSO: false, + scim: 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 6d97b537c..663e9d879 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -5,8 +5,8 @@ // 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 { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -39,6 +39,7 @@ type TLicenseServiceFactoryDep = { orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; + keyStore: Pick; }; export type TLicenseServiceFactory = ReturnType; @@ -46,12 +47,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 +82,7 @@ export const licenseServiceFactory = ({ orgDAL, permissionService, licenseDAL }: isValidLicense = true; return; } + if (appCfg.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicence(); if (token) { @@ -100,22 +108,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,15 +130,20 @@ 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); } }; @@ -166,7 +178,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 }); @@ -215,7 +227,7 @@ 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 }; }; @@ -505,6 +517,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..354714489 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -25,6 +25,7 @@ export type TFeatureSet = { auditLogs: false; auditLogsRetentionDays: 0; samlSSO: false; + scim: 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..e527f1a4a 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -16,6 +16,7 @@ export enum OrgPermissionSubjects { Settings = "settings", IncidentAccount = "incident-contact", Sso = "sso", + Scim = "scim", Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity" @@ -29,6 +30,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity]; @@ -69,6 +71,11 @@ 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.Billing); can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 4735312e4..67db2473c 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -177,6 +177,8 @@ export const permissionServiceFactory = ({ const getServiceTokenProjectPermission = async (serviceTokenId: string, projectId: string) => { const serviceToken = await serviceTokenDAL.findById(serviceTokenId); + if (!serviceToken) throw new BadRequestError({ message: "Service token not found" }); + if (serviceToken.projectId !== projectId) throw new UnauthorizedError({ message: "Failed to find service authorization for given project" 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 767729179..b541e16be 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -195,7 +195,7 @@ export const samlConfigServiceFactory = ({ updateQuery.certTag = certTag; } const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery); - await orgDAL.updateById(orgId, { authEnforced: false }); + await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false }); return ssoConfig; }; @@ -338,7 +338,8 @@ export const samlConfigServiceFactory = ({ email, firstName, lastName, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isGhost: false }, tx ); 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 a2c2c63c0..9d13cb342 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -4,7 +4,8 @@ import { 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" } export type TCreateSamlCfgDTO = { 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..b5f35c9ee --- /dev/null +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -0,0 +1,58 @@ +import { TListScimUsers, TScimUser } from "./scim-types"; + +export const buildScimUserList = ({ + scimUsers, + offset, + limit +}: { + scimUsers: TScimUser[]; + offset: number; + limit: number; +}): TListScimUsers => { + return { + Resources: scimUsers, + itemsPerPage: limit, + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + startIndex: offset, + totalResults: scimUsers.length + }; +}; + +export const buildScimUser = ({ + userId, + firstName, + lastName, + email, + active +}: { + userId: string; + firstName: string; + lastName: string; + email: string; + active: boolean; +}): TScimUser => { + return { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + id: userId, + userName: email, + displayName: `${firstName} ${lastName}`, + name: { + givenName: firstName, + middleName: null, + familyName: lastName + }, + emails: [ + { + primary: true, + value: email, + type: "work" + } + ], + active, + groups: [], + meta: { + resourceType: "User", + location: null + } + }; +}; 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..33b2f2c84 --- /dev/null +++ b/backend/src/ee/services/scim/scim-service.ts @@ -0,0 +1,431 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +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 { TOrgPermission } from "@app/lib/types"; +import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { deleteOrgMembership } from "@app/services/org/org-fns"; +import { TProjectDALFactory } from "@app/services/project/project-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 { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { buildScimUser, buildScimUserList } from "./scim-fns"; +import { + TCreateScimTokenDTO, + TCreateScimUserDTO, + TDeleteScimTokenDTO, + TGetScimUserDTO, + TListScimUsers, + TListScimUsersDTO, + TReplaceScimUserDTO, + TScimTokenJwtPayload, + TUpdateScimUserDTO +} from "./scim-types"; + +type TScimServiceFactoryDep = { + scimDAL: Pick; + userDAL: Pick; + orgDAL: Pick< + TOrgDALFactory, + "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" + >; + projectDAL: Pick; + projectMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; + smtpService: TSmtpService; +}; + +export type TScimServiceFactory = ReturnType; + +export const scimServiceFactory = ({ + licenseService, + scimDAL, + userDAL, + orgDAL, + projectDAL, + projectMembershipDAL, + permissionService, + smtpService +}: TScimServiceFactoryDep) => { + const createScimToken = async ({ actor, actorId, actorOrgId, orgId, description, ttlDays }: TCreateScimTokenDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, 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, orgId }: TOrgPermission) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, 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, 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, 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 ({ offset, 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 }; + }; + + const findOpts = { + ...(offset && { offset }), + ...(limit && { limit }) + }; + + const users = await orgDAL.findMembership( + { + orgId, + ...parseFilter(filter) + }, + findOpts + ); + + const scimUsers = users.map(({ userId, firstName, lastName, email }) => + buildScimUser({ + userId: userId ?? "", + firstName: firstName ?? "", + lastName: lastName ?? "", + email, + active: true + }) + ); + + return buildScimUserList({ + scimUsers, + offset, + limit + }); + }; + + const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + userId, + 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({ + userId: membership.userId as string, + firstName: membership.firstName as string, + lastName: membership.lastName as string, + email: membership.email, + active: true + }); + }; + + const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => { + 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 user = await userDAL.findOne({ + email + }); + + if (user) { + await userDAL.transaction(async (tx) => { + const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx }); + if (orgMembership) + throw new ScimRequestError({ + detail: "User already exists in the database", + status: 409 + }); + + if (!orgMembership) { + await orgDAL.createMembership( + { + userId: user.id, + orgId, + inviteEmail: email, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited + }, + tx + ); + } + }); + } else { + user = await userDAL.transaction(async (tx) => { + const newUser = await userDAL.create( + { + email, + firstName, + lastName, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + + await orgDAL.createMembership( + { + inviteEmail: email, + orgId, + userId: newUser.id, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited + }, + tx + ); + return newUser; + }); + } + + const appCfg = getConfig(); + 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({ + userId: user.id, + firstName: user.firstName as string, + lastName: user.lastName as string, + email: user.email, + active: true + }); + }; + + const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + userId, + 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 deleteOrgMembership({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectDAL, + projectMembershipDAL + }); + } + + return buildScimUser({ + userId: membership.userId as string, + firstName: membership.firstName as string, + lastName: membership.lastName as string, + email: membership.email, + active + }); + }; + + const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + userId, + 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) { + // tx + await deleteOrgMembership({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectDAL, + projectMembershipDAL + }); + } + + return buildScimUser({ + userId: membership.userId as string, + firstName: membership.firstName as string, + lastName: membership.lastName as string, + email: membership.email, + active + }); + }; + + 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, + 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..9751d591f --- /dev/null +++ b/backend/src/ee/services/scim/scim-types.ts @@ -0,0 +1,87 @@ +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 = { + offset: 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 = { + userId: string; + orgId: string; +}; + +export type TCreateScimUserDTO = { + email: string; + firstName: string; + lastName: string; + orgId: string; +}; + +export type TUpdateScimUserDTO = { + userId: string; + orgId: string; + operations: { + op: string; + path?: string; + value?: + | string + | { + active: boolean; + }; + }[]; +}; + +export type TReplaceScimUserDTO = { + userId: string; + active: boolean; + 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; + }; +}; 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 ef10db804..c53136f13 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 @@ -11,12 +11,15 @@ 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 { 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 { 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"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; @@ -43,10 +46,13 @@ 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; secretService: Pick< TSecretServiceFactory, | "fnSecretBulkInsert" @@ -62,11 +68,14 @@ export type TSecretApprovalRequestServiceFactory = ReturnType id), version: 1, type: SecretType.Shared - })) + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) : []; const updatedSecrets = secretUpdationCommits.length @@ -434,6 +447,8 @@ export const secretApprovalRequestServiceFactory = ({ 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" }); const folderId = folder.id; @@ -450,7 +465,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: createdSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); commits.push( @@ -477,7 +493,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: updatedSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); // now find any secret that needs to update its name @@ -487,7 +504,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: nameUpdatedSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secsGroupedByBlindIndex = groupBy(secretsToBeUpdated, (el) => el.secretBlindIndex as string); @@ -526,7 +544,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.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index 9e69f0a8f..330f6beca 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 @@ -240,7 +240,7 @@ export const secretRotationQueueFactory = ({ ); }); - telemetryService.sendPostHogEvents({ + await telemetryService.sendPostHogEvents({ event: PostHogEventTypes.SecretRotated, distinctId: "", properties: { 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..d8f4e647e 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 @@ -158,7 +158,7 @@ export const secretScanningQueueFactory = ({ }); } - telemetryService.sendPostHogEvents({ + await telemetryService.sendPostHogEvents({ event: PostHogEventTypes.SecretScannerPush, distinctId: repository.fullName, properties: { @@ -228,7 +228,7 @@ export const secretScanningQueueFactory = ({ }); } - telemetryService.sendPostHogEvents({ + await telemetryService.sendPostHogEvents({ event: PostHogEventTypes.SecretScannerFull, distinctId: repository.fullName, properties: { 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/config/env.ts b/backend/src/lib/config/env.ts index 4542c7fc3..655e402df 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -94,14 +94,17 @@ 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()), + + // GENERIC STANDALONE_MODE: z .enum(["true", "false"]) .transform((val) => val === "true") - .optional() + .optional(), + INFISICAL_CLOUD: zodStrBool.default("false") }) .transform((data) => ({ ...data, 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..9d7f4e886 100644 --- a/backend/src/lib/crypto/index.ts +++ b/backend/src/lib/crypto/index.ts @@ -1,12 +1,20 @@ export { buildSecretBlindIndexFromName, createSecretBlindIndex, + decodeBase64, decryptAsymmetric, decryptSymmetric, decryptSymmetric128BitHexKeyUTF8, + encodeBase64, encryptAsymmetric, encryptSymmetric, encryptSymmetric128BitHexKeyUTF8, generateAsymmetricKeyPair } from "./encryption"; +export { + decryptIntegrationAuths, + decryptSecretApprovals, + decryptSecrets, + decryptSecretVersions +} from "./secret-encryption"; export { generateSrpServerKey, srpCheckClientProof } from "./srp"; 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/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..d93244bbd 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -58,3 +58,35 @@ export class BadRequestError extends Error { 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/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..45c135b77 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,12 @@ 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" } export enum QueueJobs { @@ -24,8 +27,10 @@ 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" } export type TQueueJobTypes = { @@ -64,6 +69,23 @@ export type TQueueJobTypes = { 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; + }; }; export type TQueueServiceFactory = ReturnType; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index ca6b9003a..556a88d7c 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"; @@ -31,13 +32,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 +72,7 @@ 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(registerRoutes, { smtp, queue, db, keyStore }); if (appCfg.isProductionMode) { await server.register(registerExternalNextjs, { diff --git a/backend/src/server/lib/telemetry.ts b/backend/src/server/lib/telemetry.ts new file mode 100644 index 000000000..5169b0e97 --- /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.email; + } + 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..de352a9bc 100644 --- a/backend/src/server/plugins/audit-log.ts +++ b/backend/src/server/plugins/audit-log.ts @@ -63,6 +63,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 7abcd073c..3a0a0ab39 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -3,6 +3,7 @@ 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"; @@ -26,7 +27,7 @@ export type TAuthMode = } | { authMode: AuthMode.SERVICE_TOKEN; - serviceToken: TServiceTokens; + serviceToken: TServiceTokens & { createdByEmail: string }; actor: ActorType.SERVICE; serviceTokenId: string; } @@ -35,6 +36,12 @@ export type TAuthMode = actor: ActorType.IDENTITY; identityId: string; identityName: string; + } + | { + authMode: AuthMode.SCIM_TOKEN; + actor: ActorType.SCIM_CLIENT; + scimTokenId: string; + orgId: string; }; const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { @@ -55,6 +62,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { } const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload; + switch (decodedToken.authTokenType) { case AuthTokenType.ACCESS_TOKEN: return { @@ -70,6 +78,12 @@ 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; } @@ -113,6 +127,11 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user }; break; } + case AuthMode.SCIM_TOKEN: { + const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); + req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId }; + break; + } default: throw new UnauthorizedError({ name: "Unknown token strategy" }); } diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 572814d64..2d61647e8 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -14,6 +14,8 @@ export const injectPermission = fp(async (server) => { req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId }; } else if (req.auth.actor === ActorType.SERVICE) { req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId }; + } else if (req.auth.actor === ActorType.SCIM_CLIENT) { + req.permission = { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId }; } }); }); 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/routes/index.ts b/backend/src/server/routes/index.ts index 28f65ea99..caecf852c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -11,6 +11,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal import { permissionServiceFactory } from "@app/ee/services/permission/permission-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,6 +34,7 @@ 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 { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; @@ -63,6 +66,7 @@ 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 { 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"; @@ -93,6 +97,8 @@ 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"; @@ -109,7 +115,12 @@ 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" }); @@ -155,6 +166,8 @@ export const registerRoutes = async ( const auditLogDAL = auditLogDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); + const scimDAL = scimDALFactory(db); + const telemetryDAL = telemetryDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -181,13 +194,14 @@ export const registerRoutes = async ( projectRoleDAL, serviceTokenDAL }); - 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, @@ -210,8 +224,27 @@ export const registerRoutes = async ( samlConfigDAL, licenseService }); + const scimService = scimServiceFactory({ + licenseService, + scimDAL, + userDAL, + orgDAL, + projectDAL, + projectMembershipDAL, + permissionService, + smtpService + }); + + const telemetryService = telemetryServiceFactory({ + keyStore, + licenseService + }); + const telemetryQueue = telemetryQueueServiceFactory({ + keyStore, + telemetryDAL, + queueService + }); - const telemetryService = telemetryServiceFactory(); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); const userService = userServiceFactory({ userDAL }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService }); @@ -230,6 +263,8 @@ export const registerRoutes = async ( incidentContactDAL, tokenService, projectDAL, + projectMembershipDAL, + projectKeyDAL, smtpService, userDAL, orgBotDAL @@ -248,7 +283,8 @@ export const registerRoutes = async ( userDAL, authService: loginService, serverCfgDAL: superAdminDAL, - orgService + orgService, + keyStore }); const apiKeyService = apiKeyServiceFactory({ apiKeyDAL, userDAL }); @@ -266,19 +302,13 @@ 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, projectDAL, permissionService, + projectBotDAL, orgDAL, userDAL, smtpService, @@ -286,6 +316,46 @@ export const registerRoutes = async ( projectRoleDAL, licenseService }); + 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 + }); + + const projectService = projectServiceFactory({ + permissionService, + projectDAL, + projectQueue: projectQueueService, + secretBlindIndexDAL, + identityProjectDAL, + identityOrgMembershipDAL, + projectBotDAL, + projectKeyDAL, + userDAL, + projectEnvDAL, + orgService, + projectMembershipDAL, + folderDAL, + licenseService + }); const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, @@ -293,11 +363,7 @@ export const registerRoutes = async ( projectDAL, folderDAL }); - const projectKeyService = projectKeyServiceFactory({ - permissionService, - projectKeyDAL, - projectMembershipDAL - }); + const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -332,9 +398,9 @@ export const registerRoutes = async ( folderDAL, permissionService, secretImportDAL, + projectDAL, secretDAL }); - const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL }); const integrationAuthService = integrationAuthServiceFactory({ integrationAuthDAL, integrationDAL, @@ -355,7 +421,11 @@ export const registerRoutes = async ( orgDAL, projectMembershipDAL, smtpService, - projectDAL + projectDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, }); const secretBlindIndexService = secretBlindIndexServiceFactory({ permissionService, @@ -368,6 +438,7 @@ export const registerRoutes = async ( secretVersionTagDAL, secretBlindIndexDAL, permissionService, + projectDAL, secretDAL, secretTagDAL, snapshotService, @@ -378,14 +449,17 @@ export const registerRoutes = async ( const sarService = secretApprovalRequestServiceFactory({ permissionService, folderDAL, + secretDAL, secretTagDAL, secretApprovalRequestSecretDAL: sarSecretDAL, secretApprovalRequestReviewerDAL: sarReviewerDAL, + projectDAL, secretVersionDAL, secretBlindIndexDAL, secretApprovalRequestDAL, secretService, snapshotService, + secretVersionTagDAL, secretQueueService }); const secretRotationQueue = secretRotationQueueFactory({ @@ -444,9 +518,13 @@ export const registerRoutes = async ( }); 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, @@ -486,6 +564,7 @@ export const registerRoutes = async ( secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, + scim: scimService, secretBlindIndex: secretBlindIndexService, telemetry: telemetryService }); @@ -537,4 +616,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/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index da13d5e00..38f948851 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,6 +1,6 @@ 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 { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; @@ -16,7 +16,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - config: SuperAdminSchema + config: SuperAdminSchema.omit({ createdAt: true, updatedAt: true }) }) } }, @@ -31,7 +31,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { method: "PATCH", schema: { body: z.object({ - allowSignUp: z.boolean().optional() + allowSignUp: z.boolean().optional(), + allowedSignUpDomain: z.string().optional().nullable() }), response: { 200: z.object({ @@ -72,6 +73,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { 200: z.object({ message: z.string(), user: UsersSchema, + organization: OrganizationsSchema, token: z.string(), new: z.string() }) @@ -82,13 +84,13 @@ 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, properties: { @@ -109,6 +111,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { message: "Successfully set up admin account", user: user.user, token: token.access, + organization, new: "123" }; } diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 4ca4ef324..0ec27a98b 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -2,14 +2,16 @@ import { z } from "zod"; import { IdentitiesSchema, OrgMembershipRole } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +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]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Create identity", security: [ @@ -49,6 +51,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 }; } }); @@ -56,7 +69,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "PATCH", url: "/:identityId", - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Update identity", security: [ @@ -105,7 +118,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:identityId", - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Delete identity", security: [ diff --git a/backend/src/server/routes/v1/identity-ua.ts b/backend/src/server/routes/v1/identity-ua.ts index 11b8e0e8d..4499a88e7 100644 --- a/backend/src/server/routes/v1/identity-ua.ts +++ b/backend/src/server/routes/v1/identity-ua.ts @@ -39,11 +39,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: { diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 744ba6ebf..fbc68d974 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -48,6 +48,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-router.ts b/backend/src/server/routes/v1/integration-router.ts index ab0ba36eb..b5d3b28d5 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -3,8 +3,10 @@ import { z } from "zod"; import { IntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { removeTrailingSlash, shake } from "@app/lib/fn"; +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, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { server.route({ @@ -30,6 +32,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { .object({ secretPrefix: z.string().optional(), secretSuffix: z.string().optional(), + syncBehavior: z.string().optional(), secretGCPLabel: z .object({ labelName: z.string(), @@ -53,28 +56,40 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { 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: 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 + metadata: createIntegrationEventProperty + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IntegrationCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + ...createIntegrationEventProperty, + projectId: integrationAuth.projectId, + ...req.auditLogInfo } }); 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 0d1fe5070..5956b53df 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,8 +1,10 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; +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({ @@ -30,6 +32,15 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId }); + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.UserOrgInvitation, + distinctId: getTelemetryDistinctId(req), + properties: { + inviteeEmail: req.body.inviteeEmail, + ...req.auditLogInfo + } + }); + return { completeInviteLink, message: `Send an invite link to ${req.body.inviteeEmail}` diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index bfda652f0..fcc1a86e0 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -87,13 +87,15 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { schema: { params: z.object({ organizationId: z.string().trim() }), body: z.object({ - name: z.string().trim().optional(), + name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), slug: z .string() .trim() - .regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens") + .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() + authEnforced: z.boolean().optional(), + scimEnabled: z.boolean().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 09de72233..afa2135c0 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { OrgMembershipsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + OrgMembershipsSchema, + ProjectMembershipRole, + ProjectMembershipsSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -80,7 +86,10 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, - members: req.body.members + members: req.body.members.map((member) => ({ + ...member, + projectRole: ProjectMembershipRole.Member + })) }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index f0faaa0f4..026b16f1f 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -2,13 +2,11 @@ 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 { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -119,7 +117,7 @@ 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({ actorId: req.permission.id, @@ -171,7 +169,7 @@ 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({ actorId: req.permission.id, @@ -216,6 +214,41 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/:workspaceId", + method: "PATCH", + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), + autoCapitalization: z.boolean().optional() + }), + response: { + 200: z.object({ + workspace: ProjectsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const workspace = await server.services.project.updateProject({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + update: { + name: req.body.name, + autoCapitalization: req.body.autoCapitalization + } + }); + return { + workspace + }; + } + }); + server.route({ url: "/:workspaceId/auto-capitalization", method: "POST", @@ -249,48 +282,6 @@ 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, - actorOrgId: req.permission.orgId, - 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", 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/v2/index.ts b/backend/src/server/routes/v2/index.ts index 1f423c084..deebbc981 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -2,6 +2,7 @@ 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 +22,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (projectServer) => { await projectServer.register(registerProjectRouter); await projectServer.register(registerIdentityProjectRouter); + await projectServer.register(registerProjectMembershipRouter); }, { prefix: "/workspace" } ); 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..417fcb44c --- /dev/null +++ b/backend/src/server/routes/v2/project-membership-router.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +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", + schema: { + params: z.object({ + projectId: z.string().describe("The ID of the project.") + }), + body: z.object({ + emails: z.string().email().array().describe("Emails of the users to add to the project.") + }), + 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, + actorId: req.permission.id, + actor: req.permission.type, + emails: req.body.emails + }); + + 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", + schema: { + params: z.object({ + projectId: z.string().describe("The ID of the project.") + }), + + body: z.object({ + emails: z.string().email().array().describe("Emails of the users to remove from the project.") + }), + 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, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + emails: req.body.emails + }); + + 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 d38efdbb9..fe1254b2b 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,11 +1,23 @@ +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 { authRateLimit } 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"; + +const projectWithEnv = ProjectsSchema.merge( + z.object({ + _id: z.string(), + environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() + }) +); export const registerProjectRouter = async (server: FastifyZodProvider) => { + /* Get project key */ server.route({ url: "/:workspaceId/encrypted-key", method: "GET", @@ -34,8 +46,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { const key = await server.services.projectKey.getLatestProjectKey({ actor: req.permission.type, actorId: req.permission.id, - projectId: req.params.workspaceId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId }); await server.services.auditLog.createAuditLog({ @@ -52,4 +64,107 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return key; } }); + + /* Start upgrade of a project */ + server.route({ + url: "/:projectId/upgrade", + method: "POST", + schema: { + params: z.object({ + projectId: z.string().trim() + }), + + body: z.object({ + userPrivateKey: z.string().trim() + }), + response: { + 200: z.void() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + await server.services.project.upgradeProject({ + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.projectId, + userPrivateKey: req.body.userPrivateKey + }); + } + }); + + /* Get upgrade status of project */ + server.route({ + url: "/:projectId/upgrade/status", + method: "GET", + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + status: z.string().nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const status = await server.services.project.getProjectUpgradeStatus({ + projectId: req.params.projectId, + actor: req.permission.type, + actorId: req.permission.id + }); + + return { status }; + } + }); + + /* Create new project */ + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + projectName: z.string().trim(), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional(), + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + project: projectWithEnv + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const project = await server.services.project.createProject({ + actorId: req.permission.id, + actor: req.permission.type, + orgId: req.body.organizationId, + workspaceName: req.body.projectName, + slug: req.body.slug + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.ProjectCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + orgId: req.body.organizationId, + name: project.name, + ...req.auditLogInfo + } + }); + + return { project }; + } + }); }; diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 8fc114e76..97bc3d864 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -197,7 +197,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }) } }, - 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 }; diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 5f47ae3c5..6b3dd6041 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"; @@ -13,6 +12,7 @@ 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 { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; +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"; @@ -20,19 +20,6 @@ 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", @@ -108,9 +95,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, @@ -198,9 +185,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, @@ -274,9 +261,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, @@ -349,9 +336,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, @@ -419,9 +406,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, @@ -525,9 +512,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, @@ -602,9 +589,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, @@ -765,9 +752,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, @@ -947,9 +934,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, @@ -1065,9 +1052,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, @@ -1092,7 +1079,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(), @@ -1139,7 +1125,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Create]: inputSecrets.filter(({ type }) => type === "shared") + [CommitType.Create]: inputSecrets } }); @@ -1186,9 +1172,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, @@ -1306,9 +1292,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, @@ -1414,9 +1400,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, diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 209e86ac7..24387d2f5 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 }; } @@ -90,6 +117,13 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { if (!userAgent) throw new Error("user agent header is required"); const appCfg = getConfig(); + const serverCfg = await getServerCfg(); + if (!serverCfg.allowSignUp) { + throw new BadRequestError({ + message: "Sign up is disabled" + }); + } + const { user, accessToken, refreshToken } = await server.services.signup.completeEmailAccountSignup({ ...req.body, ip: req.realIp, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6e4d60bba..11ce57735 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -4,6 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; +import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; @@ -261,21 +262,27 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ - email, - firstName, - lastName, - authMethod, - callbackPort, - isSignupAllowed - }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { let user = await userDAL.findUserByEmail(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({ email, firstName, lastName, authMethods: [authMethod], isGhost: false }); } const isLinkingRequired = !user?.authMethods?.includes(authMethod); const isUserCompleted = user.isAccepted; diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 67f640bc9..86af5a5f9 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -28,5 +28,4 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; - isSignupAllowed?: boolean; }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 021ca7070..61fcfe3ce 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -50,7 +50,7 @@ export const authSignupServiceFactory = ({ 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], email, isGhost: false }); } if (!user) throw new Error("Failed to create user"); diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index bea0dbe10..b051952e7 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -17,21 +17,24 @@ 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" } export type AuthModeJwtTokenPayload = { diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 3b73da6d4..d375a8fa5 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -54,6 +54,8 @@ export const identityUaServiceFactory = ({ const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) throw new UnauthorizedError(); + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + checkIPAgainstBlocklist({ ipAddress: ip, trustedIps: identityUa.clientSecretTrustedIps as TIp[] @@ -131,7 +133,7 @@ export const identityUaServiceFactory = ({ } ); - return { accessToken, identityUa, validClientSecretInfo, identityAccessToken }; + return { accessToken, identityUa, validClientSecretInfo, identityAccessToken, identityMembershipOrg }; }; const attachUa = async ({ 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 60f3d561c..aa2354747 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -43,6 +43,7 @@ import { import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list"; import { getTeams } from "./integration-team"; import { exchangeCode, exchangeRefresh } from "./integration-token"; +import { access } from "node:fs"; type TIntegrationAuthServiceFactoryDep = { integrationAuthDAL: TIntegrationAuthDALFactory; @@ -591,6 +592,7 @@ export const integrationAuthServiceFactory = ({ 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`, { @@ -601,7 +603,7 @@ export const integrationAuthServiceFactory = ({ } } ); - + return data.map(({ app: { id: appId }, stage, pipeline: { id: pipelineId, name } }) => ({ app: { appId }, stage, diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index d3cabbcb5..816d6f409 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -37,6 +37,12 @@ export enum IntegrationType { OAUTH2 = "oauth2" } +export enum IntegrationSyncBehavior { + OVERWRITE_TARGET = "overwrite-target", + PREFER_TARGET = "prefer-target", + PREFER_SOURCE = "prefer-source" +} + 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..7e613ceab 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -20,11 +20,20 @@ import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; import { z } from "zod"; -import { TIntegrationAuths, TIntegrations } from "@app/db/schemas"; +import { TIntegrationAuths, TIntegrations, SecretType } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; -import { Integrations, IntegrationUrls } from "./integration-list"; +import { Integrations, IntegrationUrls, IntegrationSyncBehavior } from "./integration-list"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; + +import { createManySecretsRawHelper } from "../secret/secret-fns"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -441,16 +450,19 @@ 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, @@ -514,12 +526,6 @@ const syncSecretsAWSParameterStore = async ({ } }) ); - - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); }; /** @@ -541,12 +547,6 @@ const syncSecretsAWSSecretManager = async ({ try { if (!accessId) return; - AWS.config.update({ - region: integration.region as string, - accessKeyId: accessId, - secretAccessKey: accessToken - }); - secretsManager = new SecretsManagerClient({ region: integration.region as string, credentials: { @@ -575,12 +575,6 @@ const syncSecretsAWSSecretManager = async ({ }) ); } - - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); } catch (err) { if (err instanceof ResourceNotFoundException && secretsManager) { await secretsManager.send( @@ -590,26 +584,45 @@ const syncSecretsAWSSecretManager = async ({ }) ); } - AWS.config.update({ - region: undefined, - accessKeyId: undefined, - secretAccessKey: undefined - }); } }; /** * Sync/push [secrets] to Heroku app named [integration.app] + * server.services... */ const syncSecretsHeroku = async ({ + projectDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + botKey, // TODO: consider getting botKey inside this fn + projectId, + environment, + secretPath, integration, secrets, accessToken }: { + projectDAL: TProjectDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; + botKey: string; + projectId: string; + environment: string; + secretPath: string; integration: TIntegrations; secrets: Record; accessToken: string; }) => { + const herokuSecrets = ( await request.get(`${IntegrationUrls.HEROKU_API_URL}/apps/${integration.app}/config-vars`, { headers: { @@ -619,13 +632,63 @@ const syncSecretsHeroku = async ({ } }) ).data; + + let secretsToAdd: { [key: string]: string } = {}; + let 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; + switch (metadata.syncBehavior) { + case IntegrationSyncBehavior.OVERWRITE_TARGET: { + if (!(key in secrets)) secrets[key] = null; + break; + }; + case IntegrationSyncBehavior.PREFER_TARGET: { + secrets[key] = herokuSecrets[key]; + if (!(key in secrets)) { + secretsToAdd[key] = herokuSecrets[key]; + } else { + if (secrets[key] !== herokuSecrets[key]) { + secretsToUpdate[key] = secrets[key]?.value as string; + } + } + break; + }; + case IntegrationSyncBehavior.PREFER_SOURCE: { + if(!(key in secrets)) { + secrets[key] = herokuSecrets[key]; + secretsToAdd[key] = herokuSecrets[key]; + } + break; + }; + default: { + if (!(key in secrets)) secrets[key] = null; + break; + }; } }); + await createManySecretsRawHelper({ + botKey, + projectDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + projectId, + environment, + path: secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + await request.patch( `${IntegrationUrls.HEROKU_API_URL}/apps/${integration.app}/config-vars`, getSecretKeyValuePair(secrets), @@ -2950,8 +3013,22 @@ const syncSecretsHasuraCloud = async ({ /** * Sync/push [secrets] to [app] in integration named [integration] + * + * Do this in terms of DAL + * */ export const syncIntegrationSecrets = async ({ + projectDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + botKey, + projectId, + environment, + secretPath, integration, integrationAuth, secrets, @@ -2959,6 +3036,17 @@ export const syncIntegrationSecrets = async ({ accessToken, appendices }: { + projectDAL: TProjectDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; + botKey: string; + projectId: string; + environment: string; + secretPath: string; integration: TIntegrations; integrationAuth: TIntegrationAuths; secrets: Record; @@ -2999,9 +3087,20 @@ export const syncIntegrationSecrets = async ({ break; case Integrations.HEROKU: await syncSecretsHeroku({ + projectDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + botKey, + projectId, + environment, + secretPath, integration, secrets, - accessToken + accessToken, }); break; case Integrations.VERCEL: diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index e914aac42..05c77e746 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -76,7 +76,8 @@ export const orgDALFactory = (db: TDbClient) => { 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, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, user: { email, firstName, lastName, id: userId, publicKey } @@ -86,6 +87,79 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findOrgMembersByEmail = async (orgId: string, emails: string[]) => { + try { + const members = 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("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("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("email", emails); + return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + + 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("*"); @@ -165,7 +239,14 @@ 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`) + .select( + selectAllTableCols(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("scimEnabled").withSchema(TableName.Organization) + ); if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -184,6 +265,9 @@ export const orgDALFactory = (db: TDbClient) => { findAllOrgMembers, findOrgById, findAllOrgsByUserId, + ghostUserExists, + findOrgMembersByEmail, + 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..ec6d4cb2d --- /dev/null +++ b/backend/src/services/org/org-fns.ts @@ -0,0 +1,41 @@ +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +type TDeleteOrgMembership = { + orgMembershipId: string; + orgId: string; + orgDAL: Pick; + projectDAL: Pick; + projectMembershipDAL: Pick; +}; + +export const deleteOrgMembership = async ({ + orgMembershipId, + orgId, + orgDAL, + projectDAL, + projectMembershipDAL +}: TDeleteOrgMembership) => { + const membership = await orgDAL.transaction(async (tx) => { + // delete org membership + const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); + + const projects = await projectDAL.find({ orgId }, { tx }); + + // delete associated project memberships + await projectMembershipDAL.delete( + { + $in: { + projectId: projects.map((project) => project.id) + }, + userId: orgMembership.userId as string + }, + tx + ); + + return orgMembership; + }); + + return membership; +}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 7fafca438..28cf4b382 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -1,6 +1,8 @@ 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 { TProjects } from "@app/db/schemas/projects"; @@ -11,6 +13,7 @@ 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"; @@ -19,6 +22,8 @@ import { 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"; @@ -28,6 +33,7 @@ import { TOrgRoleDALFactory } from "./org-role-dal"; import { TDeleteOrgMembershipDTO, TFindAllWorkspacesDTO, + TFindOrgMembersByEmailDTO, TInviteUserToOrgDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -40,6 +46,8 @@ type TOrgServiceFactoryDep = { orgRoleDAL: TOrgRoleDALFactory; userDAL: TUserDALFactory; projectDAL: TProjectDALFactory; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; smtpService: TSmtpService; @@ -61,6 +69,8 @@ export const orgServiceFactory = ({ permissionService, smtpService, projectDAL, + projectMembershipDAL, + projectKeyDAL, tokenService, orgBotDAL, licenseService, @@ -93,6 +103,15 @@ export const orgServiceFactory = ({ return members; }; + const findOrgMembersByEmail = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const members = await orgDAL.findOrgMembersByEmail(orgId, emails); + + return members; + }; + const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); @@ -118,6 +137,54 @@ 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], + 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 details * */ @@ -126,16 +193,32 @@ export const orgServiceFactory = ({ actorId, actorOrgId, orgId, - data: { name, slug, authEnforced } + data: { name, slug, authEnforced, scimEnabled } }: TUpdateOrgDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + 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 (authEnforced) { + 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 || scimEnabled) { const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId); if (!samlCfg) throw new BadRequestError({ @@ -147,7 +230,8 @@ export const orgServiceFactory = ({ const org = await orgDAL.updateById(orgId, { name, slug: slug ? slugify(slug) : undefined, - authEnforced + authEnforced, + scimEnabled }); if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" }); return org; @@ -321,7 +405,8 @@ export const orgServiceFactory = ({ { email: inviteeEmail, isAccepted: false, - authMethods: [AuthMethod.EMAIL] + authMethods: [AuthMethod.EMAIL], + isGhost: false }, tx ); @@ -424,10 +509,50 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const membership = await orgDAL.deleteMembershipById(membershipId, orgId); + const deletedMembership = await orgDAL.transaction(async (tx) => { + const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx); - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return membership; + if (!orgMembership.userId) { + await licenseService.updateSubscriptionOrgMemberCount(orgId); + return orgMembership; + } + + // 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; }; /* @@ -470,10 +595,12 @@ export const orgServiceFactory = ({ inviteUserToOrganization, verifyUserToOrg, updateOrg, + findOrgMembersByEmail, createOrganization, deleteOrganizationById, deleteOrgMembership, findAllWorkspaces, + addGhostUser, updateOrgMembership, // incident contacts findIncidentContacts, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 01b3c8e37..bd8fe2e95 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -30,6 +30,13 @@ export type TVerifyUserToOrgDTO = { code: string; }; +export type TFindOrgMembersByEmailDTO = { + actor: ActorType; + actorId: string; + orgId: string; + emails: string[]; +}; + export type TFindAllWorkspacesDTO = { actor: ActorType; actorId: string; @@ -38,5 +45,5 @@ export type TFindAllWorkspacesDTO = { }; export type TUpdateOrgDTO = { - data: Partial<{ name: string; slug: string; authEnforced: boolean }>; + data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>; } & 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-service.ts b/backend/src/services/project-bot/project-bot-service.ts index 5ead160f2..456a1e7a0 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -1,125 +1,111 @@ import { ForbiddenError } from "@casl/ability"; -import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectVersion, 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 { getConfig } from "@app/lib/config/env"; -import { - decryptAsymmetric, - decryptSymmetric, - decryptSymmetric128BitHexKeyUTF8, - encryptSymmetric, - encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair -} from "@app/lib/crypto"; +import { decryptAsymmetric, generateAsymmetricKeyPair } from "@app/lib/crypto"; +import { infisicalSymmetricDecrypt, 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 { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types"; type TProjectBotServiceFactoryDep = { permissionService: Pick; + projectDAL: Pick; projectBotDAL: TProjectBotDALFactory; }; export type TProjectBotServiceFactory = ReturnType; -export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => { - const getBotKey = async (projectId: string) => { - const appCfg = getConfig(); - const encryptionKey = appCfg.ENCRYPTION_KEY; - const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; +export const projectBotServiceFactory = ({ + projectBotDAL, + projectDAL, + permissionService +}: TProjectBotServiceFactoryDep) => { + const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => + infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + const getBotKey = async (projectId: string) => { 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 - }); - } + const botPrivateKey = getBotPrivateKey({ bot }); - throw new BadRequestError({ - message: "Failed to obtain bot copy of workspace key needed for operation" + return decryptAsymmetric({ + ciphertext: bot.encryptedProjectKey, + privateKey: botPrivateKey, + nonce: bot.encryptedProjectKeyNonce, + publicKey: bot.sender.publicKey }); }; - const findBotByProjectId = async ({ actorId, actor, actorOrgId, projectId }: TProjectPermission) => { + const findBotByProjectId = async ({ + actorId, + actor, + projectId, + actorOrgId, + privateKey, + botKey, + publicKey + }: TFindBotByProjectIdDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, 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 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, isActive }: TSetActiveStateDTO) => { const bot = await projectBotDAL.findById(botId); if (!bot) throw new BadRequestError({ message: "Bot not found" }); @@ -127,6 +113,16 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T const { permission } = await permissionService.getProjectPermission(actor, actorId, bot.projectId, 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 +149,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-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-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 22b9937a9..77ed867c4 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,7 +1,7 @@ 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 } from "@app/lib/knex"; export type TProjectMembershipDALFactory = ReturnType; @@ -24,20 +24,83 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("projectId").withSchema(TableName.ProjectMembership), db.ref("role").withSchema(TableName.ProjectMembership), db.ref("roleId").withSchema(TableName.ProjectMembership), + db.ref("isGhost").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") - ); - return members.map(({ email, firstName, lastName, publicKey, ...data }) => ({ + ) + .where({ isGhost: false }); + return members.map(({ email, firstName, lastName, publicKey, isGhost, ...data }) => ({ ...data, - user: { email, firstName, lastName, id: data.userId, publicKey } + user: { email, firstName, lastName, id: data.userId, publicKey, isGhost } })); } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); } }; - return { ...projectMemberOrm, findAllProjectMembers }; + const findProjectGhostUser = async (projectId: string) => { + try { + const ghostUser = await 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 findMembershipsByEmail = async (projectId: string, emails: string[]) => { + try { + const members = await db(TableName.ProjectMembership) + .where({ projectId }) + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + selectAllTableCols(TableName.ProjectMembership), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("email").withSchema(TableName.Users) + ) + .whereIn("email", emails) + .where({ isGhost: false }); + return members.map(({ userId, email, ...data }) => ({ + ...data, + user: { id: userId, email } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find members by email" }); + } + }; + + 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, + findMembershipsByEmail, + findProjectMembershipsByUserId + }; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 6b2123e5e..014e9fb1b 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,15 +1,28 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; -import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas"; +import { + OrgMembershipStatus, + ProjectMembershipRole, + ProjectVersion, + SecretKeyEncoding, + TableName, + TProjectMemberships, + TUsers +} 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 { 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"; @@ -17,7 +30,9 @@ import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TAddUsersToWorkspaceDTO, - TDeleteProjectMembershipDTO, + TAddUsersToWorkspaceNonE2EEDTO, + TDeleteProjectMembershipOldDTO, + TDeleteProjectMembershipsDTO, TGetProjectMembershipDTO, TInviteUserToProjectDTO, TUpdateProjectMembershipDTO @@ -26,11 +41,12 @@ import { type TProjectMembershipServiceFactoryDep = { permissionService: Pick; smtpService: TSmtpService; + projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; - userDAL: Pick; + userDAL: Pick; projectRoleDAL: Pick; - orgDAL: Pick; - projectDAL: Pick; + orgDAL: Pick; + projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; }; @@ -42,6 +58,7 @@ export const projectMembershipServiceFactory = ({ projectMembershipDAL, smtpService, projectRoleDAL, + projectBotDAL, orgDAL, userDAL, projectDAL, @@ -55,64 +72,90 @@ export const projectMembershipServiceFactory = ({ return projectMembershipDAL.findAllProjectMembers(projectId); }; - const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, email }: TInviteUserToProjectDTO) => { + const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); 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 invitees: TUsers[] = []; const project = await projectDAL.findById(projectId); - const inviteeMembershipOrg = await orgDAL.findMembership({ - userId: invitee.id, - orgId: project.orgId, - status: OrgMembershipStatus.Accepted + const users = await userDAL.find({ + $in: { email: emails } }); - if (!inviteeMembershipOrg) - throw new BadRequestError({ - message: "Failed to validate invitee org membership", - name: "Invite user to project" + + await projectDAL.transaction(async (tx) => { + for (const invitee of users) { + if (!invitee.isAccepted) + throw new BadRequestError({ + message: "Failed to validate invitee", + name: "Invite user to project" + }); + + const inviteeMembership = await projectMembershipDAL.findOne( + { + userId: invitee.id, + projectId + }, + tx + ); + + if (inviteeMembership) { + throw new BadRequestError({ + message: "Existing member of project", + name: "Invite user to project" + }); + } + + 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" + }); + } + + await projectMembershipDAL.create( + { + userId: invitee.id, + projectId, + role: ProjectMembershipRole.Member + }, + tx + ); + + invitees.push(invitee); + } + + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical workspace invitation", + recipients: invitees.map((i) => i.email), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } }); + }); const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId); - await projectMembershipDAL.create({ - userId: invitee.id, - projectId, - role: ProjectMembershipRole.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 }; + return { invitees, latestKey }; }; - const addUsersToProject = async ({ projectId, actorId, actor, actorOrgId, members }: TAddUsersToWorkspaceDTO) => { + const addUsersToProject = async ({ + projectId, + actorId, + actor, + actorOrgId, + members, + sendEmails = true + }: TAddUsersToWorkspaceDTO) => { const project = await projectDAL.findById(projectId); if (!project) throw new BadRequestError({ message: "Project not found" }); @@ -134,11 +177,16 @@ export const projectMembershipServiceFactory = ({ await projectMembershipDAL.transaction(async (tx) => { await projectMembershipDAL.insertMany( - orgMembers.map(({ userId }) => ({ - projectId, - userId: userId as string, - role: ProjectMembershipRole.Member - })), + orgMembers.map(({ userId, id: membershipId }) => { + const role = + members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member; + + return { + projectId, + userId: userId as string, + role + }; + }), tx ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); @@ -153,22 +201,134 @@ export const projectMembershipServiceFactory = ({ 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 workspace invitation", + recipients: orgMembers.map(({ email }) => email).filter(Boolean), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } return orgMembers; }; + const addUsersToProjectNonE2EE = async ({ + projectId, + actorId, + actor, + emails, + 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); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + + const orgMembers = await orgDAL.findOrgMembersByEmail(project.orgId, emails); + + if (orgMembers.length !== emails.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[] = []; + + await projectMembershipDAL.transaction(async (tx) => { + const result = await projectMembershipDAL.insertMany( + orgMembers.map(({ user }) => ({ + projectId, + userId: user.id, + role: ProjectMembershipRole.Member + })), + tx + ); + + members.push(...result); + + const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); + await projectKeyDAL.insertMany( + orgMembers.map(({ user, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + })), + tx + ); + }); + + if (sendEmails) { + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical workspace invitation", + recipients: orgMembers.map(({ user }) => user.email).filter(Boolean), + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } + return members; + }; + const updateProjectMembership = async ({ actorId, actor, @@ -180,6 +340,15 @@ export const projectMembershipServiceFactory = ({ const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); + + if (membershipUser?.isGhost) { + throw new BadRequestError({ + message: "Unauthorized member update", + name: "Update project membership" + }); + } + const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); if (isCustomRole) { const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); @@ -205,16 +374,26 @@ export const projectMembershipServiceFactory = ({ return membership; }; + // 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, projectId, membershipId - }: TDeleteProjectMembershipDTO) => { + }: TDeleteProjectMembershipOldDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, 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); @@ -223,11 +402,74 @@ export const projectMembershipServiceFactory = ({ return membership; }; + const deleteProjectMemberships = async ({ + actorId, + actor, + actorOrgId, + projectId, + emails + }: TDeleteProjectMembershipsDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, 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 projectMembers = await projectMembershipDAL.findMembershipsByEmail(projectId, emails); + + if (projectMembers.length !== emails.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 memberships = await projectMembershipDAL.transaction(async (tx) => { + const deletedMemberships = await projectMembershipDAL.delete( + { + projectId, + $in: { + id: projectMembers.map(({ id }) => id) + } + }, + tx + ); + + await projectKeyDAL.delete( + { + projectId, + $in: { + receiverId: projectMembers.map(({ user }) => user.id).filter(Boolean) + } + }, + tx + ); + + return deletedMemberships; + }); + return memberships; + }; + return { getProjectMemberships, inviteUserToProject, 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..634b51d77 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,9 +1,10 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; export type TInviteUserToProjectDTO = { - email: string; + emails: string[]; } & TProjectPermission; export type TUpdateProjectMembershipDTO = { @@ -11,14 +12,25 @@ export type TUpdateProjectMembershipDTO = { role: string; } & TProjectPermission; -export type TDeleteProjectMembershipDTO = { +export type TDeleteProjectMembershipOldDTO = { membershipId: string; } & TProjectPermission; +export type TDeleteProjectMembershipsDTO = { + emails: string[]; +} & TProjectPermission; + export type TAddUsersToWorkspaceDTO = { + sendEmails?: boolean; members: { orgMembershipId: string; workspaceEncryptedKey: string; workspaceEncryptedNonce: string; + projectRole: ProjectMembershipRole; }[]; } & TProjectPermission; + +export type TAddUsersToWorkspaceNonE2EEDTO = { + sendEmails?: boolean; + emails: string[]; +} & TProjectPermission; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 44ba57481..7d0826e12 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -1,6 +1,8 @@ +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"; export type TProjectDALFactory = ReturnType; @@ -52,6 +54,32 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const findProjectGhostUser = async (projectId: string) => { + try { + const ghostUser = await 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) @@ -132,10 +160,25 @@ export const projectDALFactory = (db: TDbClient) => { } }; + 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, + 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..e1d45e96d --- /dev/null +++ b/backend/src/services/project/project-queue.ts @@ -0,0 +1,568 @@ +/* eslint-disable no-await-in-loop */ +import { + IntegrationAuthsSchema, + ProjectMembershipRole, + ProjectUpgradeStatus, + ProjectVersion, + SecretApprovalRequestsSecretsSchema, + SecretKeyEncoding, + SecretsSchema, + SecretVersionsSchema, + 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 { 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; + 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 +}: 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 + await projectMembershipDAL.create( + { + projectId: project.id, + userId: ghostUser.user.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({ userId: key.receiverId, 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 387c9bd78..c7c3af6fa 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1,22 +1,40 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { ProjectMembershipRole } from "@app/db/schemas"; +import { 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 { 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 { 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 { 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, + TUpdateProjectDTO, + TUpgradeProjectDTO +} from "./project-types"; export const DEFAULT_PROJECT_ENVS = [ { name: "Development", slug: "dev" }, @@ -26,11 +44,18 @@ 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; + projectKeyDAL: Pick; + projectBotDAL: Pick; + projectMembershipDAL: Pick; secretBlindIndexDAL: Pick; permissionService: TPermissionServiceFactory; + orgService: Pick; licenseService: Pick; }; @@ -38,8 +63,15 @@ export type TProjectServiceFactory = ReturnType; export const projectServiceFactory = ({ projectDAL, + projectQueue, + projectKeyDAL, permissionService, + userDAL, folderDAL, + orgService, + identityProjectDAL, + projectBotDAL, + identityOrgMembershipDAL, secretBlindIndexDAL, projectMembershipDAL, projectEnvDAL, @@ -48,8 +80,13 @@ export const projectServiceFactory = ({ /* * Create workspace. Make user the admin * */ - const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName }: TCreateProjectDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId); + const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName, slug }: TCreateProjectDTO) => { + const { permission, membership: orgMembership } = await permissionService.getOrgPermission( + actor, + actorId, + orgId, + actorOrgId + ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); const appCfg = getConfig(); @@ -64,20 +101,28 @@ export const projectServiceFactory = ({ }); } - const newProject = projectDAL.transaction(async (tx) => { + const results = await projectDAL.transaction(async (tx) => { + const ghostUser = await orgService.addGhostUser(orgId, tx); + const project = await projectDAL.create( - { name: workspaceName, orgId, slug: slugify(`${workspaceName}-${alphaNumericNanoId(4)}`) }, + { + name: workspaceName, + orgId, + slug: slug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), + version: ProjectVersion.V2 + }, tx ); - // set user as admin member for proeject + // set ghost user as admin of project await projectMembershipDAL.create( { - userId: actorId, + userId: ghostUser.user.id, role: ProjectMembershipRole.Admin, projectId: project.id }, tx ); + // generate the blind index for project await secretBlindIndexDAL.create( { @@ -99,18 +144,165 @@ 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 + await projectMembershipDAL.create( + { + projectId: project.id, + userId: user.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( + ProjectMembershipRole.Admin, + orgId + ); + + 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); + + await identityProjectDAL.create( + { + identityId: actorId, + projectId: project.id, + role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, + roleId: customRole?.id + }, + tx + ); + } + + return { + ...project, + environments: envs, + _id: project.id + }; }); - return newProject; + return results; }; const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); - const deletedProject = await projectDAL.deleteById(projectId); + const deletedProject = await projectDAL.transaction(async (tx) => { + const project = await projectDAL.deleteById(projectId, tx); + const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(projectId).catch(() => null); + + // Delete the org membership for the ghost user if it's found. + if (projectGhostUser) { + await userDAL.deleteById(projectGhostUser.id, tx); + } + + return project; + }); + return deletedProject; }; @@ -124,6 +316,17 @@ export const projectServiceFactory = ({ return projectDAL.findProjectById(projectId); }; + const updateProject = async ({ projectId, actor, actorId, actorOrgId, update }: TUpdateProjectDTO) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + const updatedProject = await projectDAL.updateById(projectId, { + name: update.name, + autoCapitalization: update.autoCapitalization + }); + return updatedProject; + }; + const toggleAutoCapitalization = async ({ projectId, actor, @@ -146,12 +349,55 @@ export const projectServiceFactory = ({ return updatedProject; }; + const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => { + const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); + + if (membership?.role !== 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, actorId }: TProjectPermission) => { + const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId); + 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 2ffea1117..3843450c2 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,3 +1,6 @@ +import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + import { ActorType } from "../auth/auth-type"; export type TCreateProjectDTO = { @@ -6,6 +9,7 @@ export type TCreateProjectDTO = { actorOrgId?: string; orgId: string; workspaceName: string; + slug?: string; }; export type TDeleteProjectDTO = { @@ -21,3 +25,24 @@ export type TGetProjectDTO = { actorOrgId?: string; projectId: string; }; + +export type TUpdateProjectDTO = { + update: { + name?: string; + autoCapitalization?: boolean; + }; +} & TProjectPermission; + +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-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index 1fa55f214..00b33a13c 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -41,13 +41,12 @@ export const fnSecretsFromImports = async ({ environment: importEnv.slug, environmentInfo: importEnv, folderId: importedFolders?.[i]?.id, - secrets: importedFolders?.[i]?.id - ? importedSecsGroupByFolderId[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. - })) - : [] + // 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: (importedSecsGroupByFolderId?.[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. + })) })); }; diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index a519c7820..1beae9be6 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -4,6 +4,7 @@ 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 { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -21,6 +22,7 @@ type TSecretImportServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; folderDAL: TSecretFolderDALFactory; secretDAL: Pick; + projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; permissionService: Pick; }; @@ -34,6 +36,7 @@ export const secretImportServiceFactory = ({ projectEnvDAL, permissionService, folderDAL, + projectDAL, secretDAL }: TSecretImportServiceFactoryDep) => { const createImport = async ({ @@ -62,6 +65,8 @@ 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" }); diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index 62ebd8a23..1007ec4c3 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -19,7 +19,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, 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({ diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index ba65033cb..11cd522ca 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() @@ -139,5 +178,13 @@ export const secretDALFactory = (db: TDbClient) => { } }; - return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes }; + return { + ...secretOrm, + update, + bulkUpdate, + deleteMany, + bulkUpdateNoVersionIncrement, + findByFolderId, + findByBlindIndexes + }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 0f6caa248..382e87310 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -1,12 +1,31 @@ /* eslint-disable no-await-in-loop */ import path from "path"; -import { SecretKeyEncoding, TSecretBlindIndexes, TSecrets } from "@app/db/schemas"; +import { + SecretKeyEncoding, + TSecretBlindIndexes, + TSecrets, + SecretEncryptionAlgo, + SecretType, + TableName +} from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { buildSecretBlindIndexFromName, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { + buildSecretBlindIndexFromName, + encryptSymmetric128BitHexKeyUTF8, + decryptSymmetric128BitHexKeyUTF8 +} from "@app/lib/crypto"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretDALFactory } from "./secret-dal"; +import { + TCreateManySecretsRawHelper, + TFnSecretBulkInsert, + TFnSecretBlindIndexCheck +} from "./secret-types"; + +import { groupBy, unique } from "@app/lib/fn"; +import { BadRequestError } from "@app/lib/errors"; export const generateSecretBlindIndexBySalt = async (secretName: string, secretBlindIndexDoc: TSecretBlindIndexes) => { const appCfg = getConfig(); @@ -228,3 +247,201 @@ export const decryptSecretRaw = (secret: TSecrets & { workspace: string; environ user: secret.userId }; }; + +/** + * 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, ...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 })); +}; + +export const createManySecretsRawHelper = async ({ // TODO: place on top + projectId, + environment, + path, + secrets, + userId, + botKey, // TODO: consider getting botKey inside this fn + projectDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL +}: TCreateManySecretsRawHelper) => { +await projectDAL.checkProjectUpgradeStatus(projectId); + +const folder = await folderDAL.findBySecretPath(projectId, environment, path); +if (!folder) throw new BadRequestError({ message: "Folder not found", 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" }); + +const inputSecrets = await Promise.all( + secrets.map(async (secret) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + + if (secret.type === SecretType.Personal) { + const sharedExist = await secretDAL.findOne({ + secretBlindIndex: keyName2BlindIndex[secret.secretName], + folderId, + type: SecretType.Shared + }); + + if (!sharedExist) + throw new BadRequestError({ + message: "Failed to create personal secret override for no corresponding shared secret" + }); + + if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" }); + } + + const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : []; + if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + 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 + }); + }) +); + + // insert operation + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets, + folderId, + isNew: true, + blindIndexCfg, + secretDAL + }); + + 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; +} \ No newline at end of file diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index b797b7caf..2b5a69298 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -25,6 +25,11 @@ import { TSecretDALFactory } from "./secret-dal"; import { interpolateSecrets } from "./secret-fns"; import { TCreateSecretReminderDTO, THandleReminderDTO, TRemoveSecretReminderDTO } from "./secret-types"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; + export type TSecretQueueFactory = ReturnType; type TSecretQueueFactoryDep = { @@ -32,15 +37,19 @@ type TSecretQueueFactoryDep = { integrationDAL: Pick; projectBotService: Pick; integrationAuthService: Pick; - folderDAL: Pick; - secretDAL: Pick; + folderDAL: TSecretFolderDALFactory; + secretDAL: TSecretDALFactory; secretImportDAL: Pick; webhookDAL: Pick; projectEnvDAL: Pick; - projectDAL: Pick; + projectDAL: TProjectDALFactory; projectMembershipDAL: Pick; smtpService: TSmtpService; orgDAL: Pick; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; }; export type TGetSecrets = { @@ -62,7 +71,11 @@ export const secretQueueFactory = ({ orgDAL, smtpService, projectDAL, - projectMembershipDAL + projectMembershipDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, }: TSecretQueueFactoryDep) => { const syncIntegrations = async (dto: TGetSecrets) => { await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { @@ -307,6 +320,17 @@ export const secretQueueFactory = ({ } await syncIntegrationSecrets({ + projectDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL, + botKey, + projectId, + environment, + secretPath, integration, integrationAuth, secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index ff9dce9a8..6b4aaf2d5 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -11,6 +11,7 @@ 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 { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -18,19 +19,22 @@ 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 +} from "./secret-fns"; import { TSecretQueueFactory } from "./secret-queue"; import { + TCreateManySecretsRawHelper, TCreateBulkSecretDTO, TCreateSecretDTO, TCreateSecretRawDTO, TDeleteBulkSecretDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, - TFnSecretBlindIndexCheck, TFnSecretBlindIndexCheckV2, TFnSecretBulkDelete, - TFnSecretBulkInsert, TFnSecretBulkUpdate, TGetASecretDTO, TGetASecretRawDTO, @@ -49,6 +53,7 @@ type TSecretServiceFactoryDep = { secretTagDAL: TSecretTagDALFactory; secretVersionDAL: TSecretVersionDALFactory; folderDAL: Pick; + projectDAL: Pick; secretBlindIndexDAL: TSecretBlindIndexDALFactory; permissionService: Pick; snapshotService: Pick; @@ -68,6 +73,7 @@ export const secretServiceFactory = ({ permissionService, snapshotService, secretQueueService, + projectDAL, projectBotService, secretImportDAL, secretVersionTagDAL @@ -92,41 +98,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 } }) => ({ @@ -199,54 +170,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) => { @@ -281,6 +204,8 @@ export const secretServiceFactory = ({ 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" }); const folderId = folder.id; @@ -297,7 +222,8 @@ export const secretServiceFactory = ({ folderId, isNew: true, userId: actorId, - blindIndexCfg + blindIndexCfg, + secretDAL }); // if user creating personal check its shared also exist @@ -334,6 +260,10 @@ export const secretServiceFactory = ({ tags: inputSecret.tags } ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -359,6 +289,12 @@ export const secretServiceFactory = ({ 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" }); const folderId = folder.id; @@ -375,7 +311,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" }); @@ -387,7 +324,8 @@ export const secretServiceFactory = ({ inputSecrets: [{ secretName: inputSecret.newSecretName }], folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); newSecretNameBlindIndex = kN2NewBlindIndex[inputSecret.newSecretName]; } @@ -459,6 +397,8 @@ export const secretServiceFactory = ({ 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" }); const folderId = folder.id; @@ -474,7 +414,8 @@ export const secretServiceFactory = ({ inputSecrets: [{ secretName: inputSecret.secretName }], folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const deletedSecret = await secretDAL.transaction(async (tx) => @@ -650,6 +591,8 @@ export const secretServiceFactory = ({ 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" }); const folderId = folder.id; @@ -661,7 +604,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); // get all tags @@ -680,6 +624,10 @@ export const secretServiceFactory = ({ keyEncoding: SecretKeyEncoding.UTF8 })), folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -705,6 +653,8 @@ export const secretServiceFactory = ({ 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" }); const folderId = folder.id; @@ -716,7 +666,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); // now find any secret that needs to update its name @@ -726,7 +677,8 @@ export const secretServiceFactory = ({ inputSecrets: nameUpdatedSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); // get all tags @@ -776,6 +728,8 @@ export const secretServiceFactory = ({ 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" }); const folderId = folder.id; @@ -787,7 +741,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secretsDeleted = await secretDAL.transaction(async (tx) => @@ -871,6 +826,98 @@ export const secretServiceFactory = ({ }); return decryptSecretRaw(secret, botKey); }; + + const createManySecretsRawHelper = async ({ // TODO: place on top + projectId, + environment, + path, + secrets, + userId + }: TCreateManySecretsRawHelper) => { + const botKey = await projectBotService.getBotKey(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, path); + if (!folder) throw new BadRequestError({ message: "Folder not found", 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" }); + + const inputSecrets = await Promise.all( + secrets.map(async (secret) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + + if (secret.type === SecretType.Personal) { + const sharedExist = await secretDAL.findOne({ + secretBlindIndex: keyName2BlindIndex[secret.secretName], + folderId, + type: SecretType.Shared + }); + + if (!sharedExist) + throw new BadRequestError({ + message: "Failed to create personal secret override for no corresponding shared secret" + }); + + if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" }); + } + + const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : []; + if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + 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 + }); + }) + ); + + // insert operation + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets, + folderId, + isNew: true, + blindIndexCfg, + secretDAL + }); + + 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; + } const createSecretRaw = async ({ secretName, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 50f678172..6b0b205ed 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -2,6 +2,13 @@ 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 { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; type TPartialSecret = Pick; @@ -181,6 +188,10 @@ export type TFnSecretBulkInsert = { folderId: string; tx?: Knex; inputSecrets: Array & { tags?: string[] }>; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; }; export type TFnSecretBulkUpdate = { @@ -204,6 +215,7 @@ export type TFnSecretBlindIndexCheck = { blindIndexCfg: TSecretBlindIndexes; inputSecrets: Array<{ secretName: string; type?: SecretType }>; isNew: boolean; + secretDAL: Pick; }; // when blind index is already present @@ -229,3 +241,31 @@ export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; }; + +// --- + +export type TCreateManySecretsRawHelper = { + 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) + botKey: string; + projectDAL: TProjectDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; +} \ No newline at end of file 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 76d33bd6b..cce0d3780 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -142,7 +142,7 @@ export const serviceTokenServiceFactory = ({ const updatedToken = await serviceTokenDAL.updateById(serviceToken.id, { lastUsed: new Date() }); - return updatedToken; + return { ...serviceToken, lastUsed: updatedToken.lastUsed }; }; return { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 142993ceb..7ebeaa227 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -25,7 +25,8 @@ export enum SmtpTemplates { 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/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/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 1144bd414..2a6a2c75e 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 ({ @@ -70,6 +99,7 @@ export const superAdminServiceFactory = ({ lastName, email, superAdmin: true, + isGhost: false, isAccepted: true, authMethods: [AuthMethod.EMAIL] }, @@ -96,7 +126,11 @@ 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( + userInfo.user.id, + userInfo.user.email, + initialOrganizationName + ); await updateServerCfg({ initialized: true }); const token = await authService.generateUserTokens({ @@ -106,7 +140,7 @@ export const superAdminServiceFactory = ({ 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..947403783 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 = { @@ -53,9 +58,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/user-dal.ts b/backend/src/services/user/user-dal.ts index 0de490399..2c89b9c4d 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -23,7 +23,7 @@ export const userDALFactory = (db: TDbClient) => { const findUserEncKeyByEmail = async (email: string) => { try { return await db(TableName.Users) - .where({ email }) + .where({ email, isGhost: false }) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) .first(); } catch (error) { @@ -47,6 +47,17 @@ 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 createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => { try { const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*"); @@ -111,6 +122,7 @@ export const userDALFactory = (db: TDbClient) => { findUserEncKeyByEmail, findUserEncKeyByUserId, updateUserEncryptionByUserId, + findUserByProjectMembershipId, upsertUserEncryptionKey, createUserEncryption, findOneUserAction, 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/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/packages/api/api.go b/cli/packages/api/api.go index 9ae356cd0..1b69b8f23 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -145,6 +145,25 @@ 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 CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesResponse, error) { var workSpacesResponse GetWorkSpacesResponse response, err := httpClient. @@ -490,5 +509,7 @@ 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 } diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 3c6466382..586a23511 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -120,14 +120,21 @@ 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 Secret struct { SecretKeyCiphertext string `json:"secretKeyCiphertext,omitempty"` SecretKeyIV string `json:"secretKeyIV,omitempty"` @@ -505,4 +512,5 @@ type GetRawSecretsV3Response struct { SecretComment string `json:"secretComment"` } `json:"secrets"` Imports []any `json:"imports"` + ETag string } diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 8857f5806..750727df1 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -5,12 +5,15 @@ package cmd import ( "bytes" + "context" "encoding/base64" "fmt" "io/ioutil" "os" + "os/exec" "os/signal" "path" + "runtime" "strings" "sync" "syscall" @@ -71,12 +74,56 @@ 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 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,20 +217,24 @@ 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) if err != nil { return nil, err } - return secrets, nil + if existingEtag != res.Etag { + *currentEtag = res.Etag + } + + return res.Secrets, nil } } -func ProcessTemplate(templatePath string, data interface{}, accessToken string) (*bytes.Buffer, error) { +func ProcessTemplate(templatePath string, data interface{}, accessToken string, existingEtag string, currentEtag *string) (*bytes.Buffer, error) { // custom template function to fetch secrets from Infisical - secretFunction := secretTemplateFunction(accessToken) + secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) funcs := template.FuncMap{ "secret": secretFunction, } @@ -203,7 +254,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(encodedTemplate string, data interface{}, accessToken string, existingEtag string, currentEtag *string) (*bytes.Buffer, error) { // custom template function to fetch secrets from Infisical decoded, err := base64.StdEncoding.DecodeString(encodedTemplate) if err != nil { @@ -212,7 +263,7 @@ func ProcessBase64Template(encodedTemplate string, data interface{}, accessToken templateString := string(decoded) - secretFunction := secretTemplateFunction(accessToken) + secretFunction := secretTemplateFunction(accessToken, existingEtag, currentEtag) // TODO: Fix this funcs := template.FuncMap{ "secret": secretFunction, } @@ -250,7 +301,16 @@ type TokenManager struct { } 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} + return &TokenManager{ + 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) { @@ -428,38 +488,80 @@ func (tm *TokenManager) WriteTokenToFiles() { } } -func (tm *TokenManager) FetchSecrets() { - log.Info().Msgf("template engine started...") +func (tm *TokenManager) 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 (tm *TokenManager) MonitorSecretChanges(secretTemplate Template, sigChan chan os.Signal) { + + 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 + } + } + + var existingEtag string + var currentEtag string + var firstRun = true + + execTimeout := secretTemplate.Config.Execute.Timeout + execCommand := secretTemplate.Config.Execute.Command + 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) + var processedTemplate *bytes.Buffer + var err error - 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) + if secretTemplate.SourcePath != "" { + processedTemplate, err = ProcessTemplate(secretTemplate.SourcePath, nil, token, existingEtag, ¤tEtag) + } else { + processedTemplate, err = ProcessBase64Template(secretTemplate.Base64TemplateContent, nil, token, existingEtag, ¤tEtag) } - // fetch new secrets every 5 minutes (TODO: add PubSub in the future ) - time.Sleep(5 * time.Minute) + 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 + } + } + } + time.Sleep(pollingInterval) + } 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) } + } } @@ -544,7 +646,11 @@ var agentCmd = &cobra.Command{ tm := NewTokenManager(filePaths, agentConfig.Templates, configUniversalAuthType.ClientIDPath, configUniversalAuthType.ClientSecretPath, tokenRefreshNotifier, configUniversalAuthType.RemoveClientSecretOnRead, agentConfig.Infisical.ExitAfterAuth) 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, sigChan) + } for { select { diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index d0db485b3..26cc45f65 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -87,16 +87,12 @@ 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) - } + secrets = util.ExpandSecrets(secrets, infisicalToken, "") + } + secrets = util.FilterSecretsByTag(secrets, tagSlugs) + output, err = formatEnvs(secrets, format) + if err != nil { + util.HandleError(err) } fmt.Print(output) diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index 070074fa9..e47eb447e 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,27 @@ var initCmd = &cobra.Command{ util.HandleError(err) } - err = writeWorkspaceFile(workspaces[index]) + selectedOrganization := organizations[index] + + 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/models/cli.go b/cli/packages/models/cli.go index 165982a77..c3999f68b 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -34,17 +34,22 @@ type SingleEnvironmentVariable struct { Comment string `json:"comment"` } +type PlaintextSecretResult struct { + Secrets []SingleEnvironmentVariable + Etag 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 { 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/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..cc75681e8 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -152,7 +152,7 @@ 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) (models.PlaintextSecretResult, error) { httpClient := resty.New() httpClient.SetAuthToken(accessToken). SetHeader("Accept", "application/json") @@ -170,12 +170,12 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin rawSecrets, err := api.CallGetRawSecretsV3(httpClient, api.GetRawSecretsV3Request{WorkspaceId: workspaceId, SecretPath: secretsPath, Environment: environmentName}) 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 { @@ -189,7 +189,10 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin // } // } - return plainTextSecrets, nil + return models.PlaintextSecretResult{ + Secrets: plainTextSecrets, + Etag: rawSecrets.ETag, + }, nil } func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedSecretV3) ([]models.SingleEnvironmentVariable, error) { @@ -220,6 +223,30 @@ func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleE 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) { var infisicalToken string if params.InfisicalToken == "" { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f95810777..6c3a89a74 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -86,6 +86,7 @@ services: environment: - NODE_ENV=development - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5861a4a0e..86a8e4cca 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -4,10 +4,12 @@ services: db-migration: container_name: infisical-db-migration depends_on: - - db + db: + condition: service_healthy image: infisical/infisical:latest-postgres env_file: .env command: npm run migration:latest + pull_policy: always networks: - infisical @@ -16,12 +18,13 @@ services: restart: unless-stopped depends_on: db: - condition: service_started + 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 @@ -49,9 +52,14 @@ services: restart: always env_file: .env volumes: - - pg_data:/data/db + - 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: 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-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/invite-member-to-workspace.mdx b/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx new file mode 100644 index 000000000..a09c586ca --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/invite-member-to-workspace.mdx @@ -0,0 +1,4 @@ +--- +title: "Invite Member" +openapi: "POST /api/v2/workspace/{projectId}/memberships" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx b/docs/api-reference/endpoints/workspaces/remove-member-from-workspace.mdx new file mode 100644 index 000000000..8b781a4e8 --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/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/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/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/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/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.mdx b/docs/documentation/getting-started/introduction.mdx index 84d34e871..4c96b5a78 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -2,7 +2,7 @@ title: "Introduction" --- -Infisical is an [open-source](https://opensource.com/resources/what-open-source), [end-to-end encrypted](https://en.wikipedia.org/wiki/End-to-end_encryption) secret management platform for storing, managing, and syncing +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) secrets management platform for storing, managing, and syncing application configuration and secrets like API keys, database credentials, and environment variables across applications and infrastructure. Start syncing environment variables with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. diff --git a/docs/documentation/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/ldap.mdx b/docs/documentation/platform/ldap.mdx new file mode 100644 index 000000000..63b2a814b --- /dev/null +++ b/docs/documentation/platform/ldap.mdx @@ -0,0 +1,36 @@ +--- +title: "LDAP" +description: "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 team@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); this includes support for Active Directory. + + + + In Infisical, head to your Organization Settings > Authentication > LDAP Configuration and select **Set up LDAP**. + + 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. + - Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=example,dc=com` + - CA Certificate: The CA certificate to use when verifying the LDAP server certificate. + + + Enabling LDAP allows members in your organization to log into Infisical via LDAP. + + ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) + + \ No newline at end of file diff --git a/docs/documentation/platform/ldap/general.mdx b/docs/documentation/platform/ldap/general.mdx new file mode 100644 index 000000000..b4f772ea3 --- /dev/null +++ b/docs/documentation/platform/ldap/general.mdx @@ -0,0 +1,36 @@ +--- +title: "General LDAP" +description: "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 team@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) + + + + In Infisical, head to your Organization Settings > Authentication > LDAP Configuration and select **Set up LDAP**. + + 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. + - Search Base / User DN: Base DN under which to perform user search such as `ou=Users,dc=example,dc=com` + - CA Certificate: The CA certificate to use when verifying the LDAP server certificate. + + + Enabling LDAP allows members in your organization to log into Infisical via LDAP. + + ![LDAP toggle](/images/platform/ldap/ldap-toggle.png) + + \ No newline at end of file diff --git a/docs/documentation/platform/ldap/jumpcloud.mdx b/docs/documentation/platform/ldap/jumpcloud.mdx new file mode 100644 index 000000000..b52a75880 --- /dev/null +++ b/docs/documentation/platform/ldap/jumpcloud.mdx @@ -0,0 +1,56 @@ +--- +title: "JumpCloud LDAP" +description: "Configure JumpCloud LDAP for Logging 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 team@infisical.com to purchase an enterprise license to use it. + + + + + 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 > Authentication > LDAP Configuration and select **Set up LDAP**. + + 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. + - Search Base / User DN: Base DN under which to perform user search (`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**. + + + + 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) \ No newline at end of file diff --git a/docs/documentation/platform/ldap/overview.mdx b/docs/documentation/platform/ldap/overview.mdx new file mode 100644 index 000000000..d19095b7c --- /dev/null +++ b/docs/documentation/platform/ldap/overview.mdx @@ -0,0 +1,23 @@ +--- +title: "LDAP Overview" +description: "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) + +To note, configuring LDAP retains the end-to-end encrypted architecture of 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 + +Check out the general instructions for configuring LDAP [here](/documentation/platform/ldap/general). 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/scim/azure.mdx b/docs/documentation/platform/scim/azure.mdx new file mode 100644 index 000000000..45f95e135 --- /dev/null +++ b/docs/documentation/platform/scim/azure.mdx @@ -0,0 +1,74 @@ +--- +title: "Azure SCIM" +description: "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 team@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..68bb9b66f --- /dev/null +++ b/docs/documentation/platform/scim/jumpcloud.mdx @@ -0,0 +1,64 @@ +--- +title: "JumpCloud SCIM" +description: "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 team@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 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 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..4baa19815 --- /dev/null +++ b/docs/documentation/platform/scim/okta.mdx @@ -0,0 +1,70 @@ +--- +title: "Okta SCIM" +description: "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 team@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 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** and **Push Profile Updates**. + - 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 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..deec8b630 --- /dev/null +++ b/docs/documentation/platform/scim/overview.mdx @@ -0,0 +1,32 @@ +--- +title: "SCIM Overview" +description: "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 team@infisical.com to purchase an enterprise license to use it. + + +You can configure your organization in Infisical to have members 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-rotation/postgres.mdx b/docs/documentation/platform/secret-rotation/postgres.mdx index f70e8a70b..b11ae1d76 100644 --- a/docs/documentation/platform/secret-rotation/postgres.mdx +++ b/docs/documentation/platform/secret-rotation/postgres.mdx @@ -1,21 +1,17 @@ --- title: "PostgreSQL/CockroachDB" -description: "Rotated database user password of a postgreSQL or cockroach db" +description: "Rotated database user password of a PostgreSQL or Cockroach DB" --- Infisical will update periodically the provided database user's password. - - 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 -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. 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. ## Rotation Configuration @@ -34,4 +30,4 @@ Infisical will update periodically the provided database user's password. - Finally select the secrets in your provided board to replace with new secret after each rotation - Your done and good to go. -Congrats. You have 10x your PostgreSQL/CockroachDB access security. +Congratulations. You have improved your PostgreSQL/CockroachDB access security. diff --git a/docs/documentation/platform/sso/azure.mdx b/docs/documentation/platform/sso/azure.mdx index 0f644834a..25303fed6 100644 --- a/docs/documentation/platform/sso/azure.mdx +++ b/docs/documentation/platform/sso/azure.mdx @@ -12,7 +12,7 @@ description: "Configure Azure SAML for Infisical SSO" - 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. @@ -101,6 +101,11 @@ description: "Configure Azure SAML for Infisical SSO" 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/google-saml.mdx b/docs/documentation/platform/sso/google-saml.mdx new file mode 100644 index 000000000..743c4e3ff --- /dev/null +++ b/docs/documentation/platform/sso/google-saml.mdx @@ -0,0 +1,95 @@ +--- +title: "Google SAML" +description: "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 team@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/jumpcloud.mdx b/docs/documentation/platform/sso/jumpcloud.mdx index 2273a8852..d25ec0b6d 100644 --- a/docs/documentation/platform/sso/jumpcloud.mdx +++ b/docs/documentation/platform/sso/jumpcloud.mdx @@ -12,7 +12,7 @@ description: "Configure JumpCloud SAML for Infisical SSO" - 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. @@ -81,6 +81,11 @@ description: "Configure JumpCloud SAML for Infisical SSO" 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/okta.mdx b/docs/documentation/platform/sso/okta.mdx index 576bd769a..bac8efb68 100644 --- a/docs/documentation/platform/sso/okta.mdx +++ b/docs/documentation/platform/sso/okta.mdx @@ -12,7 +12,7 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" - 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) @@ -84,6 +84,11 @@ description: "Configure Okta SAML 2.0 for Infisical SSO" 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. + diff --git a/docs/documentation/platform/sso/overview.mdx b/docs/documentation/platform/sso/overview.mdx index 8f4b3bb0f..e1fd25957 100644 --- a/docs/documentation/platform/sso/overview.mdx +++ b/docs/documentation/platform/sso/overview.mdx @@ -3,13 +3,13 @@ title: "SSO Overview" description: "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). - + 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). @@ -22,3 +22,4 @@ 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) +- [Google SAML](/documentation/platform/sso/google-saml) 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..8d105c1d6 Binary files /dev/null and b/docs/images/platform/ldap/ldap-config.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..dcc7ffc96 Binary files /dev/null and b/docs/images/platform/ldap/ldap-toggle.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..b20ceddca 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/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/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/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/infisical-agent/overview.mdx b/docs/infisical-agent/overview.mdx index c98090289..f62194ad8 100644 --- a/docs/infisical-agent/overview.mdx +++ b/docs/infisical-agent/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Infisical Agent" +title: "Overview" --- 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. @@ -51,6 +51,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: `60s` (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 +79,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/docker-intro.mdx b/docs/integrations/platforms/docker-intro.mdx index 5f23584c8..2823fc8ca 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/internals/components.mdx b/docs/internals/components.mdx index 02c6d3692..29522b0bb 100644 --- a/docs/internals/components.mdx +++ b/docs/internals/components.mdx @@ -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/mint.json b/docs/mint.json index b67493aeb..8f1835d5f 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -146,7 +146,25 @@ "documentation/platform/sso/gitlab", "documentation/platform/sso/okta", "documentation/platform/sso/azure", - "documentation/platform/sso/jumpcloud" + "documentation/platform/sso/jumpcloud", + "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" ] } ] @@ -154,27 +172,24 @@ { "group": "Self-host Infisical", "pages": [ + "self-hosting/overview", + "self-hosting/configuration/requirements", { - "group": "Deployment options", + "group": "Installation methods", "pages": [ - "self-hosting/overview", "self-hosting/deployment-options/standalone-infisical", "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", + { + "group": "Guides", + "pages": [ + "self-hosting/configuration/schema-migrations", + "self-hosting/guides/mongo-to-postgres" + ] + }, "self-hosting/faq" ] }, @@ -216,10 +231,6 @@ "infisical-agent/overview" ] }, - { - "group": "Integrations", - "pages": ["integrations/overview"] - }, { "group": "Infrastructure Integrations", "pages": [ @@ -239,7 +250,7 @@ ] }, { - "group": "3rd-party Integrations", + "group": "Native Integrations", "pages": [ { "group": "AWS", @@ -252,39 +263,49 @@ "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": "View more", + "pages": [ + "integrations/cloud/heroku", + "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/cloud-66", + "integrations/cloud/windmill" + ] + } ] }, { "group": "CI/CD Integrations", "pages": [ "integrations/cloud/teamcity", - "integrations/cloud/checkly", "integrations/cicd/githubactions", "integrations/cicd/gitlab", - "integrations/cicd/circleci", - "integrations/cicd/travisci", - "integrations/cicd/bitbucket", - "integrations/cicd/codefresh", - "integrations/cicd/jenkins" + { + "group": "View more", + "pages": [ + "integrations/cicd/circleci", + "integrations/cicd/travisci", + "integrations/cicd/bitbucket", + "integrations/cicd/codefresh", + "integrations/cicd/jenkins", + "integrations/cloud/checkly" + ] + } ] }, { @@ -294,20 +315,25 @@ "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" + ] + } ] }, { @@ -377,9 +403,14 @@ { "group": "Projects", "pages": [ + "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/invite-member-to-workspace", + "api-reference/endpoints/workspaces/remove-member-from-workspace", "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", diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index faabd794c..7712caaf1 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -42,7 +42,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/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 ed9f843a3..4bb56ebcd 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -1,188 +1,426 @@ --- -title: "All environment variables" -description: "Configure your environment variables when self-hosting Infisical." +title: "Configurations" +description: "Configure environment variables for self-hosted Infisical" --- -## Environment variables -Depending on your chosen self hosted deployment method, you may need to configured at least the required environment variable listed below. -Other environment variables are listed below to increase the functionality of your self hosted instance based on your use case. +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. - - - - Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` - +## General platform +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` - - - - Postgres database connection string. - - - - 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` - - -#### Settings - -{" "} - - - Only allow users who are invited to sign up + + Must be a random 32 byte base64 string. Can be generated with `openssl rand -base64 32` - - Site URL - should be an absolute URL including the protocol (e.g. https://app.infisical.com) + + 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 - - - + + + 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) + + + + + + 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. + + + + + 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. + + + + + + + +## SSO based login +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. + + + + + + +## 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..262c7fb7c --- /dev/null +++ b/docs/self-hosting/configuration/requirements.mdx @@ -0,0 +1,71 @@ +--- +title: "Requirements" +description: "" +--- + +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:**Β 1 vCPU / 2 GB RAM / 10 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 1 vCPU, 1 GB RAM, and 1GB 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..6a94af751 --- /dev/null +++ b/docs/self-hosting/configuration/schema-migrations.mdx @@ -0,0 +1,60 @@ +--- +title: "Schema migration" +description: "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 through this process. + +### 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/docker-compose.mdx b/docs/self-hosting/deployment-options/docker-compose.mdx index 771d0836f..583fe1674 100644 --- a/docs/self-hosting/deployment-options/docker-compose.mdx +++ b/docs/self-hosting/deployment-options/docker-compose.mdx @@ -67,7 +67,8 @@ View all available configurations [here](/self-hosting/configuration/envars). The default .env file contains credentials that are intended solely for testing purposes. - For production use, please generate a new `ENCRYPTION_KEY` and `AUTH_SECRET`. Instructions to do so, can be found [here](/self-hosting/configuration/envars) + 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 @@ -77,4 +78,6 @@ Run the command below to start Infisical and all related services. 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`. \ No newline at end of file +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) \ 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..ae27207cd 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: "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 [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 Halm 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/standalone-infisical.mdx b/docs/self-hosting/deployment-options/standalone-infisical.mdx index 5da640337..740792578 100644 --- a/docs/self-hosting/deployment-options/standalone-infisical.mdx +++ b/docs/self-hosting/deployment-options/standalone-infisical.mdx @@ -7,19 +7,32 @@ 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/faq.mdx b/docs/self-hosting/faq.mdx index 6cef86b9c..598d408ae 100644 --- a/docs/self-hosting/faq.mdx +++ b/docs/self-hosting/faq.mdx @@ -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..f8a6cca0f --- /dev/null +++ b/docs/self-hosting/guides/mongo-to-postgres.mdx @@ -0,0 +1,201 @@ +--- +title: "Migrate Mongo to Postgres" +description: "How to migrate from MongoDB to PostgreSQL for Infisical" +--- + +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..f8089719d 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -14,13 +14,6 @@ Choose from a variety of deployment options listed below to get started. Use the fully packaged docker image to deploy Infisical anywhere - - Automatically create and deploy Infisical on to a Kubernetes cluster - 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 - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6567504c..41005ba7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,5 +1,5 @@ { - "name": "frontend", + "name": "npm-proj-1709146141702-0.772936286416932EMIzNi", "lockfileVersion": 3, "requires": true, "packages": { @@ -49,7 +49,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", @@ -69,7 +69,7 @@ "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", - "posthog-js": "^1.58.0", + "posthog-js": "^1.105.4", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", @@ -83,9 +83,9 @@ "react-markdown": "^8.0.3", "react-redux": "^8.0.2", "react-table": "^7.8.0", - "sanitize-html": "^2.11.0", + "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", @@ -95,7 +95,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", @@ -2488,6 +2488,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", @@ -3244,6 +3253,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", @@ -6607,6 +7047,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", @@ -8972,9 +9435,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", @@ -9273,6 +9737,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", @@ -9286,6 +9787,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", @@ -9369,6 +9871,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", @@ -9379,6 +9882,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", @@ -9657,6 +10161,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", @@ -10384,9 +10889,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" @@ -10974,6 +11479,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" }, @@ -11026,6 +11532,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" } @@ -11667,6 +12174,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" } @@ -12741,6 +13249,7 @@ "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" } @@ -12882,7 +13391,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", @@ -13468,7 +13978,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", @@ -13719,7 +14230,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", @@ -14445,6 +14957,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", @@ -16889,6 +17402,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" }, @@ -16992,7 +17506,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", @@ -17041,7 +17556,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", @@ -17218,6 +17734,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" }, @@ -17229,6 +17746,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" }, @@ -17237,9 +17755,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" }, @@ -17253,7 +17772,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", @@ -17264,7 +17784,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", @@ -18577,17 +19098,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.4", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.4.tgz", + "integrity": "sha512-hazxQYi4nxSqktu0Hh1xCV+sJCpN8mp5E5Ei/cfEa2nsb13xQbzn81Lf3VIDA0xMU1mXxNRStntlY267eQVC/w==", "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", @@ -18612,12 +19144,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", @@ -18631,6 +19165,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", @@ -18642,6 +19177,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", @@ -18944,6 +19480,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" @@ -19182,7 +19719,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", @@ -19266,6 +19804,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", @@ -19279,12 +19818,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" } @@ -20603,9 +21144,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", @@ -20853,25 +21394,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": { @@ -20949,6 +21507,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", @@ -20968,6 +21527,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", @@ -21351,12 +21911,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": { @@ -21819,19 +22383,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", @@ -22355,6 +22924,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" }, @@ -23569,9 +24139,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" }, @@ -23580,7 +24150,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 2c63f7956..8acd377f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,7 +57,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", @@ -77,7 +77,7 @@ "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", - "posthog-js": "^1.58.0", + "posthog-js": "^1.105.4", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", @@ -91,9 +91,9 @@ "react-markdown": "^8.0.3", "react-redux": "^8.0.2", "react-table": "^7.8.0", - "sanitize-html": "^2.11.0", + "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", @@ -103,7 +103,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/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx index 9f109b931..8ad06375b 100644 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ b/frontend/src/components/basic/table/ProjectUsersTable.tsx @@ -59,8 +59,8 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa 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 handleDelete = async (email: string) => { + await deleteUserFromWorkspaceMutateAsync({ emails: [email], workspaceId }); }; const handleRoleUpdate = async (index: number, e: string) => { @@ -368,7 +368,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa myRole !== "member" ? (
diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index 4e2d981a7..c0e5d4c21 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -13,12 +13,12 @@ nacl.util = require("tweetnacl-util"); */ const generateKeyPair = () => { const pair = nacl.box.keyPair(); - - return ({ - publicKey: nacl.util.encodeBase64(pair.publicKey), - privateKey: nacl.util.encodeBase64(pair.secretKey) - }); -} + + return { + publicKey: nacl.util.encodeBase64(pair.publicKey), + privateKey: nacl.util.encodeBase64(pair.secretKey) + }; +}; type EncryptAsymmetricProps = { plaintext: string; @@ -29,27 +29,19 @@ type EncryptAsymmetricProps = { /** * Verify that private key [privateKey] is the one that corresponds to * the public key [publicKey] - * @param {Object} + * @param {Object} * @param {String} - base64-encoded Nacl private key * @param {String} - base64-encoded Nacl public key */ -const verifyPrivateKey = ({ - privateKey, - publicKey -}: { - privateKey: string; - publicKey: string; -}) => { +const verifyPrivateKey = ({ privateKey, publicKey }: { privateKey: string; publicKey: string }) => { const derivedPublicKey = nacl.util.encodeBase64( - nacl.box.keyPair.fromSecretKey( - nacl.util.decodeBase64(privateKey) - ).publicKey + nacl.box.keyPair.fromSecretKey(nacl.util.decodeBase64(privateKey)).publicKey ); - + if (derivedPublicKey !== publicKey) { throw new Error("Failed to verify private key"); } -} +}; /** * Derive a key from password [password] and salt [salt] using Argon2id @@ -218,7 +210,14 @@ const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps): try { plaintext = aes.decrypt({ ciphertext, iv, tag, secret: key }); } catch (err) { - console.log("Failed to perform decryption"); + console.log("Failed to decrypt with the following parameters", { + ciphertext, + iv, + tag, + key + }); + console.log("Failed to perform decryption", err); + process.exit(1); } @@ -229,7 +228,8 @@ export { decryptAssymmetric, decryptSymmetric, deriveArgonKey, - encryptAssymmetric, + encryptAssymmetric, encryptSymmetric, generateKeyPair, - verifyPrivateKey}; + verifyPrivateKey +}; diff --git a/frontend/src/components/utilities/secrets/encryptSecrets.ts b/frontend/src/components/utilities/secrets/encryptSecrets.ts index 610e2a6f1..943502cdf 100644 --- a/frontend/src/components/utilities/secrets/encryptSecrets.ts +++ b/frontend/src/components/utilities/secrets/encryptSecrets.ts @@ -56,7 +56,6 @@ const encryptSecrets = async ({ publicKey: wsKey.sender.publicKey, privateKey: PRIVATE_KEY }); - } else { // case: a (shared) key does not exist for the workspace randomBytes = crypto.randomBytes(16).toString("hex"); @@ -116,7 +115,6 @@ const encryptSecrets = async ({ return result; }); - } catch (error) { console.log("Error while encrypting secrets"); } diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 87e278119..dc0e1a491 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -30,7 +30,7 @@ export const Checkbox = ({
-