diff --git a/.github/values.yaml b/.github/values.yaml deleted file mode 100644 index 1b3ffd87a..000000000 --- a/.github/values.yaml +++ /dev/null @@ -1,57 +0,0 @@ -## @section Common parameters -## - -## @param nameOverride Override release name -## -nameOverride: "" -## @param fullnameOverride Override release fullname -## -fullnameOverride: "" - -## @section Infisical backend parameters -## Documentation : https://infisical.com/docs/self-hosting/deployments/kubernetes -## - -infisical: - autoDatabaseSchemaMigration: false - - enabled: false - - name: infisical - replicaCount: 3 - image: - repository: infisical/staging_infisical - tag: "latest" - pullPolicy: Always - - deploymentAnnotations: - secrets.infisical.com/auto-reload: "true" - - kubeSecretRef: "managed-secret" - -ingress: - ## @param ingress.enabled Enable ingress - ## - enabled: true - ## @param ingress.ingressClassName Ingress class name - ## - ingressClassName: nginx - ## @param ingress.nginx.enabled Ingress controller - ## - # nginx: - # enabled: true - ## @param ingress.annotations Ingress annotations - ## - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-prod" - hostName: "gamma.infisical.com" - tls: - - secretName: letsencrypt-prod - hosts: - - gamma.infisical.com - -postgresql: - enabled: false - -redis: - enabled: false diff --git a/.github/workflows/check-fe-ts-and-lint.yml b/.github/workflows/check-fe-ts-and-lint.yml index 9b4d363cf..c9b38d068 100644 --- a/.github/workflows/check-fe-ts-and-lint.yml +++ b/.github/workflows/check-fe-ts-and-lint.yml @@ -30,6 +30,6 @@ jobs: - name: 🏗️ Run Type check run: npm run type:check working-directory: frontend - - name: 🏗️ Run Link check - run: npm run lint:fix + - name: 🏗️ Run Lint check + run: npm run lint working-directory: frontend diff --git a/.github/workflows/helm-release-infisical-core.yml b/.github/workflows/helm-release-infisical-core.yml index ebce7cbe0..49118a5ae 100644 --- a/.github/workflows/helm-release-infisical-core.yml +++ b/.github/workflows/helm-release-infisical-core.yml @@ -30,6 +30,8 @@ jobs: - name: Set up chart-testing uses: helm/chart-testing-action@v2.7.0 + with: + yamale_version: "6.0.0" - name: Run chart-testing (lint) run: ct lint --config ct.yaml --charts helm-charts/infisical-standalone-postgres @@ -54,7 +56,7 @@ jobs: --config ct.yaml \ --charts helm-charts/infisical-standalone-postgres \ --helm-extra-args="--timeout=300s" \ - --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.autoDatabaseSchemaMigration=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.132.2-postgres" \ + --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.151.0" \ --namespace infisical-standalone-postgres release: diff --git a/.github/workflows/nightly-tag-generation.yml b/.github/workflows/nightly-tag-generation.yml index b2704f96c..ff15b61d8 100644 --- a/.github/workflows/nightly-tag-generation.yml +++ b/.github/workflows/nightly-tag-generation.yml @@ -1,8 +1,6 @@ name: Generate Nightly Tag on: - schedule: - - cron: '0 0 * * *' # Run daily at midnight UTC workflow_dispatch: # Allow manual triggering for testing permissions: diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 486ff12b2..5d9f384f7 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -65,6 +65,15 @@ jobs: INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} DD_GIT_REPOSITORY_URL=${{ github.server_url }}/${{ github.repository }} DD_GIT_COMMIT_SHA=${{ github.sha }} + - name: Snyk to check Docker image for vulnerabilities + continue-on-error: true + uses: snyk/actions/docker@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + image: infisical/infisical:${{ steps.extract_version.outputs.version }} + command: monitor + args: --file=Dockerfile.standalone-infisical --project-name="infisical-core-docker-image" infisical-fips-standalone: name: Build infisical standalone image postgres @@ -126,7 +135,9 @@ jobs: TAG_NAME="${{ github.ref_name }}" echo "Checking for tag: $TAG_NAME" - if gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME --silent 2>/dev/null; then + EXACT_MATCH=$(gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME | jq -r 'if type == "array" then .[].ref else .ref end' | grep -x "refs/tags/$TAG_NAME") + + if [ "$EXACT_MATCH" == "refs/tags/$TAG_NAME" ]; then echo "Tag $TAG_NAME already exists, skipping..." else echo "Creating tag in Infisical/infisical-omnibus: $TAG_NAME" @@ -141,4 +152,4 @@ jobs: echo "Successfully created tag $TAG_NAME" fi env: - GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }} diff --git a/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml b/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml index 50cb9ecb0..f960aa6ff 100644 --- a/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml +++ b/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml @@ -33,6 +33,8 @@ jobs: - name: Set up chart-testing uses: helm/chart-testing-action@v2.7.0 + with: + yamale_version: "6.0.0" - name: Run chart-testing (lint) run: ct lint --config ct.yaml --charts helm-charts/infisical-standalone-postgres @@ -64,5 +66,5 @@ jobs: --config ct.yaml \ --charts helm-charts/infisical-standalone-postgres \ --helm-extra-args="--timeout=300s" \ - --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.autoDatabaseSchemaMigration=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.132.2-postgres --set infisical.autoBootstrap.enabled=true" \ + --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.151.0 --set infisical.autoBootstrap.enabled=true" \ --namespace infisical-standalone-postgres diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index 974ce4a42..ab1d6fbb7 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id ARG CAPTCHA_SITE_KEY=captcha-site-key -FROM node:20-slim AS base +FROM node:20.19.5-trixie-slim AS base + +# Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230 +RUN npm install -g npm@10.9.0 FROM base AS frontend-dependencies WORKDIR /app @@ -155,7 +158,7 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.41.89 \ + && apt-get update && apt-get install -y infisical=0.43.14 \ && rm -rf /var/lib/apt/lists/* RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 9ca5e1dea..01c9a737b 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id ARG CAPTCHA_SITE_KEY=captcha-site-key -FROM node:20-slim AS base +FROM node:20.19.5-trixie-slim AS base + +# Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230 +RUN npm install -g npm@10.9.0 FROM base AS frontend-dependencies @@ -139,7 +142,7 @@ RUN apt-get update && apt-get install -y \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.41.89 \ + && apt-get update && apt-get install -y infisical=0.43.14 \ && rm -rf /var/lib/apt/lists/* WORKDIR / diff --git a/backend/Dockerfile b/backend/Dockerfile index bca974f26..a16d2b1fe 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM node:20-slim AS build +FROM node:20.19.5-trixie-slim AS build WORKDIR /app @@ -26,7 +26,7 @@ COPY . . RUN npm run build # Production stage -FROM node:20-slim +FROM node:20.19.5-trixie-slim WORKDIR /app ENV npm_config_cache /home/node/.npm @@ -55,7 +55,7 @@ COPY --from=build /app . # Install Infisical CLI RUN apt-get install -y curl bash && \ curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ - apt-get update && apt-get install -y infisical=0.41.89 git + apt-get update && apt-get install -y infisical=0.43.14 git HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ CMD node healthcheck.js diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index de5648797..5e0612128 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:20-slim +FROM node:20.19.5-trixie-slim # ? Setup a test SoftHSM module. In production a real HSM is used. @@ -49,25 +49,26 @@ RUN rm -fr ${SOFTHSM2_SOURCES} # Install pkcs11-tool RUN apt-get install -y opensc -RUN mkdir -p /etc/softhsm2/tokens && \ - softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - # ? App setup # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.41.89 + apt-get install -y infisical=0.43.14 WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json +COPY dev-entrypoint.sh dev-entrypoint.sh +RUN chmod +x dev-entrypoint.sh + RUN npm install COPY . . ENV HOST=0.0.0.0 +ENTRYPOINT ["/app/dev-entrypoint.sh"] CMD ["npm", "run", "dev:docker"] diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips index b954ccd50..f30b059f9 100644 --- a/backend/Dockerfile.dev.fips +++ b/backend/Dockerfile.dev.fips @@ -1,4 +1,4 @@ -FROM node:20-slim +FROM node:20.19.5-trixie-slim # ? Setup a test SoftHSM module. In production a real HSM is used. @@ -50,9 +50,6 @@ RUN rm -fr ${SOFTHSM2_SOURCES} # Install pkcs11-tool RUN apt-get install -y opensc -RUN mkdir -p /etc/softhsm2/tokens && \ - softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - WORKDIR /openssl-build RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ && tar -xf openssl-3.1.2.tar.gz \ @@ -70,13 +67,16 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.41.89 + apt-get install -y infisical=0.43.14 WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json +COPY dev-entrypoint.sh dev-entrypoint.sh +RUN chmod +x dev-entrypoint.sh + RUN npm install COPY . . @@ -87,4 +87,5 @@ ENV OPENSSL_MODULES=/usr/local/lib/ossl-modules # ENV NODE_OPTIONS=--force-fips # Note(Daniel): We can't set this on the node options because it may break for existing folks using the infisical/infisical-fips image. Instead we call crypto.setFips(true) at runtime. ENV FIPS_ENABLED=true +ENTRYPOINT ["/app/dev-entrypoint.sh"] CMD ["npm", "run", "dev:docker"] diff --git a/backend/dev-entrypoint.sh b/backend/dev-entrypoint.sh new file mode 100755 index 000000000..9cb3c0a5e --- /dev/null +++ b/backend/dev-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +update-ca-certificates + +# Initialize SoftHSM token if it doesn't exist +if [ ! -f /etc/softhsm2/tokens/auth-app.db ]; then + echo "Initializing SoftHSM token..." + mkdir -p /etc/softhsm2/tokens + softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 + echo "SoftHSM token initialized" +else + echo "SoftHSM token already exists, skipping initialization" +fi + + +exec "$@" \ No newline at end of file diff --git a/backend/e2e-test/routes/v1/secret-folder.spec.ts b/backend/e2e-test/routes/v1/secret-folder.spec.ts index 4d4bd7ab4..e954179a7 100644 --- a/backend/e2e-test/routes/v1/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v1/secret-folder.spec.ts @@ -40,7 +40,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +57,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -162,4 +162,25 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + 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: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/e2e-test/routes/v2/secret-folder.spec.ts b/backend/e2e-test/routes/v2/secret-folder.spec.ts index a2bed759a..92bcc92e5 100644 --- a/backend/e2e-test/routes/v2/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v2/secret-folder.spec.ts @@ -18,7 +18,7 @@ const createFolder = async (dto: { path: string; name: string }) => { return res.json().folder; }; -const deleteFolder = async (dto: { path: string; id: string }) => { +const deleteFolder = async (dto: { path: string; id: string; forceDelete?: boolean }) => { const res = await testServer.inject({ method: "DELETE", url: `/api/v2/folders/${dto.id}`, @@ -28,7 +28,8 @@ const deleteFolder = async (dto: { path: string; id: string }) => { body: { projectId: seedData1.project.id, environment: seedData1.environment.slug, - path: dto.path + path: dto.path, + forceDelete: dto.forceDelete ?? false } }); expect(res.statusCode).toBe(200); @@ -40,7 +41,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +58,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -86,7 +87,7 @@ describe("Secret Folder Router", async () => { folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el))) }); - await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id }))); + await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id, forceDelete: true }))); }); test("Update a deep folder", async () => { @@ -162,4 +163,26 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/e2e-test/routes/v2/service-token.spec.ts b/backend/e2e-test/routes/v2/service-token.spec.ts index 4f72987cb..d3a8b0f67 100644 --- a/backend/e2e-test/routes/v2/service-token.spec.ts +++ b/backend/e2e-test/routes/v2/service-token.spec.ts @@ -146,7 +146,8 @@ describe("Service token secret ops", async () => { let folderId = ""; beforeAll(async () => { initLogger(); - await initEnvConfig(testSuperAdminDAL, logger); + + await initEnvConfig(testHsmService, testKmsRootConfigDAL, testSuperAdminDAL, logger); serviceToken = await createServiceToken( [{ secretPath: "/**", environment: seedData1.environment.slug }], diff --git a/backend/e2e-test/routes/v3/secrets.spec.ts b/backend/e2e-test/routes/v3/secrets.spec.ts index 1e58c7f4a..db5953f29 100644 --- a/backend/e2e-test/routes/v3/secrets.spec.ts +++ b/backend/e2e-test/routes/v3/secrets.spec.ts @@ -158,7 +158,7 @@ describe("Secret V3 Router", async () => { let folderId = ""; beforeAll(async () => { initLogger(); - await initEnvConfig(testSuperAdminDAL, logger); + await initEnvConfig(testHsmService, testKmsRootConfigDAL, testSuperAdminDAL, logger); const projectKeyRes = await testServer.inject({ method: "GET", diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 085b8fe30..0f84dbee2 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -6,7 +6,7 @@ import { crypto } from "@app/lib/crypto/cryptography"; import path from "path"; import { seedData1 } from "@app/db/seed-data"; -import { getDatabaseCredentials, initEnvConfig } from "@app/lib/config/env"; +import { getDatabaseCredentials, getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { initLogger } from "@app/lib/logger"; import { main } from "@app/server/app"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; @@ -20,6 +20,8 @@ import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; import { buildRedisFromConfig } from "@app/lib/config/redis"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { bootstrapCheck } from "@app/server/boot-strap-check"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -28,6 +30,7 @@ export default { async setup() { const logger = initLogger(); const databaseCredentials = getDatabaseCredentials(logger); + const hsmConfig = getHsmConfig(logger); const db = initDbConnection({ dbConnectionUri: databaseCredentials.dbConnectionUri, @@ -35,7 +38,19 @@ export default { }); const superAdminDAL = superAdminDALFactory(db); - const envCfg = await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + const envCfg = await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const redis = buildRedisFromConfig(envCfg); await redis.flushdb("SYNC"); @@ -68,16 +83,14 @@ export default { await queue.initialize(); - const hsmModule = initializeHsmModule(envCfg); - hsmModule.initialize(); - const server = await main({ db, smtp, logger, queue, keyStore, - hsmModule: hsmModule.getModule(), + hsmService, + kmsRootConfigDAL, superAdminDAL, redis, envConfig: envCfg @@ -92,6 +105,10 @@ export default { // @ts-expect-error type globalThis.testSuperAdminDAL = superAdminDAL; // @ts-expect-error type + globalThis.testKmsRootConfigDAL = kmsRootConfigDAL; + // @ts-expect-error type + globalThis.testHsmService = hsmService; + // @ts-expect-error type globalThis.jwtAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.ACCESS_TOKEN, diff --git a/backend/package-lock.json b/backend/package-lock.json index db4e9e7dd..adda83043 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,7 +16,7 @@ "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sts": "^3.600.0", "@casl/ability": "^6.5.0", - "@elastic/elasticsearch": "^8.15.0", + "@elastic/elasticsearch": "^9.1.1", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^8.5.0", "@fastify/etag": "^5.1.0", @@ -63,7 +63,7 @@ "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", - "axios": "^1.11.0", + "axios": "^1.12.0", "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", @@ -74,7 +74,7 @@ "cron": "^3.1.7", "dd-trace": "^5.40.0", "dotenv": "^16.4.1", - "fastify": "^4.28.1", + "fastify": "^4.29.1", "fastify-plugin": "^4.5.1", "google-auth-library": "^9.9.0", "googleapis": "^137.1.0", @@ -141,6 +141,7 @@ "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/preset-env": "^7.18.10", "@babel/preset-react": "^7.24.7", + "@react-email/preview-server": "^4.3.0", "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", @@ -176,7 +177,7 @@ "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", - "react-email": "4.0.7", + "react-email": "^4.3.0", "rimraf": "^5.0.5", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", @@ -196,6 +197,19 @@ "node": ">=0.10.0" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -5150,30 +5164,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", - "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", - "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helpers": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -5221,16 +5237,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -5238,10 +5254,11 @@ } }, "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -5286,14 +5303,15 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", - "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -5447,6 +5465,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-hoist-variables": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", @@ -5473,29 +5501,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", - "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", - "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -5618,10 +5646,11 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", - "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -5642,26 +5671,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", - "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", - "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -6998,14 +7028,14 @@ } }, "node_modules/@babel/template": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", - "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.1", + "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" }, "engines": { @@ -7013,19 +7043,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", - "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.6", - "@babel/parser": "^7.25.6", - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.6", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" @@ -7048,15 +7078,6 @@ } } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/traverse/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -7064,9 +7085,9 @@ "dev": true }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7329,12 +7350,13 @@ "integrity": "sha512-d5RjycE+MObE/hU+8OM5Zp4VjTwiPLRa8299fj7muOmR16fb942z8byoMbCErnGh0lBevvgkGrLclQDvINbIyg==" }, "node_modules/@elastic/elasticsearch": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-8.15.0.tgz", - "integrity": "sha512-mG90EMdTDoT6GFSdqpUAhWK9LGuiJo6tOWqs0Usd/t15mPQDj7ZqHXfCBqNkASZpwPZpbAYVjd57S6nbUBINCg==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.1.1.tgz", + "integrity": "sha512-s/JZtHZjtbAYC2gdSzm4LLOSReR724e7cf7ZauIAZlGvAyMgZPZCJpq7xHazSy4rZZhule4ubMs4vepBgWvKQA==", "license": "Apache-2.0", "dependencies": { - "@elastic/transport": "^8.7.0", + "@elastic/transport": "^9.0.1", + "apache-arrow": "18.x - 20.x", "tslib": "^2.4.0" }, "engines": { @@ -7342,30 +7364,55 @@ } }, "node_modules/@elastic/transport": { - "version": "8.7.1", - "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-8.7.1.tgz", - "integrity": "sha512-2eeMVkz57Ayxv+UAZkIKzzrUu7nm96jr3+N3kLfbBqALYe2jwDpLr9pR0jc/x9HyJKAM909YGaNlHFDZeb0+Mw==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-9.2.0.tgz", + "integrity": "sha512-2HpxEX9eQE/viokiKHqRa1n3RaFqNKoOU5gc7AOJ4ahG9xZbim+Z3OdBwshW9aKuFeIn1WPtZxSrfghZ0UJFtg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "1.x", - "debug": "^4.3.4", - "hpagent": "^1.0.0", + "@opentelemetry/core": "2.x", + "debug": "^4.4.1", + "hpagent": "^1.2.0", "ms": "^2.1.3", - "secure-json-parse": "^2.4.0", - "tslib": "^2.4.0", - "undici": "^6.12.0" + "secure-json-parse": "^4.0.0", + "tslib": "^2.8.1", + "undici": "^7.16.0" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@elastic/transport/node_modules/@opentelemetry/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.1.0.tgz", + "integrity": "sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@elastic/transport/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", + "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" } }, "node_modules/@elastic/transport/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -7376,16 +7423,26 @@ } } }, - "node_modules/@elastic/transport/node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "license": "MIT" + "node_modules/@elastic/transport/node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "dev": true, "license": "MIT", "optional": true, @@ -7732,6 +7789,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -8234,6 +8308,48 @@ "p-limit": "^3.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@gitbeaker/core": { "version": "42.5.0", "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-42.5.0.tgz", @@ -8535,10 +8651,20 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", "cpu": [ "arm64" ], @@ -8555,13 +8681,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", "cpu": [ "x64" ], @@ -8578,13 +8704,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.3" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", "cpu": [ "arm64" ], @@ -8599,9 +8725,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", "cpu": [ "x64" ], @@ -8616,9 +8742,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", "cpu": [ "arm" ], @@ -8633,9 +8759,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", "cpu": [ "arm64" ], @@ -8649,10 +8775,27 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", "cpu": [ "s390x" ], @@ -8667,9 +8810,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", "cpu": [ "x64" ], @@ -8684,9 +8827,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", "cpu": [ "arm64" ], @@ -8701,9 +8844,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "cpu": [ "x64" ], @@ -8718,9 +8861,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", "cpu": [ "arm" ], @@ -8737,13 +8880,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", "cpu": [ "arm64" ], @@ -8760,13 +8903,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", "cpu": [ "s390x" ], @@ -8783,13 +8949,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "cpu": [ "x64" ], @@ -8806,13 +8972,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", "cpu": [ "arm64" ], @@ -8829,13 +8995,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", "cpu": [ "x64" ], @@ -8852,13 +9018,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", "cpu": [ "wasm32" ], @@ -8866,7 +9032,7 @@ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.5.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -8875,10 +9041,30 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", "cpu": [ "ia32" ], @@ -8896,9 +9082,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", "cpu": [ "x64" ], @@ -9026,6 +9212,29 @@ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -9084,17 +9293,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { @@ -9116,20 +9322,34 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, - "engines": { - "node": ">=6.0.0" + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", @@ -9232,6 +9452,26 @@ "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" }, + "node_modules/@lottiefiles/dotlottie-react": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.3.tgz", + "integrity": "sha512-V4FfdYlqzjBUX7f0KV6vfQOOI0Cp+3XeG/ZqSDFSEVg5P7fpROpDv5/I9aTM8sOCESK1SWT96Fem+QVUnBV1wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lottiefiles/dotlottie-web": "0.42.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@lottiefiles/dotlottie-web": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.42.0.tgz", + "integrity": "sha512-Zr2LCaOAoPCsdAQgeLyCSiQ1+xrAJtRCyuEYDj0qR5heUwpc+Pxbb88JyTVumcXFfKOBMOMmrlsTScLz2mrvQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -9453,16 +9693,16 @@ ] }, "node_modules/@next/env": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", - "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", + "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", "dev": true, "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", - "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", + "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", "cpu": [ "arm64" ], @@ -9477,9 +9717,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", - "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", + "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", "cpu": [ "x64" ], @@ -9494,9 +9734,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", - "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", + "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", "cpu": [ "arm64" ], @@ -9511,9 +9751,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", - "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", + "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", "cpu": [ "arm64" ], @@ -9528,9 +9768,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", - "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", + "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", "cpu": [ "x64" ], @@ -9545,9 +9785,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", - "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", + "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", "cpu": [ "x64" ], @@ -9562,9 +9802,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", - "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", + "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", "cpu": [ "arm64" ], @@ -9579,9 +9819,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", - "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", + "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", "cpu": [ "x64" ], @@ -11100,6 +11340,787 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@react-email/body": { "version": "0.0.11", "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.11.tgz", @@ -11314,6 +12335,849 @@ "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/preview-server": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@react-email/preview-server/-/preview-server-4.3.0.tgz", + "integrity": "sha512-cUaSrxezCzdg2hF6PzIxVrtagLdw3z3ovHeB3y2RDkmDZpp7EeIoNyJm22Ch2S0uAqTZNAgqu67aroLn3mFC1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.26.10", + "@babel/parser": "7.27.0", + "@babel/traverse": "7.27.0", + "@lottiefiles/dotlottie-react": "0.13.3", + "@radix-ui/colors": "3.0.0", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@types/node": "22.14.1", + "@types/normalize-path": "3.0.2", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "@types/webpack": "5.28.5", + "autoprefixer": "10.4.21", + "clsx": "2.1.1", + "esbuild": "0.25.10", + "framer-motion": "12.23.22", + "json5": "2.2.3", + "log-symbols": "4.1.0", + "module-punycode": "npm:punycode@2.3.1", + "next": "15.5.2", + "node-html-parser": "7.0.1", + "ora": "5.4.1", + "pretty-bytes": "6.1.1", + "prism-react-renderer": "2.4.1", + "react": "19.0.0", + "react-dom": "19.0.0", + "sharp": "0.34.4", + "socket.io-client": "4.8.1", + "sonner": "2.0.3", + "source-map-js": "1.2.1", + "spamc": "0.0.5", + "stacktrace-parser": "0.1.11", + "tailwind-merge": "3.2.0", + "tailwindcss": "3.4.0", + "use-debounce": "10.0.4", + "zod": "3.24.3" + } + }, + "node_modules/@react-email/preview-server/node_modules/@babel/parser": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/@babel/traverse": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", + "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.27.0", + "@babel/parser": "^7.27.0", + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-email/preview-server/node_modules/@types/node": { + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@react-email/preview-server/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/@react-email/preview-server/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@react-email/preview-server/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/@react-email/preview-server/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@react-email/preview-server/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-email/preview-server/node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-email/preview-server/node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-email/preview-server/node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/@react-email/preview-server/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, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@react-email/preview-server/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-email/preview-server/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-email/preview-server/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@react-email/render": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.6.tgz", @@ -12996,18 +14860,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -13088,6 +14944,18 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -13115,6 +14983,28 @@ "@types/ms": "*" } }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -13284,6 +15174,13 @@ "@types/node": "*" } }, + "node_modules/@types/normalize-path": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/normalize-path/-/normalize-path-3.0.2.tgz", + "integrity": "sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/oauth": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.4.tgz", @@ -13427,6 +15324,13 @@ "pkcs11js": "*" } }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prompt-sync": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/prompt-sync/-/prompt-sync-4.2.3.tgz", @@ -13454,6 +15358,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", + "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.14.tgz", @@ -13599,6 +15513,18 @@ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" }, + "node_modules/@types/webpack": { + "version": "5.28.5", + "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", + "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tapable": "^2.2.0", + "webpack": "^5" + } + }, "node_modules/@types/whatwg-url": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", @@ -14073,6 +15999,167 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, "node_modules/@xmldom/is-dom-node": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", @@ -14091,6 +16178,20 @@ "node": ">=10.0.0" } }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@yao-pkg/pkg": { "version": "5.12.0", "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.12.0.tgz", @@ -14365,9 +16466,10 @@ } }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -14383,6 +16485,19 @@ "acorn": "^8" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -14490,6 +16605,19 @@ } } }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", @@ -14546,6 +16674,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/apache-arrow": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-20.0.0.tgz", + "integrity": "sha512-JUeK0jFRUd7rbmrhhzR3O2KXjLaZ4YYYFOptyUfxOsMIoZCPi6bZR58gVi/xi3HTBMPseXm9PXyQ2V916930pA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^20.13.0", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -14665,6 +16813,28 @@ "node": ">=0.8.0" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-back": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", + "integrity": "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", @@ -14905,10 +17075,52 @@ "node": ">=8.0.0" } }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -15014,9 +17226,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -15161,6 +17373,16 @@ "node": ">=6.0.0" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.13.tgz", + "integrity": "sha512-7s16KR8io8nIBWQyCYhmFhd+ebIzb9VKTzki+wOJXHTxTnV6+mFGH3+Jwn1zoKaY9/H9T/0BcKCZnzXljPnpSQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -15311,6 +17533,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/botbuilder": { "version": "4.23.2", "resolved": "https://registry.npmjs.org/botbuilder/-/botbuilder-4.23.2.tgz", @@ -15590,9 +17819,9 @@ ] }, "node_modules/browserslist": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", - "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -15608,11 +17837,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001629", - "electron-to-chromium": "^1.4.796", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.16" + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -15662,6 +17893,13 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bullmq": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", @@ -15707,18 +17945,6 @@ "esbuild": ">=0.17" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -15855,16 +18081,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -15885,6 +18110,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -15902,10 +18143,20 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001639", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001639.tgz", - "integrity": "sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg==", + "version": "1.0.30001748", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz", + "integrity": "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==", "dev": true, "funding": [ { @@ -15920,7 +18171,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/cassandra-driver": { "version": "4.7.2", @@ -15965,6 +18217,58 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk-template/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -16012,13 +18316,37 @@ "node": ">=10" } }, - "node_modules/cipher-base": { + "node_modules/chrome-trace-event": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cipher-base": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.5.tgz", + "integrity": "sha512-xq7ICKB4TMHUx7Tz1L9O2SGKOhYMOTR32oir45Bq28/AQTpHogKgHcoYFSdRbMtddl+ozNXfXY9jWcgYKmde0w==", + "license": "MIT", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" } }, "node_modules/cjs-module-lexer": { @@ -16145,6 +18473,16 @@ "node": ">=0.8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -16239,6 +18577,44 @@ "node": ">= 0.8" } }, + "node_modules/command-line-args": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.1.tgz", + "integrity": "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.2.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-usage": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.3.tgz", + "integrity": "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.0", + "typical": "^7.1.1" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -16252,6 +18628,13 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", @@ -16269,6 +18652,16 @@ "express-session": ">=1" } }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -16414,6 +18807,49 @@ "resolved": "https://registry.npmjs.org/crypto-randomuuid/-/crypto-randomuuid-1.0.0.tgz", "integrity": "sha512-/RC5F4l1SCqD/jazwUF6+t34Cd8zTSAGZ7rvvZu1whZUhD2a5MOGKjSGowoGcpj/cbVZk1ZODIooJEQQq3nNAA==" }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz", @@ -16816,9 +19252,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -16832,12 +19268,26 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dev-null": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", "license": "MIT" }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -16868,6 +19318,13 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -17028,10 +19485,11 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.4.816", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.816.tgz", - "integrity": "sha512-EKH5X5oqC6hLmiS7/vYtZHZFTNdhsYG5NVPRN6Yn0kQHNBlT59+xSM8HBy66P5fxWpKgZbPqb+diC64ng295Jw==", - "dev": true + "version": "1.5.232", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.232.tgz", + "integrity": "sha512-ENirSe7wf8WzyPCibqKUG1Cg43cPaxH4wRR7AJsX7MCABCHBIOFqvaYODSLKUuZdraxUTHRE/0A2Aq8BYKEHOg==", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "10.3.0", @@ -17092,6 +19550,60 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -17143,10 +19655,11 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -17259,6 +19772,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -17354,9 +19874,10 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -18080,6 +20601,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -18225,9 +20753,9 @@ } }, "node_modules/fastify": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.28.1.tgz", - "integrity": "sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==", + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.1.tgz", + "integrity": "sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==", "funding": [ { "type": "github", @@ -18443,6 +20971,23 @@ "node": ">=14" } }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -18508,6 +21053,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "3.2.9", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", @@ -18540,19 +21091,27 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -18602,6 +21161,48 @@ "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==" }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.21", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -18920,6 +21521,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -18952,6 +21566,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -19062,6 +21686,13 @@ "node": ">= 6" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -19456,6 +22087,16 @@ "node": ">=0.10.0" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/helmet": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", @@ -20304,11 +22945,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -20354,8 +22996,7 @@ "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, "node_modules/isexe": { "version": "2.0.0", @@ -20421,6 +23062,57 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jmespath": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", @@ -20600,6 +23292,14 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -20611,6 +23311,13 @@ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -20846,6 +23553,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/knex": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/knex/-/knex-3.0.1.tgz", @@ -21143,6 +23860,16 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, "node_modules/local-pkg": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", @@ -21568,6 +24295,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -21797,6 +24537,17 @@ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" }, + "node_modules/module-punycode": { + "name": "punycode", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -21903,6 +24654,23 @@ "node": ">=16" } }, + "node_modules/motion-dom": { + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/mri": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", @@ -22109,16 +24877,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/next": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", - "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", + "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", "dev": true, "license": "MIT", "dependencies": { - "@next/env": "15.2.4", - "@swc/counter": "0.1.3", + "@next/env": "15.5.2", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -22130,19 +24896,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.4", - "@next/swc-darwin-x64": "15.2.4", - "@next/swc-linux-arm64-gnu": "15.2.4", - "@next/swc-linux-arm64-musl": "15.2.4", - "@next/swc-linux-x64-gnu": "15.2.4", - "@next/swc-linux-x64-musl": "15.2.4", - "@next/swc-win32-arm64-msvc": "15.2.4", - "@next/swc-win32-x64-msvc": "15.2.4", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "15.5.2", + "@next/swc-darwin-x64": "15.5.2", + "@next/swc-linux-arm64-gnu": "15.5.2", + "@next/swc-linux-arm64-musl": "15.5.2", + "@next/swc-linux-x64-gnu": "15.5.2", + "@next/swc-linux-x64-musl": "15.5.2", + "@next/swc-win32-arm64-msvc": "15.5.2", + "@next/swc-win32-x64-msvc": "15.5.2", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -22403,11 +25169,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/node-html-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", + "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "dev": true, + "license": "MIT" }, "node_modules/nodemailer": { "version": "6.9.9", @@ -22491,6 +25269,16 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -22529,11 +25317,63 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nwsapi": { "version": "2.2.18", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.18.tgz", "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==" }, + "node_modules/nypm": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz", + "integrity": "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^2.0.0", + "tinyexec": "^0.3.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/nypm/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -25580,6 +28420,15 @@ "node": ">=12" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -25609,6 +28458,50 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, "node_modules/postcss-load-config": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", @@ -25644,6 +28537,53 @@ } } }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -25772,6 +28712,19 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -25798,6 +28751,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -26047,6 +29014,20 @@ "node": ">=6" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/proto3-json-serializer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", @@ -26328,47 +29309,36 @@ } }, "node_modules/react-email": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.0.7.tgz", - "integrity": "sha512-XCXlfZLKv9gHd/ZwUEhCpRGc/FJLZGYczeuG1kVR/be2PlkwEB4gjX9ARBbRFv86ncbtpOu/wI6jD6kadRyAKw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.3.0.tgz", + "integrity": "sha512-XFHCSfhdlO7k5q2TYGwC0HsVh5Yn13YaOdahuJEUEOfOJKHEpSP4PKg7R/RiKFoK9cDvzunhY+58pXxz0vE2zA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "7.24.5", - "@babel/traverse": "7.25.6", - "chalk": "4.1.2", - "chokidar": "4.0.3", - "commander": "11.1.0", - "debounce": "2.0.0", - "esbuild": "0.25.0", - "glob": "10.3.4", - "log-symbols": "4.1.0", - "mime-types": "2.1.35", - "next": "15.2.4", - "normalize-path": "3.0.0", - "ora": "5.4.1", - "socket.io": "4.8.1" + "@babel/parser": "^7.27.0", + "@babel/traverse": "^7.27.0", + "chokidar": "^4.0.3", + "commander": "^13.0.0", + "debounce": "^2.0.0", + "esbuild": "^0.25.0", + "glob": "^11.0.0", + "jiti": "2.4.2", + "log-symbols": "^7.0.0", + "mime-types": "^3.0.0", + "normalize-path": "^3.0.0", + "nypm": "0.6.0", + "ora": "^8.0.0", + "prompts": "2.4.2", + "socket.io": "^4.8.1", + "tsconfig-paths": "4.2.0" }, "bin": { - "email": "dist/cli/index.js" + "email": "dist/index.js" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/react-email/node_modules/@babel/parser": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", - "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", - "dev": true, - "license": "MIT", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/react-email/node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -26760,80 +29730,6 @@ "node": ">=18" } }, - "node_modules/react-email/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-email/node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/react-email/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/react-email/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/react-email/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/react-email/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -26851,26 +29747,29 @@ } }, "node_modules/react-email/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/react-email/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/react-email/node_modules/esbuild": { @@ -26915,131 +29814,219 @@ } }, "node_modules/react-email/node_modules/glob": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.4.tgz", - "integrity": "sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.0.3", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" }, "bin": { - "glob": "dist/cjs/src/bin.js" + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-email/node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/react-email/node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-email/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "node_modules/react-email/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "@isaacs/cliui": "^8.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/react-email/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "node_modules/react-email/node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "dev": true, "license": "MIT", "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-email/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==", + "node_modules/react-email/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/react-email/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react-email/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "dev": true, "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 6" + "node": ">= 0.6" + } + }, + "node_modules/react-email/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-email/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/react-email/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/ora/node_modules/log-symbols/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-email/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/react-email/node_modules/readdirp": { @@ -27057,43 +30044,64 @@ } }, "node_modules/react-email/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-email/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/react-email/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/react-email/node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-email/node_modules/supports-color": { + "node_modules/react-email/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/react-is": { @@ -27117,6 +30125,98 @@ "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-cache/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -27698,6 +30798,26 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/scim-patch": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/scim-patch/-/scim-patch-0.8.3.tgz", @@ -27744,9 +30864,9 @@ "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==" }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -27834,6 +30954,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -27896,29 +31026,36 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -27927,40 +31064,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/sharp/node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, "node_modules/shebang-command": { @@ -28104,6 +31229,13 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/sjcl": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/sjcl/-/sjcl-1.0.8.tgz", @@ -28405,6 +31537,40 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -28517,6 +31683,17 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -28535,6 +31712,23 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spamc": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/spamc/-/spamc-0.0.5.tgz", + "integrity": "sha512-jYXItuZuiWZyG9fIdvgTUbp2MNRuyhuSwvvhhpPJd4JK/9oSZxkD7zAj53GJtowSlXwCJzLg6sCKAoE9wXsKgg==", + "dev": true + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -28637,6 +31831,29 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -28738,15 +31955,6 @@ "node": ">=4.0.0" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -29060,13 +32268,130 @@ "url": "https://opencollective.com/unts" } }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, + "node_modules/tailwind-merge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", + "integrity": "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==", "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", + "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/tailwindcss/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -29253,6 +32578,78 @@ "node": ">= 6" } }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -29316,6 +32713,13 @@ "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", "dev": true }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinypool": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", @@ -29355,6 +32759,20 @@ "resolved": "https://registry.npmjs.org/tlhunter-sorted-set/-/tlhunter-sorted-set-0.1.0.tgz", "integrity": "sha512-eGYW4bjf1DtrHzUYxYfAcSytpOkA44zsr7G2n3PV7yOUR23vmkGe3LL4R+1jL9OsXtbsFOwe8XtbCrabeaEFnw==" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -29571,9 +32989,9 @@ } }, "node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tsup": { @@ -30262,14 +33680,14 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", - "dev": true, + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -30339,6 +33757,15 @@ "node": ">=14.17" } }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/ufo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", @@ -30396,12 +33823,12 @@ "dev": true }, "node_modules/undici": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", - "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -30502,9 +33929,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -30520,9 +33947,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -30572,6 +34000,64 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-debounce": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz", + "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -31370,6 +34856,20 @@ "node": ">=18" } }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -31385,6 +34885,96 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/webpack": { + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.26.3", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -31450,15 +35040,18 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -31595,6 +35188,15 @@ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" }, + "node_modules/wordwrapjs": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.0.tgz", + "integrity": "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -31813,6 +35415,15 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xpath": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", @@ -31939,6 +35550,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.24.3", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", diff --git a/backend/package.json b/backend/package.json index f06c1d69f..17be524f2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -79,12 +79,17 @@ "keywords": [], "author": "", "license": "ISC", + "overrides": { + "cipher-base": "1.0.5", + "sha.js": "2.4.12" + }, "devDependencies": { "@babel/cli": "^7.18.10", "@babel/core": "^7.18.10", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/preset-env": "^7.18.10", "@babel/preset-react": "^7.24.7", + "@react-email/preview-server": "^4.3.0", "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", @@ -120,7 +125,7 @@ "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", - "react-email": "4.0.7", + "react-email": "^4.3.0", "rimraf": "^5.0.5", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", @@ -138,7 +143,7 @@ "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sts": "^3.600.0", "@casl/ability": "^6.5.0", - "@elastic/elasticsearch": "^8.15.0", + "@elastic/elasticsearch": "^9.1.1", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^8.5.0", "@fastify/etag": "^5.1.0", @@ -185,7 +190,7 @@ "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", - "axios": "^1.11.0", + "axios": "^1.12.0", "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", @@ -196,7 +201,7 @@ "cron": "^3.1.7", "dd-trace": "^5.40.0", "dotenv": "^16.4.1", - "fastify": "^4.28.1", + "fastify": "^4.29.1", "fastify-plugin": "^4.5.1", "google-auth-library": "^9.9.0", "googleapis": "^137.1.0", diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 111f42520..358a61b31 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -85,10 +85,9 @@ const getZodDefaultValue = (type: unknown, value: string | number | boolean | Ob }; const bigIntegerColumns: Record = { - "folder_commits": ["commitId"] + folder_commits: ["commitId"] }; - const main = async () => { const tables = ( await db("information_schema.tables") @@ -99,6 +98,7 @@ const main = async () => { (el) => !el.tableName.includes("_migrations") && !el.tableName.includes("audit_logs_") && + !el.tableName.includes("user_notifications_") && !el.tableName.includes("active_locks") && el.tableName !== "intermediate_audit_logs" ); diff --git a/backend/src/@types/fastify-zod.d.ts b/backend/src/@types/fastify-zod.d.ts index f0240d1a0..91cd00605 100644 --- a/backend/src/@types/fastify-zod.d.ts +++ b/backend/src/@types/fastify-zod.d.ts @@ -1,7 +1,9 @@ import { FastifyInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from "fastify"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { CustomLogger } from "@app/lib/logger/logger"; import { ZodTypeProvider } from "@app/server/plugins/fastify-zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; declare global { @@ -16,5 +18,7 @@ declare global { // used only for testing const testServer: FastifyZodProvider; const testSuperAdminDAL: TSuperAdminDALFactory; + const testKmsRootConfigDAL: TKmsRootConfigDALFactory; + const testHsmService: THsmServiceFactory; const jwtAuthToken: string; } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c814ab9d8..273e6d982 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -20,18 +20,19 @@ import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2 import { TGithubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template"; -import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; -import { TIdentityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; import { TKmipClientDALFactory } from "@app/ee/services/kmip/kmip-client-dal"; import { TKmipOperationServiceFactory } from "@app/ee/services/kmip/kmip-operation-service"; import { TKmipServiceFactory } from "@app/ee/services/kmip/kmip-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; +import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; +import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; -import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types"; import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-types"; @@ -47,8 +48,10 @@ import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TSshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; import { TSshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; +import { TSubOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-types"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; +import { TAdditionalPrivilegeServiceFactory } from "@app/services/additional-privilege/additional-privilege-service"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; @@ -59,8 +62,13 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; +import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; +import { TConvertorServiceFactory } from "@app/services/convertor/convertor-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; @@ -84,10 +92,12 @@ import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-a import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service"; +import { TMembershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; +import { TMembershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; -import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -100,8 +110,8 @@ import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; -import { TProjectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { TReminderServiceFactory } from "@app/services/reminder/reminder-types"; +import { TRoleServiceFactory } from "@app/services/role/role-service"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; @@ -173,6 +183,8 @@ declare module "fastify" { type: ActorType; id: string; orgId: string; + parentOrgId: string; + rootOrgId: string; }; rateLimits: RateLimitConfiguration; // passport data @@ -209,7 +221,6 @@ declare module "fastify" { authToken: TAuthTokenServiceFactory; permission: TPermissionServiceFactory; org: TOrgServiceFactory; - orgRole: TOrgRoleServiceFactory; oidc: TOidcConfigServiceFactory; superAdmin: TSuperAdminServiceFactory; user: TUserServiceFactory; @@ -221,7 +232,6 @@ declare module "fastify" { projectMembership: TProjectMembershipServiceFactory; projectEnv: TProjectEnvServiceFactory; projectKey: TProjectKeyServiceFactory; - projectRole: TProjectRoleServiceFactory; secret: TSecretServiceFactory; secretReplication: TSecretReplicationServiceFactory; secretTag: TSecretTagServiceFactory; @@ -259,7 +269,10 @@ declare module "fastify" { auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; + certificateV3: TCertificateV3ServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + certificateTemplateV2: TCertificateTemplateV2ServiceFactory; + certificateProfile: TCertificateProfileServiceFactory; sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; sshHost: TSshHostServiceFactory; @@ -267,6 +280,7 @@ declare module "fastify" { certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; + certificateEstV3: TCertificateEstV3ServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; pkiSync: TPkiSyncServiceFactory; @@ -277,9 +291,6 @@ declare module "fastify" { telemetry: TTelemetryServiceFactory; dynamicSecret: TDynamicSecretServiceFactory; dynamicSecretLease: TDynamicSecretLeaseServiceFactory; - projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; - identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; - identityProjectAdditionalPrivilegeV2: TIdentityProjectAdditionalPrivilegeV2ServiceFactory; secretSharing: TSecretSharingServiceFactory; rateLimit: TRateLimitServiceFactory; userEngagement: TUserEngagementServiceFactory; @@ -315,7 +326,19 @@ declare module "fastify" { identityAuthTemplate: TIdentityAuthTemplateServiceFactory; notification: TNotificationServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory; + pamFolder: TPamFolderServiceFactory; + pamResource: TPamResourceServiceFactory; + pamAccount: TPamAccountServiceFactory; + pamSession: TPamSessionServiceFactory; upgradePath: TUpgradePathService; + + membershipUser: TMembershipUserServiceFactory; + membershipIdentity: TMembershipIdentityServiceFactory; + membershipGroup: TMembershipGroupServiceFactory; + additionalPrivilege: TAdditionalPrivilegeServiceFactory; + role: TRoleServiceFactory; + convertor: TConvertorServiceFactory; + subOrganization: TSubOrgServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index b6bde44bc..bbc27ebc1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -17,6 +17,9 @@ import { TAccessApprovalRequestsReviewersInsert, TAccessApprovalRequestsReviewersUpdate, TAccessApprovalRequestsUpdate, + TAdditionalPrivileges, + TAdditionalPrivilegesInsert, + TAdditionalPrivilegesUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, @@ -227,6 +230,15 @@ import { TLdapGroupMaps, TLdapGroupMapsInsert, TLdapGroupMapsUpdate, + TMembershipRoles, + TMembershipRolesInsert, + TMembershipRolesUpdate, + TMemberships, + TMembershipsInsert, + TMembershipsUpdate, + TNamespaces, + TNamespacesInsert, + TNamespacesUpdate, TOidcConfigs, TOidcConfigsInsert, TOidcConfigsUpdate, @@ -254,12 +266,24 @@ import { TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate, + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate, + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update, TPkiCollectionItems, TPkiCollectionItemsInsert, TPkiCollectionItemsUpdate, TPkiCollections, TPkiCollectionsInsert, TPkiCollectionsUpdate, + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate, TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, @@ -314,6 +338,9 @@ import { TResourceMetadata, TResourceMetadataInsert, TResourceMetadataUpdate, + TRoles, + TRolesInsert, + TRolesUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, @@ -503,6 +530,9 @@ import { TUsers, TUsersInsert, TUsersUpdate, + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate, TWebhooks, TWebhooksInsert, TWebhooksUpdate, @@ -530,6 +560,10 @@ import { TMicrosoftTeamsIntegrationsInsert, TMicrosoftTeamsIntegrationsUpdate } from "@app/db/schemas/microsoft-teams-integrations"; +import { TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate } from "@app/db/schemas/pam-accounts"; +import { TPamFolders, TPamFoldersInsert, TPamFoldersUpdate } from "@app/db/schemas/pam-folders"; +import { TPamResources, TPamResourcesInsert, TPamResourcesUpdate } from "@app/db/schemas/pam-resources"; +import { TPamSessions, TPamSessionsInsert, TPamSessionsUpdate } from "@app/db/schemas/pam-sessions"; import { TProjectMicrosoftTeamsConfigs, TProjectMicrosoftTeamsConfigsInsert, @@ -652,6 +686,26 @@ declare module "knex/types/tables" { TCertificateTemplatesInsert, TCertificateTemplatesUpdate >; + [TableName.PkiCertificateTemplateV2]: KnexOriginal.CompositeTableType< + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update + >; + [TableName.PkiCertificateProfile]: KnexOriginal.CompositeTableType< + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate + >; + [TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate + >; + [TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, @@ -1308,5 +1362,28 @@ declare module "knex/types/tables" { TKeyValueStoreInsert, TKeyValueStoreUpdate >; + [TableName.PamFolder]: KnexOriginal.CompositeTableType; + [TableName.PamResource]: KnexOriginal.CompositeTableType; + [TableName.PamAccount]: KnexOriginal.CompositeTableType; + [TableName.PamSession]: KnexOriginal.CompositeTableType; + + [TableName.Namespace]: KnexOriginal.CompositeTableType; + [TableName.Membership]: KnexOriginal.CompositeTableType; + [TableName.MembershipRole]: KnexOriginal.CompositeTableType< + TMembershipRoles, + TMembershipRolesInsert, + TMembershipRolesUpdate + >; + [TableName.Role]: KnexOriginal.CompositeTableType; + [TableName.AdditionalPrivilege]: KnexOriginal.CompositeTableType< + TAdditionalPrivileges, + TAdditionalPrivilegesInsert, + TAdditionalPrivilegesUpdate + >; + [TableName.VaultExternalMigrationConfig]: KnexOriginal.CompositeTableType< + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate + >; } } diff --git a/backend/src/db/instance.ts b/backend/src/db/instance.ts index b1ecf7d65..5d9007a00 100644 --- a/backend/src/db/instance.ts +++ b/backend/src/db/instance.ts @@ -1,5 +1,29 @@ import knex, { Knex } from "knex"; +const parseSslConfig = (dbConnectionUri: string, dbRootCert?: string) => { + let modifiedDbConnectionUri = dbConnectionUri; + let sslConfig: { rejectUnauthorized: boolean; ca: string } | boolean = dbRootCert + ? { rejectUnauthorized: true, ca: Buffer.from(dbRootCert, "base64").toString("ascii") } + : false; + + if (dbRootCert) { + const url = new URL(dbConnectionUri); + const sslMode = url.searchParams.get("sslmode"); + + if (sslMode && sslMode !== "disable") { + url.searchParams.delete("sslmode"); + modifiedDbConnectionUri = url.toString(); + + sslConfig = { + rejectUnauthorized: ["verify-ca", "verify-full"].includes(sslMode), + ca: Buffer.from(dbRootCert, "base64").toString("ascii") + }; + } + } + + return { modifiedDbConnectionUri, sslConfig }; +}; + export type TDbClient = Knex; export const initDbConnection = ({ dbConnectionUri, @@ -32,23 +56,18 @@ export const initDbConnection = ({ return selectedReplica; }); + const { modifiedDbConnectionUri, sslConfig } = parseSslConfig(dbConnectionUri, dbRootCert); + db = knex({ client: "pg", connection: { - connectionString: dbConnectionUri, + connectionString: modifiedDbConnectionUri, host: process.env.DB_HOST, - // @ts-expect-error I have no clue why only for the port there is a type error - // eslint-disable-next-line - port: process.env.DB_PORT, + port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : undefined, user: process.env.DB_USER, database: process.env.DB_NAME, password: process.env.DB_PASSWORD, - ssl: dbRootCert - ? { - rejectUnauthorized: true, - ca: Buffer.from(dbRootCert, "base64").toString("ascii") - } - : false + ssl: sslConfig }, // https://knexjs.org/guide/#pool pool: { min: 0, max: 10 }, @@ -59,16 +78,16 @@ export const initDbConnection = ({ readReplicaDbs = readReplicas.map((el) => { const replicaDbCertificate = el.dbRootCert || dbRootCert; + const { modifiedDbConnectionUri: replicaUri, sslConfig: replicaSslConfig } = parseSslConfig( + el.dbConnectionUri, + replicaDbCertificate + ); + return knex({ client: "pg", connection: { - connectionString: el.dbConnectionUri, - ssl: replicaDbCertificate - ? { - rejectUnauthorized: true, - ca: Buffer.from(replicaDbCertificate, "base64").toString("ascii") - } - : false + connectionString: replicaUri, + ssl: replicaSslConfig }, migrations: { tableName: "infisical_migrations" @@ -87,26 +106,21 @@ export const initAuditLogDbConnection = ({ dbConnectionUri: string; dbRootCert?: string; }) => { + const { modifiedDbConnectionUri, sslConfig } = parseSslConfig(dbConnectionUri, dbRootCert); + // akhilmhdh: the default Knex is knex.Knex. but when assigned with knex({}) the value is knex.Knex // this was causing issue with files like `snapshot-dal` `findRecursivelySnapshots` this i am explicitly putting the any and unknown[] // eslint-disable-next-line const db: Knex = knex({ client: "pg", connection: { - connectionString: dbConnectionUri, + connectionString: modifiedDbConnectionUri, host: process.env.AUDIT_LOGS_DB_HOST, - // @ts-expect-error I have no clue why only for the port there is a type error - // eslint-disable-next-line - port: process.env.AUDIT_LOGS_DB_PORT, + port: process.env.AUDIT_LOGS_DB_PORT ? parseInt(process.env.AUDIT_LOGS_DB_PORT, 10) : undefined, user: process.env.AUDIT_LOGS_DB_USER, database: process.env.AUDIT_LOGS_DB_NAME, password: process.env.AUDIT_LOGS_DB_PASSWORD, - ssl: dbRootCert - ? { - rejectUnauthorized: true, - ca: Buffer.from(dbRootCert, "base64").toString("ascii") - } - : false + ssl: sslConfig }, migrations: { tableName: "infisical_migrations" diff --git a/backend/src/db/migrations/20240702131735_secret-approval-groups.ts b/backend/src/db/migrations/20240702131735_secret-approval-groups.ts index 84824ac65..c1b6bb48b 100644 --- a/backend/src/db/migrations/20240702131735_secret-approval-groups.ts +++ b/backend/src/db/migrations/20240702131735_secret-approval-groups.ts @@ -127,7 +127,8 @@ export async function down(knex: Knex): Promise { }); await knex.schema.alterTable(TableName.SecretApprovalPolicyApprover, (tb) => { tb.dropColumn("approverUserId"); - tb.uuid("approverId").notNullable().alter(); + // akhilmhdh: i had to comment this out and is not possible as membership is now changed in structure + // tb.uuid("approverId").notNullable().alter(); }); } } diff --git a/backend/src/db/migrations/20250210101840_webhook-to-kms.ts b/backend/src/db/migrations/20250210101840_webhook-to-kms.ts index 09a346abb..2fbf68128 100644 --- a/backend/src/db/migrations/20250210101840_webhook-to-kms.ts +++ b/backend/src/db/migrations/20250210101840_webhook-to-kms.ts @@ -3,13 +3,14 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -25,10 +26,12 @@ export async function up(knex: Knex): Promise { if (hasUrl) t.string("url").nullable().alter(); }); } - initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts b/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts index 94e30a7b8..179cb9bd6 100644 --- a/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts +++ b/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -30,8 +31,12 @@ export async function up(knex: Knex): Promise { } initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts b/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts index bbda48dac..aef429ab9 100644 --- a/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts +++ b/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -24,8 +25,11 @@ export async function up(knex: Knex): Promise { } initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts b/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts index a24bfdf0c..f3fa63028 100644 --- a/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts +++ b/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName, TOrgBots } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; const reencryptIdentityK8sAuth = async (knex: Knex) => { @@ -55,9 +56,11 @@ const reencryptIdentityK8sAuth = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = diff --git a/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts b/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts index 25db615fa..f970043f0 100644 --- a/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts +++ b/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName, TOrgBots } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; const reencryptIdentityOidcAuth = async (knex: Knex) => { @@ -35,8 +36,11 @@ const reencryptIdentityOidcAuth = async (knex: Knex) => { } initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts b/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts index 783693da6..62b4e8556 100644 --- a/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts +++ b/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts @@ -4,16 +4,18 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; -const reencryptSamlConfig = async (knex: Knex) => { +const reencryptSamlConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedEntrypointColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlEntryPoint"); const hasEncryptedIssuerColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlIssuer"); const hasEncryptedCertificateColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlCertificate"); @@ -28,10 +30,6 @@ const reencryptSamlConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -159,7 +157,7 @@ const reencryptSamlConfig = async (knex: Knex) => { } }; -const reencryptLdapConfig = async (knex: Knex) => { +const reencryptLdapConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedLdapBindDNColum = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapBindDN"); const hasEncryptedLdapBindPassColumn = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapBindPass"); const hasEncryptedCertificateColumn = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapCaCertificate"); @@ -194,10 +192,6 @@ const reencryptLdapConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -323,7 +317,7 @@ const reencryptLdapConfig = async (knex: Knex) => { } }; -const reencryptOidcConfig = async (knex: Knex) => { +const reencryptOidcConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedOidcClientIdColumn = await knex.schema.hasColumn(TableName.OidcConfig, "encryptedOidcClientId"); const hasEncryptedOidcClientSecretColumn = await knex.schema.hasColumn( TableName.OidcConfig, @@ -354,10 +348,6 @@ const reencryptOidcConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -462,9 +452,18 @@ const reencryptOidcConfig = async (knex: Knex) => { }; export async function up(knex: Knex): Promise { - await reencryptSamlConfig(knex); - await reencryptLdapConfig(knex); - await reencryptOidcConfig(knex); + initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); + const keyStore = inMemoryKeyStore(); + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + await reencryptSamlConfig(knex, kmsService); + await reencryptLdapConfig(knex, kmsService); + await reencryptOidcConfig(knex, kmsService); } const dropSamlConfigColumns = async (knex: Knex) => { diff --git a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts index a0985471f..dd9ff2d6a 100644 --- a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts +++ b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts @@ -3,12 +3,13 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; // Note(daniel): We aren't dropping tables or columns in this migrations so we can easily rollback if needed. // In the future we need to drop the projectGatewayId on the dynamic secrets table, and drop the project_gateways table entirely. @@ -40,8 +41,10 @@ export async function up(knex: Knex): Promise { ); initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts b/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts index 548d6207a..f2bc0a96a 100644 --- a/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts +++ b/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts @@ -2,19 +2,23 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { selectAllTableCols } from "@app/lib/knex"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; export async function up(knex: Knex) { const existingSuperAdminsWithGithubConnection = await knex(TableName.SuperAdmin) .select(selectAllTableCols(TableName.SuperAdmin)) .whereNotNull(`${TableName.SuperAdmin}.encryptedGitHubAppConnectionClientId`); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts index a70dcb8b9..82fa4a039 100644 --- a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts +++ b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts @@ -2,13 +2,14 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -25,8 +26,10 @@ export async function up(knex: Knex): Promise { }); if (!hasEncryptedCredentials) { + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); @@ -131,8 +134,11 @@ export async function down(knex: Knex): Promise { const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); if (hasEncryptedCredentials) { + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250917052037_pam.ts b/backend/src/db/migrations/20250917052037_pam.ts new file mode 100644 index 000000000..031b407db --- /dev/null +++ b/backend/src/db/migrations/20250917052037_pam.ts @@ -0,0 +1,165 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + // PAM Folders + if (!(await knex.schema.hasTable(TableName.PamFolder))) { + await knex.schema.createTable(TableName.PamFolder, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.uuid("parentId").nullable(); + t.foreign("parentId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE"); + t.index("parentId"); + + t.string("name").notNullable(); + t.index("name"); + + // Enforce uniqueness for sub-folders + t.unique(["projectId", "parentId", "name"], { + indexName: "uidx_pam_folder_children_name", + predicate: knex.whereNotNull("parentId") + }); + + // Enforce uniqueness for root-level folders + t.unique(["projectId", "name"], { + indexName: "uidx_pam_folder_root_name", + predicate: knex.whereNull("parentId") + }); + + t.text("description").nullable(); + + t.timestamps(true, true, true); + }); + } + + // PAM Resources + if (!(await knex.schema.hasTable(TableName.PamResource))) { + await knex.schema.createTable(TableName.PamResource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.string("name").notNullable(); + t.index("name"); + + t.uuid("gatewayId").notNullable(); + t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2); + t.index("gatewayId"); + + t.string("resourceType").notNullable(); + t.index("resourceType"); + + t.binary("encryptedConnectionDetails").notNullable(); + + t.timestamps(true, true, true); + }); + } + + // PAM Accounts + if (!(await knex.schema.hasTable(TableName.PamAccount))) { + await knex.schema.createTable(TableName.PamAccount, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.uuid("folderId").nullable(); + t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE"); + t.index("folderId"); + + t.uuid("resourceId").notNullable(); + t.foreign("resourceId").references("id").inTable(TableName.PamResource); + t.index("resourceId"); + + t.string("name").notNullable(); + t.index("name"); + + // Enforce uniqueness for folders + t.unique(["projectId", "folderId", "name"], { + indexName: "uidx_pam_account_children_name", + predicate: knex.whereNotNull("folderId") + }); + + // Enforce uniqueness for root-level + t.unique(["projectId", "name"], { + indexName: "uidx_pam_account_root_name", + predicate: knex.whereNull("folderId") + }); + + t.text("description").nullable(); + t.binary("encryptedCredentials").notNullable(); + + t.timestamps(true, true, true); + }); + } + + // PAM Sessions + if (!(await knex.schema.hasTable(TableName.PamSession))) { + await knex.schema.createTable(TableName.PamSession, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + + t.uuid("accountId").nullable(); + t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL"); + t.index("accountId"); + + // To be used in the event of an account deletion + t.string("resourceType").notNullable(); + t.string("resourceName").notNullable(); + t.string("accountName").notNullable(); + + t.uuid("userId").nullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + t.index("userId"); + + // To be used in the event of user deletion + t.string("actorName").notNullable(); + t.string("actorEmail").notNullable(); + + t.string("actorIp").notNullable(); + t.string("actorUserAgent").notNullable(); + + t.string("status").notNullable(); + t.index("status"); + + t.binary("encryptedLogsBlob").nullable(); + + t.datetime("expiresAt").notNullable(); + + t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established + t.datetime("endedAt").nullable(); + t.index(["startedAt", "endedAt"]); + + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.PamFolder); + await createOnUpdateTrigger(knex, TableName.PamResource); + await createOnUpdateTrigger(knex, TableName.PamAccount); + await createOnUpdateTrigger(knex, TableName.PamSession); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.PamSession); + await knex.schema.dropTableIfExists(TableName.PamAccount); + await knex.schema.dropTableIfExists(TableName.PamResource); + await knex.schema.dropTableIfExists(TableName.PamFolder); + + await dropOnUpdateTrigger(knex, TableName.PamSession); + await dropOnUpdateTrigger(knex, TableName.PamAccount); + await dropOnUpdateTrigger(knex, TableName.PamResource); + await dropOnUpdateTrigger(knex, TableName.PamFolder); +} diff --git a/backend/src/db/migrations/20251002113756_add-gateway-pam-key.ts b/backend/src/db/migrations/20251002113756_add-gateway-pam-key.ts new file mode 100644 index 000000000..b2bb10003 --- /dev/null +++ b/backend/src/db/migrations/20251002113756_add-gateway-pam-key.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey"))) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.binary("encryptedPamSessionKey"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey")) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.dropColumn("encryptedPamSessionKey"); + }); + } +} diff --git a/backend/src/db/migrations/20251005152640_simplify-membership.ts b/backend/src/db/migrations/20251005152640_simplify-membership.ts new file mode 100644 index 000000000..ff2987da7 --- /dev/null +++ b/backend/src/db/migrations/20251005152640_simplify-membership.ts @@ -0,0 +1,1121 @@ +import { Knex } from "knex"; + +import { AccessScope, TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +const createNamespaceTable = async (knex: Knex) => { + await knex.schema.createTable(TableName.Namespace, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("description"); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.Namespace); +}; + +const createMembershipTable = async (knex: Knex) => { + await knex.schema.createTable(TableName.Membership, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.string("scope", 24).notNullable(); + + t.uuid("actorUserId"); + t.foreign("actorUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("actorIdentityId"); + t.foreign("actorIdentityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.uuid("actorGroupId"); + t.foreign("actorGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("scopeOrgId").notNullable(); + t.foreign("scopeOrgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("scopeProjectId", 36); + t.foreign("scopeProjectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("scopeNamespaceId"); + t.foreign("scopeNamespaceId").references("id").inTable(TableName.Namespace).onDelete("CASCADE"); + + t.boolean("isActive").defaultTo(true).notNullable(); + t.string("status"); + t.string("inviteEmail"); + t.datetime("lastInvitedAt"); + t.string("lastLoginAuthMethod"); + t.datetime("lastLoginTime"); + t.specificType("projectFavorites", "text[]"); + t.timestamps(true, true, true); + + t.index(["scope", "scopeOrgId"]); + + t.check( + `(:actorUserIdColumn: IS NOT NULL AND :actorIdentityIdColumn: IS NULL AND :actorGroupIdColumn: IS NULL) OR + (:actorIdentityIdColumn: IS NOT NULL AND :actorUserIdColumn: IS NULL AND :actorGroupIdColumn: IS NULL) OR + (:actorGroupIdColumn: IS NOT NULL AND :actorUserIdColumn: IS NULL AND :actorIdentityIdColumn: IS NULL)`, + { + actorUserIdColumn: "actorUserId", + actorIdentityIdColumn: "actorIdentityId", + actorGroupIdColumn: "actorGroupId" + }, + "only_one_actor_type" + ); + + t.check( + `(:scopeColumn: = 'namespace' AND :scopeNamespaceIdColumn: IS NOT NULL) OR + (:scopeColumn: = 'project' AND :scopeProjectIdColumn: IS NOT NULL) OR + (:scopeColumn: = 'organization') + `, + { + scopeColumn: "scope", + scopeNamespaceIdColumn: "scopeNamespaceId", + scopeProjectIdColumn: "scopeProjectId" + }, + "scope_matches_id" + ); + }); + + await createOnUpdateTrigger(knex, TableName.Membership); +}; + +const createRoleTable = async (knex: Knex) => { + await knex.schema.createTable(TableName.Role, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("description"); + t.string("slug").notNullable(); + t.jsonb("permissions").notNullable(); + + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("projectId", 36); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("namespaceId"); + t.foreign("namespaceId").references("id").inTable(TableName.Namespace).onDelete("CASCADE"); + + t.check( + `(:orgIdColumn: IS NOT NULL AND :namespaceIdColumn: IS NULL AND :projectIdColumn: IS NULL) OR + (:namespaceIdColumn: IS NOT NULL AND :orgIdColumn: IS NULL AND :projectIdColumn: IS NULL) OR + (:projectIdColumn: IS NOT NULL AND :orgIdColumn: IS NULL AND :namespaceIdColumn: IS NULL)`, + { + orgIdColumn: "orgId", + namespaceIdColumn: "namespaceId", + projectIdColumn: "projectId" + }, + "only_one_scope_id" + ); + + t.timestamps(true, true, true); + }); + + await knex.schema.raw(` + CREATE UNIQUE INDEX role_name_org_id_unique + ON "${TableName.Role}" (slug, "orgId") + WHERE "orgId" IS NOT NULL; + `); + + await knex.schema.raw(` + CREATE UNIQUE INDEX role_name_project_id_unique + ON "${TableName.Role}" (slug, "projectId") + WHERE "projectId" IS NOT NULL; + `); + + await knex.schema.raw(` + CREATE UNIQUE INDEX role_name_namespace_id_unique + ON "${TableName.Role}" (slug, "namespaceId") + WHERE "namespaceId" IS NOT NULL; + `); + + await createOnUpdateTrigger(knex, TableName.Role); +}; + +const createMembershipRoleTable = async (knex: Knex) => { + await knex.schema.createTable(TableName.MembershipRole, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.Role); + t.uuid("membershipId").notNullable(); + t.foreign("membershipId").references("id").inTable(TableName.Membership).onDelete("CASCADE"); + + t.index("membershipId"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.MembershipRole); +}; + +const createAdditionalPrivilegeTable = async (knex: Knex) => { + await knex.schema.createTable(TableName.AdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 60).notNullable(); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.jsonb("permissions").notNullable(); + + t.uuid("actorUserId"); + t.foreign("actorUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("actorIdentityId"); + t.foreign("actorIdentityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("projectId", 36); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("namespaceId"); + t.foreign("namespaceId").references("id").inTable(TableName.Namespace).onDelete("CASCADE"); + + t.check( + `(:orgIdColumn: IS NOT NULL AND :namespaceIdColumn: IS NULL AND :projectIdColumn: IS NULL) OR + (:namespaceIdColumn: IS NOT NULL AND :orgIdColumn: IS NULL AND :projectIdColumn: IS NULL) OR + (:projectIdColumn: IS NOT NULL AND :orgIdColumn: IS NULL AND :namespaceIdColumn: IS NULL)`, + { + orgIdColumn: "orgId", + namespaceIdColumn: "namespaceId", + projectIdColumn: "projectId" + }, + "only_one_scope_id" + ); + + t.check( + `(:actorUserIdColumn: IS NOT NULL AND :actorIdentityIdColumn: IS NULL) OR + (:actorIdentityIdColumn: IS NOT NULL AND :actorUserIdColumn: IS NULL) + `, + { + actorUserIdColumn: "actorUserId", + actorIdentityIdColumn: "actorIdentityId" + }, + "only_one_actor_type" + ); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.AdditionalPrivilege); +}; + +const migrateMembershipData = async (knex: Knex) => { + await knex + .insert( + knex(TableName.OrgMembership).select( + "id", + "status", + "inviteEmail", + "createdAt", + "updatedAt", + "userId", + "orgId", + "projectFavorites", + "isActive", + "lastInvitedAt", + "lastLoginAuthMethod", + "lastLoginTime", + knex.raw("?", [AccessScope.Organization]) + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??)", [ + TableName.Membership, + "id", + "status", + "inviteEmail", + "createdAt", + "updatedAt", + "actorUserId", + "scopeOrgId", + "projectFavorites", + "isActive", + "lastInvitedAt", + "lastLoginAuthMethod", + "lastLoginTime", + "scope" + ]) + ); + + await knex + .insert( + knex(TableName.IdentityOrgMembership).select( + "id", + "identityId", + "orgId", + "lastLoginAuthMethod", + "lastLoginTime", + "createdAt", + "updatedAt", + knex.raw("?", [AccessScope.Organization]) + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??)", [ + TableName.Membership, + "id", + "actorIdentityId", + "scopeOrgId", + "lastLoginAuthMethod", + "lastLoginTime", + "createdAt", + "updatedAt", + "scope" + ]) + ); + + await knex + .insert( + knex(TableName.Groups).select("id", "orgId", "createdAt", "updatedAt", knex.raw("?", [AccessScope.Organization])) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??)", [ + TableName.Membership, + "actorGroupId", + "scopeOrgId", + "createdAt", + "updatedAt", + "scope" + ]) + ); + + await knex + .insert( + knex(TableName.ProjectMembership) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + .select( + knex.ref("id").withSchema(TableName.ProjectMembership), + "userId", + "projectId", + "orgId", + knex.ref("createdAt").withSchema(TableName.ProjectMembership), + knex.ref("updatedAt").withSchema(TableName.ProjectMembership), + knex.raw("?", [AccessScope.Project]) + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??)", [ + TableName.Membership, + "id", + "actorUserId", + "scopeProjectId", + "scopeOrgId", + "createdAt", + "updatedAt", + "scope" + ]) + ); + + await knex + .insert( + knex(TableName.IdentityProjectMembership) + .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) + .select( + knex.ref("id").withSchema(TableName.IdentityProjectMembership), + "identityId", + "projectId", + "orgId", + knex.ref("createdAt").withSchema(TableName.IdentityProjectMembership), + knex.ref("updatedAt").withSchema(TableName.IdentityProjectMembership), + knex.raw("?", [AccessScope.Project]) + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??)", [ + TableName.Membership, + "id", + "actorIdentityId", + "scopeProjectId", + "scopeOrgId", + "createdAt", + "updatedAt", + "scope" + ]) + ); + + await knex + .insert( + knex(TableName.GroupProjectMembership) + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .select( + knex.ref("id").withSchema(TableName.GroupProjectMembership), + "groupId", + "projectId", + "orgId", + knex.ref("createdAt").withSchema(TableName.GroupProjectMembership), + knex.ref("updatedAt").withSchema(TableName.GroupProjectMembership), + knex.raw("?", [AccessScope.Project]) + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??)", [ + TableName.Membership, + "id", + "actorGroupId", + "scopeProjectId", + "scopeOrgId", + "createdAt", + "updatedAt", + "scope" + ]) + ); +}; + +const migrateRoleData = async (knex: Knex) => { + await knex + .insert( + knex(TableName.OrgRoles).select( + "id", + "name", + "description", + "slug", + "permissions", + "createdAt", + "updatedAt", + "orgId" + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??)", [ + TableName.Role, + "id", + "name", + "description", + "slug", + "permissions", + "createdAt", + "updatedAt", + "orgId" + ]) + ); + + await knex + .insert( + knex(TableName.ProjectRoles).select( + "id", + "name", + "description", + "slug", + "permissions", + "createdAt", + "updatedAt", + "projectId" + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??)", [ + TableName.Role, + "id", + "name", + "description", + "slug", + "permissions", + "createdAt", + "updatedAt", + "projectId" + ]) + ); + + const hasExternalGroupRoleMappingRoleColumn = await knex.schema.hasColumn( + TableName.ExternalGroupOrgRoleMapping, + "roleId" + ); + if (hasExternalGroupRoleMappingRoleColumn) { + await knex.schema.alterTable(TableName.ExternalGroupOrgRoleMapping, (t) => { + t.dropForeign("roleId"); + t.foreign("roleId").references("id").inTable(TableName.Role); + }); + } +}; + +const migrateMembershipRoleData = async (knex: Knex) => { + await knex + .insert(knex(TableName.OrgMembership).select("id", "role", "roleId")) + .into(knex.raw("?? (??, ??, ??)", [TableName.MembershipRole, "membershipId", "role", "customRoleId"])); + + await knex + .insert(knex(TableName.IdentityOrgMembership).select("id", "role", "roleId")) + .into(knex.raw("?? (??, ??, ??)", [TableName.MembershipRole, "membershipId", "role", "customRoleId"])); + + await knex + .insert( + knex(TableName.Groups) + .join(TableName.Membership, (qb) => { + qb.on(`${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .andOn(`${TableName.Groups}.orgId`, `${TableName.Membership}.scopeOrgId`) + .andOn(`${TableName.Membership}.scope`, knex.raw("?", [AccessScope.Organization])); + }) + .select(knex.ref("id").withSchema(TableName.Membership), "role", "roleId") + ) + .into(knex.raw("?? (??, ??, ??)", [TableName.MembershipRole, "membershipId", "role", "customRoleId"])); + + await knex + .insert( + knex(TableName.ProjectUserMembershipRole).select( + "id", + "role", + "projectMembershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.MembershipRole, + "id", + "role", + "membershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ]) + ); + + await knex + .insert( + knex(TableName.IdentityProjectMembershipRole).select( + "id", + "role", + "projectMembershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.MembershipRole, + "id", + "role", + "membershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ]) + ); + + await knex + .insert( + knex(TableName.GroupProjectMembershipRole).select( + "id", + "role", + "projectMembershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.MembershipRole, + "id", + "role", + "membershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ]) + ); +}; + +const migrateAdditionalPrivilegeData = async (knex: Knex) => { + await knex + .insert( + knex(TableName.ProjectUserAdditionalPrivilege).select( + "id", + "slug", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "userId", + "projectId", + "createdAt", + "updatedAt" + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.AdditionalPrivilege, + "id", + "name", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "actorUserId", + "projectId", + "createdAt", + "updatedAt" + ]) + ); + + await knex + .insert( + knex(TableName.IdentityProjectAdditionalPrivilege) + .join( + TableName.IdentityProjectMembership, + `${TableName.IdentityProjectMembership}.id`, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId` + ) + .select( + knex.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege), + "slug", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "identityId", + "projectId", + knex.ref("createdAt").withSchema(TableName.IdentityProjectAdditionalPrivilege), + knex.ref("updatedAt").withSchema(TableName.IdentityProjectAdditionalPrivilege) + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.AdditionalPrivilege, + "id", + "name", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "actorIdentityId", + "projectId", + "createdAt", + "updatedAt" + ]) + ); + + const hasApColumnInAccessApprovalRequest = await knex.schema.hasColumn( + TableName.AccessApprovalRequest, + "privilegeId" + ); + if (hasApColumnInAccessApprovalRequest) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign("privilegeId"); + t.foreign("privilegeId").references("id").inTable(TableName.AdditionalPrivilege); + }); + } +}; + +export async function up(knex: Knex): Promise { + const hasToMigrateNamespaceTable = !(await knex.schema.hasTable(TableName.Namespace)); + if (hasToMigrateNamespaceTable) { + await createNamespaceTable(knex); + } + + const hasToMigrateMembershipTable = !(await knex.schema.hasTable(TableName.Membership)); + if (hasToMigrateMembershipTable) { + await createMembershipTable(knex); + } + + const hasToMigrateRoleTable = !(await knex.schema.hasTable(TableName.Role)); + if (hasToMigrateRoleTable) { + await createRoleTable(knex); + } + + const hasToMigrateMembershipRoleTable = !(await knex.schema.hasTable(TableName.MembershipRole)); + if (hasToMigrateMembershipRoleTable) { + await createMembershipRoleTable(knex); + } + + const hasToMigrateAdditionalPrivilegeTable = !(await knex.schema.hasTable(TableName.AdditionalPrivilege)); + if (hasToMigrateAdditionalPrivilegeTable) { + await createAdditionalPrivilegeTable(knex); + } + + // this means these tables have been created before + if (hasToMigrateMembershipTable) { + await migrateMembershipData(knex); + } + + if (hasToMigrateRoleTable) { + await migrateRoleData(knex); + } + + if (hasToMigrateMembershipRoleTable) { + await migrateMembershipRoleData(knex); + } + + if (hasToMigrateAdditionalPrivilegeTable) { + await migrateAdditionalPrivilegeData(knex); + } +} + +const rollbackAdditionalPrivilegeData = async (knex: Knex) => { + const projectUserAdditionalPrivilegeFields = [ + "id", + "slug", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "userId", + "projectId", + "createdAt", + "updatedAt" + ]; + + await knex + .insert( + knex(TableName.AdditionalPrivilege) + .whereNotNull("actorUserId") + .whereNotNull("projectId") + .select( + "id", + "name", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "actorUserId", + "projectId", + "createdAt", + "updatedAt" + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.ProjectUserAdditionalPrivilege, + ...projectUserAdditionalPrivilegeFields + ]) + ) + .onConflict("id") + .merge(projectUserAdditionalPrivilegeFields); + + const identityProjectAdditionalPrivilegeFields = [ + "id", + "slug", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + "projectMembershipId", + "createdAt", + "updatedAt" + ]; + + await knex + .insert( + knex(TableName.AdditionalPrivilege) + .join(TableName.Membership, (qb) => { + qb.on(`${TableName.AdditionalPrivilege}.actorIdentityId`, `${TableName.Membership}.actorIdentityId`) + .andOn(`${TableName.AdditionalPrivilege}.projectId`, `${TableName.Membership}.scopeProjectId`) + .andOn(`${TableName.Membership}.scope`, knex.raw("?", [AccessScope.Project])); + }) + .whereNotNull(`${TableName.AdditionalPrivilege}.actorIdentityId`) + .whereNotNull(`${TableName.AdditionalPrivilege}.projectId`) + .select( + knex.ref("id").withSchema(TableName.AdditionalPrivilege), + "name", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "permissions", + knex.ref("id").withSchema(TableName.Membership).as("projectMembershipId"), + knex.ref("createdAt").withSchema(TableName.AdditionalPrivilege), + knex.ref("updatedAt").withSchema(TableName.AdditionalPrivilege) + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.IdentityProjectAdditionalPrivilege, + ...identityProjectAdditionalPrivilegeFields + ]) + ) + .onConflict("id") + .merge(identityProjectAdditionalPrivilegeFields); +}; + +const rollbackMembershipRoleData = async (knex: Knex) => { + const groupRoleFields = ["id", "name", "slug", "createdAt", "updatedAt", "role", "roleId", "orgId"]; + + await knex + .insert( + knex(TableName.MembershipRole) + .join(TableName.Membership, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .join(TableName.Groups, `${TableName.Membership}.actorGroupId`, `${TableName.Groups}.id`) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .select( + knex.ref("actorGroupId").withSchema(TableName.Membership), + knex.ref("name").withSchema(TableName.Groups), + knex.ref("slug").withSchema(TableName.Groups), + knex.ref("createdAt").withSchema(TableName.Groups), + knex.ref("updatedAt").withSchema(TableName.Groups), + knex.ref("role").withSchema(TableName.MembershipRole), + "customRoleId", + knex.ref("orgId").withSchema(TableName.Groups) + ) + ) + .into(knex.raw("?? (??,??,??,??,??,??,??,??)", [TableName.Groups, ...groupRoleFields])) + .onConflict("id") + .merge(groupRoleFields); + + const projectMembershipRoleFields = [ + "id", + "role", + "projectMembershipId", + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + "createdAt", + "updatedAt" + ]; + + await knex + .insert( + knex(TableName.MembershipRole) + .join(TableName.Membership, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .select( + knex.ref("id").withSchema(TableName.MembershipRole), + "role", + knex.ref("membershipId").withSchema(TableName.MembershipRole), + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + knex.ref("createdAt").withSchema(TableName.MembershipRole), + knex.ref("updatedAt").withSchema(TableName.MembershipRole) + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.ProjectUserMembershipRole, + ...projectMembershipRoleFields + ]) + ) + .onConflict("id") + .merge(projectMembershipRoleFields); + + await knex + .insert( + knex(TableName.MembershipRole) + .join(TableName.Membership, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .select( + knex.ref("id").withSchema(TableName.MembershipRole), + "role", + knex.ref("membershipId").withSchema(TableName.MembershipRole), + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + knex.ref("createdAt").withSchema(TableName.MembershipRole), + knex.ref("updatedAt").withSchema(TableName.MembershipRole) + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.IdentityProjectMembershipRole, + ...projectMembershipRoleFields + ]) + ) + .onConflict("id") + .merge(projectMembershipRoleFields); + + await knex + .insert( + knex(TableName.MembershipRole) + .join(TableName.Membership, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .select( + knex.ref("id").withSchema(TableName.MembershipRole), + "role", + knex.ref("membershipId").withSchema(TableName.MembershipRole), + "customRoleId", + "isTemporary", + "temporaryMode", + "temporaryRange", + "temporaryAccessStartTime", + "temporaryAccessEndTime", + knex.ref("createdAt").withSchema(TableName.MembershipRole), + knex.ref("updatedAt").withSchema(TableName.MembershipRole) + ) + ) + .into( + knex.raw("?? (??,??,??,??,??,??,??,??,??,??,??)", [ + TableName.GroupProjectMembershipRole, + ...projectMembershipRoleFields + ]) + ) + .onConflict("id") + .merge(projectMembershipRoleFields); +}; + +const rollbackRoleData = async (knex: Knex) => { + const orgRoleFields = ["id", "name", "description", "slug", "permissions", "createdAt", "updatedAt", "orgId"]; + + await knex + .insert( + knex(TableName.Role) + .whereNotNull("orgId") + .select("id", "name", "description", "slug", "permissions", "createdAt", "updatedAt", "orgId") + ) + .into(knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??)", [TableName.OrgRoles, ...orgRoleFields])) + .onConflict("id") + .merge(orgRoleFields); + + const projectRoleFields = ["id", "name", "description", "slug", "permissions", "createdAt", "updatedAt", "projectId"]; + + await knex + .insert( + knex(TableName.Role) + .whereNotNull("projectId") + .select("id", "name", "description", "slug", "permissions", "createdAt", "updatedAt", "projectId") + ) + .into(knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??)", [TableName.ProjectRoles, ...projectRoleFields])) + .onConflict("id") + .merge(projectRoleFields); +}; + +const rollbackMembershipData = async (knex: Knex) => { + const orgMembershipFields = [ + "id", + "status", + "inviteEmail", + "createdAt", + "updatedAt", + "userId", + "orgId", + "projectFavorites", + "isActive", + "lastInvitedAt", + "lastLoginAuthMethod", + "lastLoginTime", + "role", + "roleId" + ]; + await knex + .insert( + knex(TableName.Membership) + .leftJoin(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .select( + knex.ref("id").withSchema(TableName.Membership), + "status", + "inviteEmail", + knex.ref("createdAt").withSchema(TableName.Membership), + knex.ref("updatedAt").withSchema(TableName.Membership), + "actorUserId", + "scopeOrgId", + "projectFavorites", + "isActive", + "lastInvitedAt", + "lastLoginAuthMethod", + "lastLoginTime", + "role", + "customRoleId" + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??, ??)", [ + TableName.OrgMembership, + ...orgMembershipFields + ]) + ) + .onConflict("id") + .merge(orgMembershipFields); + + const identityOrgMembershipFields = [ + "id", + "identityId", + "orgId", + "lastLoginAuthMethod", + "lastLoginTime", + "createdAt", + "updatedAt", + "role", + "roleId" + ]; + + await knex + .insert( + knex(TableName.Membership) + .leftJoin(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .select( + knex.ref("id").withSchema(TableName.Membership), + "actorIdentityId", + "scopeOrgId", + "lastLoginAuthMethod", + "lastLoginTime", + knex.ref("createdAt").withSchema(TableName.Membership), + knex.ref("updatedAt").withSchema(TableName.Membership), + "role", + "customRoleId" + ) + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??, ??, ??, ??, ??)", [ + TableName.IdentityOrgMembership, + ...identityOrgMembershipFields + ]) + ) + .onConflict("id") + .merge(identityOrgMembershipFields); + + const projectMembershipFields = ["id", "userId", "projectId", "createdAt", "updatedAt"]; + + await knex + .insert( + knex(TableName.Membership) + .where("scope", AccessScope.Project) + .whereNotNull("actorUserId") + .select("id", "actorUserId", "scopeProjectId", "createdAt", "updatedAt") + ) + .into(knex.raw("?? (??, ??, ??, ??, ??)", [TableName.ProjectMembership, ...projectMembershipFields])) + .onConflict("id") + .merge(projectMembershipFields); + + const identityProjectMembershipFields = ["id", "identityId", "projectId", "createdAt", "updatedAt"]; + + await knex + .insert( + knex(TableName.Membership) + .where("scope", AccessScope.Project) + .whereNotNull("actorIdentityId") + .select("id", "actorIdentityId", "scopeProjectId", "createdAt", "updatedAt") + ) + .into( + knex.raw("?? (??, ??, ??, ??, ??)", [TableName.IdentityProjectMembership, ...identityProjectMembershipFields]) + ) + .onConflict("id") + .merge(identityProjectMembershipFields); + + const groupProjectMembershipFields = ["id", "groupId", "projectId", "createdAt", "updatedAt"]; + + await knex + .insert( + knex(TableName.Membership) + .where("scope", AccessScope.Project) + .whereNotNull("actorGroupId") + .select("id", "actorGroupId", "scopeProjectId", "createdAt", "updatedAt") + ) + .into(knex.raw("?? (??, ??, ??, ??, ??)", [TableName.GroupProjectMembership, ...groupProjectMembershipFields])) + .onConflict("id") + .merge(groupProjectMembershipFields); +}; + +export async function down(knex: Knex): Promise { + const hasRoleTable = await knex.schema.hasTable(TableName.Role); + if (hasRoleTable) { + await rollbackRoleData(knex); + } + + const hasMembershipTable = await knex.schema.hasTable(TableName.Membership); + if (hasMembershipTable) { + await rollbackMembershipData(knex); + } + + const hasMembershipRoleTable = await knex.schema.hasTable(TableName.MembershipRole); + if (hasMembershipRoleTable) { + await rollbackMembershipRoleData(knex); + } + + const hasAdditionalPrivilegeTable = await knex.schema.hasTable(TableName.AdditionalPrivilege); + if (hasAdditionalPrivilegeTable) { + await rollbackAdditionalPrivilegeData(knex); + } + + // Restore foreign key references + const hasApColumnInAccessApprovalRequest = await knex.schema.hasColumn( + TableName.AccessApprovalRequest, + "privilegeId" + ); + if (hasApColumnInAccessApprovalRequest) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign("privilegeId"); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege); + }); + } + + const hasExternalGroupRoleMappingRoleColumn = await knex.schema.hasColumn( + TableName.ExternalGroupOrgRoleMapping, + "roleId" + ); + if (hasExternalGroupRoleMappingRoleColumn) { + await knex.schema.alterTable(TableName.ExternalGroupOrgRoleMapping, (t) => { + t.dropForeign("roleId"); + t.foreign("roleId").references("id").inTable(TableName.OrgRoles); + }); + } + + // Drop new tables + await dropOnUpdateTrigger(knex, TableName.AdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.AdditionalPrivilege); + + await dropOnUpdateTrigger(knex, TableName.MembershipRole); + await knex.schema.dropTableIfExists(TableName.MembershipRole); + + await dropOnUpdateTrigger(knex, TableName.Membership); + await knex.schema.dropTableIfExists(TableName.Membership); + + await dropOnUpdateTrigger(knex, TableName.Role); + await knex.schema.dropTableIfExists(TableName.Role); + + await dropOnUpdateTrigger(knex, TableName.Namespace); + await knex.schema.dropTableIfExists(TableName.Namespace); +} diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts new file mode 100644 index 000000000..713953f14 --- /dev/null +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -0,0 +1,117 @@ +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.PkiCertificateTemplateV2))) { + await knex.schema.createTable(TableName.PkiCertificateTemplateV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project); + + t.string("name").notNullable(); + t.string("description"); + + t.jsonb("subject"); + t.jsonb("sans"); + t.jsonb("keyUsages"); + t.jsonb("extendedKeyUsages"); + t.jsonb("algorithms"); + t.jsonb("validity"); + + t.timestamps(true, true, true); + + t.unique(["name", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); + } + + if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("disableBootstrapCaValidation").defaultTo(false); + t.text("hashedPassphrase").notNullable(); + t.binary("encryptedCaChain"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("autoRenew").defaultTo(false); + t.integer("autoRenewDays"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiCertificateProfile))) { + await knex.schema.createTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority); + + t.uuid("certificateTemplateId").notNullable(); + t.foreign("certificateTemplateId").references("id").inTable(TableName.PkiCertificateTemplateV2); + + t.string("slug").notNullable(); + t.string("description"); + t.string("enrollmentType").notNullable().checkIn(["api", "est"]); + + t.uuid("estConfigId"); + t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL"); + + t.uuid("apiConfigId"); + t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL"); + + t.timestamps(true, true, true); + + t.unique(["slug", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + } + + if (!(await knex.schema.hasColumn(TableName.Certificate, "profileId"))) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("profileId"); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL"); + t.index("profileId"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign(["profileId"]); + t.dropIndex("profileId"); + t.dropColumn("profileId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.PkiCertificateProfile); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + + await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiCertificateTemplateV2); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); +} diff --git a/backend/src/db/migrations/20251008003912_relay-heartbeat.ts b/backend/src/db/migrations/20251008003912_relay-heartbeat.ts new file mode 100644 index 000000000..f19c540cb --- /dev/null +++ b/backend/src/db/migrations/20251008003912_relay-heartbeat.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Relay, "heartbeat"))) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.datetime("heartbeat"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Relay, "heartbeat")) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.dropColumn("heartbeat"); + }); + } +} diff --git a/backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts b/backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts new file mode 100644 index 000000000..2abc1cf69 --- /dev/null +++ b/backend/src/db/migrations/20251008220303_relay-gateway-health-alarm.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.Relay, "healthAlertedAt"))) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.datetime("healthAlertedAt"); + }); + } + if (!(await knex.schema.hasColumn(TableName.GatewayV2, "healthAlertedAt"))) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.datetime("healthAlertedAt"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.GatewayV2, "healthAlertedAt")) { + await knex.schema.alterTable(TableName.GatewayV2, (t) => { + t.dropColumn("healthAlertedAt"); + }); + } + if (await knex.schema.hasColumn(TableName.Relay, "healthAlertedAt")) { + await knex.schema.alterTable(TableName.Relay, (t) => { + t.dropColumn("healthAlertedAt"); + }); + } +} diff --git a/backend/src/db/migrations/20251010111410_add-kmip-metadata.ts b/backend/src/db/migrations/20251010111410_add-kmip-metadata.ts new file mode 100644 index 000000000..2875cc0ad --- /dev/null +++ b/backend/src/db/migrations/20251010111410_add-kmip-metadata.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.KmsKey, "kmipMetadata"))) { + await knex.schema.alterTable(TableName.KmsKey, (t) => { + t.jsonb("kmipMetadata"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.KmsKey, "kmipMetadata")) { + await knex.schema.alterTable(TableName.KmsKey, (t) => { + t.dropColumn("kmipMetadata"); + }); + } +} diff --git a/backend/src/db/migrations/20251011141618_membership-identity-index.ts b/backend/src/db/migrations/20251011141618_membership-identity-index.ts new file mode 100644 index 000000000..8d33b61a0 --- /dev/null +++ b/backend/src/db/migrations/20251011141618_membership-identity-index.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasScopeColumn = await knex.schema.hasColumn(TableName.Membership, "scope"); + const hasActorIdentityColumn = await knex.schema.hasColumn(TableName.Membership, "actorIdentityId"); + if (hasScopeColumn && hasActorIdentityColumn) { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.index(["scope", "actorIdentityId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasScopeColumn = await knex.schema.hasColumn(TableName.Membership, "scope"); + const hasActorIdentityColumn = await knex.schema.hasColumn(TableName.Membership, "actorIdentityId"); + if (hasScopeColumn && hasActorIdentityColumn) { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.dropIndex(["scope", "actorIdentityId"]); + }); + } +} diff --git a/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts new file mode 100644 index 000000000..e5f74a0e0 --- /dev/null +++ b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.VaultExternalMigrationConfig))) { + await knex.schema.createTable(TableName.VaultExternalMigrationConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.string("namespace").notNullable(); + + t.uuid("connectionId"); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + + t.timestamps(true, true, true); + t.unique(["orgId", "namespace"]); + }); + + await createOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.VaultExternalMigrationConfig); + await dropOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); +} diff --git a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts new file mode 100644 index 000000000..b83dae0ae --- /dev/null +++ b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.boolean("rotationEnabled").notNullable().defaultTo(false); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.integer("rotationIntervalSeconds").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.timestamp("lastRotatedAt").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials"))) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.binary("encryptedRotationAccountCredentials").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials")) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.dropColumn("encryptedRotationAccountCredentials"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationEnabled"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationIntervalSeconds"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("lastRotatedAt"); + }); + } +} diff --git a/backend/src/db/migrations/20251018061215_sub-org.ts b/backend/src/db/migrations/20251018061215_sub-org.ts new file mode 100644 index 000000000..6378fa66a --- /dev/null +++ b/backend/src/db/migrations/20251018061215_sub-org.ts @@ -0,0 +1,68 @@ +import { Knex } from "knex"; + +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; + +import { AccessScope, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); + if (!hasParentOrgId) { + await knex.schema.alterTable(TableName.Organization, async (t) => { + // the one just above the chain + t.uuid("parentOrgId"); + t.foreign("parentOrgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + // this would root organization containing various informations like billing etc + t.uuid("rootOrgId"); + t.foreign("rootOrgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + await dropConstraintIfExists(TableName.Organization, "organizations_slug_unique", knex); + t.unique(["rootOrgId", "parentOrgId", "slug"]); + }); + + // had to switch to raw for null not distinct + } + + const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); + if (!hasIdentityOrgCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + + await knex.raw( + ` + UPDATE ?? AS identity + SET "orgId" = membership."scopeOrgId" + FROM ?? AS membership + WHERE + membership."actorIdentityId" = identity."id" + AND membership."scope" = ? +`, + [TableName.Identity, TableName.Membership, AccessScope.Organization] + ); + + await knex.raw(`DELETE FROM ?? WHERE "orgId" IS NULL`, [TableName.Identity]); + + await knex.schema.alterTable(TableName.Identity, (t) => { + t.uuid("orgId").notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); + const hasRootOrgId = await knex.schema.hasColumn(TableName.Organization, "rootOrgId"); + if (hasParentOrgId || hasRootOrgId) { + await knex.schema.alterTable(TableName.Organization, (t) => { + if (hasParentOrgId) t.dropColumn("parentOrgId"); + if (hasRootOrgId) t.dropColumn("rootOrgId"); + }); + } + + const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); + if (hasIdentityOrgCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("orgId"); + }); + } +} diff --git a/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts b/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts new file mode 100644 index 000000000..56fe82b2c --- /dev/null +++ b/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts @@ -0,0 +1,60 @@ +import { Knex } from "knex"; + +import { AccessScope, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGroupsTable = await knex.schema.hasTable(TableName.Groups); + const hasMembershipTable = await knex.schema.hasTable(TableName.Membership); + const hasMembershipRoleTable = await knex.schema.hasTable(TableName.MembershipRole); + + if (!hasGroupsTable || !hasMembershipTable || !hasMembershipRoleTable) { + return; + } + + const groupsWithoutMembership = await knex + .select( + `${TableName.Groups}.id`, + `${TableName.Groups}.orgId`, + `${TableName.Groups}.role`, + `${TableName.Groups}.roleId` + ) + .from(TableName.Groups) + .leftJoin(TableName.Membership, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .whereNull(`${TableName.Membership}.actorGroupId`); + + if (groupsWithoutMembership.length > 0) { + const membershipInserts = groupsWithoutMembership.map((group) => ({ + actorGroupId: group.id, + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + isActive: true + })); + + const insertedMemberships = await knex(TableName.Membership).insert(membershipInserts).returning("*"); + + const membershipRoleInserts = insertedMemberships.map((membership, index) => { + const group = groupsWithoutMembership[index]; + return { + membershipId: membership.id, + role: group.role, + customRoleId: group.roleId + }; + }); + + await knex(TableName.MembershipRole).insert(membershipRoleInserts); + } + + await knex.schema.alterTable(TableName.Membership, (t) => { + t.check( + `("actorUserId" IS NOT NULL OR "actorIdentityId" IS NOT NULL OR "actorGroupId" IS NOT NULL)`, + undefined, + "at_least_one_actor" + ); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.dropChecks("at_least_one_actor"); + }); +} diff --git a/backend/src/db/migrations/utils/env-config.ts b/backend/src/db/migrations/utils/env-config.ts index de32f4db9..3a08f0123 100644 --- a/backend/src/db/migrations/utils/env-config.ts +++ b/backend/src/db/migrations/utils/env-config.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { crypto } from "@app/lib/crypto/cryptography"; +import { removeTrailingSlash } from "@app/lib/fn"; import { zpStr } from "@app/lib/zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; const envSchema = z @@ -22,13 +25,17 @@ const envSchema = z HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), HSM_KEY_LABEL: zpStr(z.string().optional()), - HSM_SLOT: z.coerce.number().optional().default(0) + HSM_SLOT: z.coerce.number().optional().default(0), + + LICENSE_SERVER_URL: zpStr(z.string().optional().default("https://portal.infisical.com")), + LICENSE_SERVER_KEY: zpStr(z.string().optional()), + LICENSE_KEY: zpStr(z.string().optional()), + LICENSE_KEY_OFFLINE: zpStr(z.string().optional()), + INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()), + + SITE_URL: zpStr(z.string().transform((val) => (val ? removeTrailingSlash(val) : val))).optional() }) // To ensure that basic encryption is always possible. - .refine( - (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), - "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." - ) .transform((data) => ({ ...data, isHsmConfigured: @@ -37,7 +44,27 @@ const envSchema = z export type TMigrationEnvConfig = z.infer; -export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory) => { +export const getMigrationHsmConfig = () => { + const parsedEnv = envSchema.safeParse(process.env); + if (!parsedEnv.success) { + console.error("Invalid environment variables. Check the error below"); + console.error(parsedEnv.error.issues); + process.exit(-1); + } + return { + isHsmConfigured: parsedEnv.data.isHsmConfigured, + HSM_PIN: parsedEnv.data.HSM_PIN, + HSM_SLOT: parsedEnv.data.HSM_SLOT, + HSM_LIB_PATH: parsedEnv.data.HSM_LIB_PATH, + HSM_KEY_LABEL: parsedEnv.data.HSM_KEY_LABEL + }; +}; + +export const getMigrationEnvConfig = async ( + superAdminDAL: TSuperAdminDALFactory, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory +) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { // eslint-disable-next-line no-console @@ -53,7 +80,7 @@ export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory let envCfg = Object.freeze(parsedEnv.data); - const fipsEnabled = await crypto.initialize(superAdminDAL, envCfg); + const fipsEnabled = await crypto.initialize(superAdminDAL, hsmService, kmsRootConfigDAL, envCfg); // Fix for 128-bit entropy encryption key expansion issue: // In FIPS it is not ideal to expand a 128-bit key into 256-bit. We solved this issue in the past by creating the ROOT_ENCRYPTION_KEY. diff --git a/backend/src/db/migrations/utils/services.ts b/backend/src/db/migrations/utils/services.ts index 0e071e6fe..cd3e5ac23 100644 --- a/backend/src/db/migrations/utils/services.ts +++ b/backend/src/db/migrations/utils/services.ts @@ -1,28 +1,23 @@ import { Knex } from "knex"; -import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { initializeHsmModule, isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { licenseDALFactory } from "@app/ee/services/license/license-dal"; +import { licenseServiceFactory } from "@app/ee/services/license/license-service"; +import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; +import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; -import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; -import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; -import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; -import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; -import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; -import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { BadRequestError } from "@app/lib/errors"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { orgDALFactory } from "@app/services/org/org-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; -import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; -import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; -import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; -import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; -import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; -import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { roleDALFactory } from "@app/services/role/role-dal"; +import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { userDALFactory } from "@app/services/user/user-dal"; import { TMigrationEnvConfig } from "./env-config"; @@ -33,8 +28,11 @@ type TDependencies = { keyStore: TKeyStoreFactory; }; -export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }: TDependencies) => { - // eslint-disable-next-line no-param-reassign +type THsmServiceDependencies = { + envConfig: Pick; +}; + +export const getMigrationHsmService = async ({ envConfig }: THsmServiceDependencies) => { const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); @@ -43,67 +41,72 @@ export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore } envConfig }); - const orgDAL = orgDALFactory(db); - const kmsRootConfigDAL = kmsRootConfigDALFactory(db); - const kmsDAL = kmskeyDALFactory(db); - const internalKmsDAL = internalKmsDALFactory(db); - const projectDAL = projectDALFactory(db); - - const kmsService = kmsServiceFactory({ - kmsRootConfigDAL, - keyStore, - kmsDAL, - internalKmsDAL, - orgDAL, - projectDAL, - hsmService, - envConfig - }); - await hsmService.startService(); - await kmsService.startService(); - return { kmsService }; + return { hsmService }; }; -export const getMigrationPITServices = async ({ - db, - keyStore, - envConfig -}: { - db: Knex; - keyStore: TKeyStoreFactory; - envConfig: TMigrationEnvConfig; -}) => { +export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }: TDependencies) => { + // ----- DAL dependencies ----- + const orgDAL = orgDALFactory(db); + const licenseDAL = licenseDALFactory(db); + const permissionDAL = permissionDALFactory(db); const projectDAL = projectDALFactory(db); - const folderCommitDAL = folderCommitDALFactory(db); - const folderCommitChangesDAL = folderCommitChangesDALFactory(db); - const folderCheckpointDAL = folderCheckpointDALFactory(db); - const folderTreeCheckpointDAL = folderTreeCheckpointDALFactory(db); + const roleDAL = roleDALFactory(db); const userDAL = userDALFactory(db); const identityDAL = identityDALFactory(db); - const folderDAL = secretFolderDALFactory(db); - const folderVersionDAL = secretFolderVersionDALFactory(db); - const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); - const folderCheckpointResourcesDAL = folderCheckpointResourcesDALFactory(db); - const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore }); - const folderTreeCheckpointResourcesDAL = folderTreeCheckpointResourcesDALFactory(db); - const secretTagDAL = secretTagDALFactory(db); - - const orgDAL = orgDALFactory(db); + const serviceTokenDAL = serviceTokenDALFactory(db); const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); - const resourceMetadataDAL = resourceMetadataDALFactory(db); - const hsmModule = initializeHsmModule(envConfig); - hsmModule.initialize(); + // ----- Service dependencies ----- + const permissionService = permissionServiceFactory({ + permissionDAL, + serviceTokenDAL, + projectDAL, + keyStore, + roleDAL, + userDAL, + identityDAL + }); - const hsmService = hsmServiceFactory({ - hsmModule: hsmModule.getModule(), + const licenseService = licenseServiceFactory({ + permissionService, + orgDAL, + licenseDAL, + keyStore, + projectDAL, envConfig }); + // ----- HSM startup ----- + + const { hsmService } = await getMigrationHsmService({ envConfig }); + + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL, + licenseService + }); + + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + if (needsEncryptionKey) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment.\nYou can enable HSM encryption in the Server Console." + }); + } + } + + // ----- KMS startup ----- + const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, @@ -115,27 +118,7 @@ export const getMigrationPITServices = async ({ envConfig }); - await hsmService.startService(); - await kmsService.startService(); + await kmsService.startService(hsmStatus); - const folderCommitService = folderCommitServiceFactory({ - folderCommitDAL, - folderCommitChangesDAL, - folderCheckpointDAL, - folderTreeCheckpointDAL, - userDAL, - identityDAL, - folderDAL, - folderVersionDAL, - secretVersionV2BridgeDAL, - projectDAL, - folderCheckpointResourcesDAL, - secretV2BridgeDAL, - folderTreeCheckpointResourcesDAL, - kmsService, - secretTagDAL, - resourceMetadataDAL - }); - - return { folderCommitService }; + return { kmsService, hsmService }; }; diff --git a/backend/src/db/schemas/additional-privileges.ts b/backend/src/db/schemas/additional-privileges.ts new file mode 100644 index 000000000..8013a6961 --- /dev/null +++ b/backend/src/db/schemas/additional-privileges.ts @@ -0,0 +1,30 @@ +// 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 AdditionalPrivilegesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + permissions: z.unknown(), + actorUserId: z.string().uuid().nullable().optional(), + actorIdentityId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid().nullable().optional(), + projectId: z.string().nullable().optional(), + namespaceId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAdditionalPrivileges = z.infer; +export type TAdditionalPrivilegesInsert = Omit, TImmutableDBKeys>; +export type TAdditionalPrivilegesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 6bedf01ad..63122f662 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -26,7 +26,8 @@ export const CertificatesSchema = z.object({ keyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional(), projectId: z.string(), - pkiSubscriberId: z.string().uuid().nullable().optional() + pkiSubscriberId: z.string().uuid().nullable().optional(), + profileId: z.string().uuid().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/gateways-v2.ts b/backend/src/db/schemas/gateways-v2.ts index 6aff8a168..4a48e3ce5 100644 --- a/backend/src/db/schemas/gateways-v2.ts +++ b/backend/src/db/schemas/gateways-v2.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const GatewaysV2Schema = z.object({ @@ -15,7 +17,9 @@ export const GatewaysV2Schema = z.object({ identityId: z.string().uuid(), relayId: z.string().uuid().nullable().optional(), name: z.string(), - heartbeat: z.date().nullable().optional() + heartbeat: z.date().nullable().optional(), + encryptedPamSessionKey: zodBuffer.nullable().optional(), + healthAlertedAt: z.date().nullable().optional() }); export type TGatewaysV2 = z.infer; diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index a592e2480..06c37ff22 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -13,7 +13,8 @@ export const IdentitiesSchema = z.object({ authMethod: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - hasDeleteProtection: z.boolean().default(false) + hasDeleteProtection: z.boolean().default(false), + orgId: z.string().uuid() }); export type TIdentities = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 8cbaa00bb..4f0f221ff 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -3,6 +3,7 @@ export * from "./access-approval-policies-approvers"; export * from "./access-approval-policies-bypassers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; +export * from "./additional-privileges"; export * from "./api-keys"; export * from "./app-connections"; export * from "./audit-log-streams"; @@ -73,8 +74,11 @@ export * from "./kms-keys"; export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; +export * from "./membership-roles"; +export * from "./memberships"; export * from "./microsoft-teams-integrations"; export * from "./models"; +export * from "./namespaces"; export * from "./oidc-configs"; export * from "./org-bots"; export * from "./org-gateway-config"; @@ -83,9 +87,17 @@ export * from "./org-memberships"; export * from "./org-relay-config"; export * from "./org-roles"; export * from "./organizations"; +export * from "./pam-accounts"; +export * from "./pam-folders"; +export * from "./pam-resources"; +export * from "./pam-sessions"; export * from "./pki-alerts"; +export * from "./pki-api-enrollment-configs"; +export * from "./pki-certificate-profiles"; +export * from "./pki-certificate-templates-v2"; export * from "./pki-collection-items"; export * from "./pki-collections"; +export * from "./pki-est-enrollment-configs"; export * from "./pki-subscribers"; export * from "./pki-syncs"; export * from "./project-bots"; @@ -104,6 +116,7 @@ export * from "./projects"; export * from "./rate-limit"; export * from "./relays"; export * from "./resource-metadata"; +export * from "./roles"; export * from "./saml-configs"; export * from "./scim-tokens"; export * from "./secret-approval-policies"; @@ -167,5 +180,6 @@ export * from "./user-aliases"; export * from "./user-encryption-keys"; export * from "./user-group-membership"; export * from "./users"; +export * from "./vault-external-migration-configs"; export * from "./webhooks"; export * from "./workflow-integrations"; diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts index ccb779d57..45ff69997 100644 --- a/backend/src/db/schemas/kms-keys.ts +++ b/backend/src/db/schemas/kms-keys.ts @@ -17,7 +17,8 @@ export const KmsKeysSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), projectId: z.string().nullable().optional(), - keyUsage: z.string().default("encrypt-decrypt") + keyUsage: z.string().default("encrypt-decrypt"), + kmipMetadata: z.unknown().nullable().optional() }); export type TKmsKeys = z.infer; diff --git a/backend/src/db/schemas/membership-roles.ts b/backend/src/db/schemas/membership-roles.ts new file mode 100644 index 000000000..98927c9cb --- /dev/null +++ b/backend/src/db/schemas/membership-roles.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const MembershipRolesSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + customRoleId: z.string().uuid().nullable().optional(), + membershipId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TMembershipRoles = z.infer; +export type TMembershipRolesInsert = Omit, TImmutableDBKeys>; +export type TMembershipRolesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/memberships.ts b/backend/src/db/schemas/memberships.ts new file mode 100644 index 000000000..e871a5627 --- /dev/null +++ b/backend/src/db/schemas/memberships.ts @@ -0,0 +1,32 @@ +// 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 MembershipsSchema = z.object({ + id: z.string().uuid(), + scope: z.string(), + actorUserId: z.string().uuid().nullable().optional(), + actorIdentityId: z.string().uuid().nullable().optional(), + actorGroupId: z.string().uuid().nullable().optional(), + scopeOrgId: z.string().uuid(), + scopeProjectId: z.string().nullable().optional(), + scopeNamespaceId: z.string().uuid().nullable().optional(), + isActive: z.boolean().default(true), + status: z.string().nullable().optional(), + inviteEmail: z.string().nullable().optional(), + lastInvitedAt: z.date().nullable().optional(), + lastLoginAuthMethod: z.string().nullable().optional(), + lastLoginTime: z.date().nullable().optional(), + projectFavorites: z.string().array().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TMemberships = z.infer; +export type TMembershipsInsert = Omit, TImmutableDBKeys>; +export type TMembershipsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 385a328b6..86bc929b8 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -23,6 +23,10 @@ export enum TableName { CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", + PkiCertificateTemplateV2 = "pki_certificate_templates_v2", + PkiCertificateProfile = "pki_certificate_profiles", + PkiEstEnrollmentConfig = "pki_est_enrollment_configs", + PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", @@ -178,6 +182,14 @@ export enum TableName { SecretScanningScan = "secret_scanning_scans", SecretScanningFinding = "secret_scanning_findings", SecretScanningConfig = "secret_scanning_configs", + + Membership = "memberships", + MembershipRole = "membership_roles", + Role = "roles", + AdditionalPrivilege = "additional_privileges", + + Namespace = "namespaces", + // reminders Reminder = "reminders", ReminderRecipient = "reminders_recipients", @@ -189,7 +201,15 @@ export enum TableName { Relay = "relays", GatewayV2 = "gateways_v2", - KeyValueStore = "key_value_store" + KeyValueStore = "key_value_store", + + // PAM + PamFolder = "pam_folders", + PamResource = "pam_resources", + PamAccount = "pam_accounts", + PamSession = "pam_sessions", + + VaultExternalMigrationConfig = "vault_external_migration_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; @@ -281,7 +301,8 @@ export enum ProjectType { CertificateManager = "cert-manager", KMS = "kms", SSH = "ssh", - SecretScanning = "secret-scanning" + SecretScanning = "secret-scanning", + PAM = "pam" } export enum ActionProjectType { @@ -290,11 +311,50 @@ export enum ActionProjectType { KMS = ProjectType.KMS, SSH = ProjectType.SSH, SecretScanning = ProjectType.SecretScanning, + PAM = ProjectType.PAM, // project operations that happen on all types Any = "any" } +export enum OrganizationActionScope { + ChildOrganization = "child-organization-only", + ParentOrganization = "parent-organization-only", + Any = "any" +} + +export enum TemporaryPermissionMode { + Relative = "relative" +} + +export enum MembershipActors { + Group = "group", + User = "user", + Identity = "identity" +} + export enum SortDirection { ASC = "asc", DESC = "desc" } + +export enum AccessScope { + Organization = "organization", + Namespace = "namespace", + Project = "project" +} + +export type AccessScopeData = + | { + scope: AccessScope.Organization; + orgId: string; + } + | { + scope: AccessScope.Namespace; + orgId: string; + namespaceId: string; + } + | { + scope: AccessScope.Project; + orgId: string; + projectId: string; + }; diff --git a/backend/src/db/schemas/namespaces.ts b/backend/src/db/schemas/namespaces.ts new file mode 100644 index 000000000..b9f092fd3 --- /dev/null +++ b/backend/src/db/schemas/namespaces.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 NamespacesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TNamespaces = z.infer; +export type TNamespacesInsert = Omit, TImmutableDBKeys>; +export type TNamespacesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index afc9e2b73..a1c01151f 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -38,7 +38,9 @@ export const OrganizationsSchema = z.object({ maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), maxSharedSecretViewLimit: z.number().nullable().optional(), googleSsoAuthEnforced: z.boolean().default(false), - googleSsoAuthLastUsed: z.date().nullable().optional() + googleSsoAuthLastUsed: z.date().nullable().optional(), + parentOrgId: z.string().uuid().nullable().optional(), + rootOrgId: z.string().uuid().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts new file mode 100644 index 000000000..7e78e0874 --- /dev/null +++ b/backend/src/db/schemas/pam-accounts.ts @@ -0,0 +1,29 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamAccountsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + folderId: z.string().uuid().nullable().optional(), + resourceId: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + encryptedCredentials: zodBuffer, + createdAt: z.date(), + updatedAt: z.date(), + rotationEnabled: z.boolean().default(false), + rotationIntervalSeconds: z.number().nullable().optional(), + lastRotatedAt: z.date().nullable().optional() +}); + +export type TPamAccounts = z.infer; +export type TPamAccountsInsert = Omit, TImmutableDBKeys>; +export type TPamAccountsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pam-folders.ts b/backend/src/db/schemas/pam-folders.ts new file mode 100644 index 000000000..80243c1bc --- /dev/null +++ b/backend/src/db/schemas/pam-folders.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamFoldersSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + parentId: z.string().uuid().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPamFolders = z.infer; +export type TPamFoldersInsert = Omit, TImmutableDBKeys>; +export type TPamFoldersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts new file mode 100644 index 000000000..325f6eddc --- /dev/null +++ b/backend/src/db/schemas/pam-resources.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamResourcesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + gatewayId: z.string().uuid(), + resourceType: z.string(), + encryptedConnectionDetails: zodBuffer, + createdAt: z.date(), + updatedAt: z.date(), + encryptedRotationAccountCredentials: zodBuffer.nullable().optional() +}); + +export type TPamResources = z.infer; +export type TPamResourcesInsert = Omit, TImmutableDBKeys>; +export type TPamResourcesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pam-sessions.ts b/backend/src/db/schemas/pam-sessions.ts new file mode 100644 index 000000000..12e4adfcf --- /dev/null +++ b/backend/src/db/schemas/pam-sessions.ts @@ -0,0 +1,35 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PamSessionsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + accountId: z.string().uuid().nullable().optional(), + resourceType: z.string(), + resourceName: z.string(), + accountName: z.string(), + userId: z.string().uuid().nullable().optional(), + actorName: z.string(), + actorEmail: z.string(), + actorIp: z.string(), + actorUserAgent: z.string(), + status: z.string(), + encryptedLogsBlob: zodBuffer.nullable().optional(), + expiresAt: z.date(), + startedAt: z.date().nullable().optional(), + endedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPamSessions = z.infer; +export type TPamSessionsInsert = Omit, TImmutableDBKeys>; +export type TPamSessionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts new file mode 100644 index 000000000..710b0dee4 --- /dev/null +++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiApiEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + autoRenew: z.boolean().default(false).nullable().optional(), + autoRenewDays: z.number().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiApiEnrollmentConfigs = z.infer; +export type TPkiApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiApiEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts new file mode 100644 index 000000000..368770c3e --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -0,0 +1,28 @@ +// 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 PkiCertificateProfilesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z.string(), + description: z.string().nullable().optional(), + enrollmentType: z.string(), + estConfigId: z.string().uuid().nullable().optional(), + apiConfigId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateProfiles = z.infer; +export type TPkiCertificateProfilesInsert = Omit, TImmutableDBKeys>; +export type TPkiCertificateProfilesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-templates-v2.ts b/backend/src/db/schemas/pki-certificate-templates-v2.ts new file mode 100644 index 000000000..de4603887 --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-templates-v2.ts @@ -0,0 +1,29 @@ +// 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 PkiCertificateTemplatesV2Schema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + subject: z.unknown().nullable().optional(), + sans: z.unknown().nullable().optional(), + keyUsages: z.unknown().nullable().optional(), + extendedKeyUsages: z.unknown().nullable().optional(), + algorithms: z.unknown().nullable().optional(), + validity: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateTemplatesV2 = z.infer; +export type TPkiCertificateTemplatesV2Insert = Omit, TImmutableDBKeys>; +export type TPkiCertificateTemplatesV2Update = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-est-enrollment-configs.ts b/backend/src/db/schemas/pki-est-enrollment-configs.ts new file mode 100644 index 000000000..4a3b16eeb --- /dev/null +++ b/backend/src/db/schemas/pki-est-enrollment-configs.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiEstEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(), + hashedPassphrase: z.string(), + encryptedCaChain: zodBuffer.nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiEstEnrollmentConfigs = z.infer; +export type TPkiEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiEstEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/relays.ts b/backend/src/db/schemas/relays.ts index 4bb615e96..82a5fa830 100644 --- a/backend/src/db/schemas/relays.ts +++ b/backend/src/db/schemas/relays.ts @@ -14,7 +14,9 @@ export const RelaysSchema = z.object({ orgId: z.string().uuid().nullable().optional(), identityId: z.string().uuid().nullable().optional(), name: z.string(), - host: z.string() + host: z.string(), + heartbeat: z.date().nullable().optional(), + healthAlertedAt: z.date().nullable().optional() }); export type TRelays = z.infer; diff --git a/backend/src/db/schemas/roles.ts b/backend/src/db/schemas/roles.ts new file mode 100644 index 000000000..4801a57a6 --- /dev/null +++ b/backend/src/db/schemas/roles.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const RolesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + slug: z.string(), + permissions: z.unknown(), + orgId: z.string().uuid().nullable().optional(), + projectId: z.string().nullable().optional(), + namespaceId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TRoles = z.infer; +export type TRolesInsert = Omit, TImmutableDBKeys>; +export type TRolesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/user-notifications-default.ts b/backend/src/db/schemas/user-notifications-default.ts new file mode 100644 index 000000000..eabcdabc5 --- /dev/null +++ b/backend/src/db/schemas/user-notifications-default.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserNotificationsDefaultSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + orgId: z.string().uuid().nullable().optional(), + type: z.string(), + title: z.string(), + body: z.string().nullable().optional(), + link: z.string().nullable().optional(), + isRead: z.boolean().default(false), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserNotificationsDefault = z.infer; +export type TUserNotificationsDefaultInsert = Omit, TImmutableDBKeys>; +export type TUserNotificationsDefaultUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/vault-external-migration-configs.ts b/backend/src/db/schemas/vault-external-migration-configs.ts new file mode 100644 index 000000000..310166669 --- /dev/null +++ b/backend/src/db/schemas/vault-external-migration-configs.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const VaultExternalMigrationConfigsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + namespace: z.string(), + connectionId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TVaultExternalMigrationConfigs = z.infer; +export type TVaultExternalMigrationConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TVaultExternalMigrationConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index 43ce4dadf..9f42ef12b 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -1,7 +1,10 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { initLogger, logger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { AuthMethod } from "../../services/auth/auth-type"; @@ -17,7 +20,21 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); await knex(TableName.SuperAdmin).insert([ // eslint-disable-next-line diff --git a/backend/src/db/seeds/2-org.ts b/backend/src/db/seeds/2-org.ts index a02224dbc..99bd70f57 100644 --- a/backend/src/db/seeds/2-org.ts +++ b/backend/src/db/seeds/2-org.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus, TableName } from "../schemas"; +import { AccessScope, OrgMembershipRole, OrgMembershipStatus, TableName } from "../schemas"; import { seedData1 } from "../seed-data"; export async function seed(knex: Knex): Promise { @@ -24,13 +24,22 @@ export async function seed(knex: Knex): Promise { ]) .returning("*"); - await knex(TableName.OrgMembership).insert([ + const [membership] = await knex(TableName.Membership) + .insert([ + { + scope: AccessScope.Organization, + scopeOrgId: org.id, + actorUserId: user.id, + isActive: true, + status: OrgMembershipStatus.Accepted + } + ]) + .returning("*"); + + await knex(TableName.MembershipRole).insert([ { - role: OrgMembershipRole.Admin, - orgId: org.id, - status: OrgMembershipStatus.Accepted, - userId: user.id, - isActive: true + membershipId: membership.id, + role: OrgMembershipRole.Admin } ]); } diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index 47a41a95c..99083ab94 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -1,19 +1,23 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { initLogger, logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AuthMethod } from "@app/services/auth/auth-type"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { assignWorkspaceKeysToMembers, createProjectKey } from "@app/services/project/project-fns"; import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal"; -import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; -import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { userDALFactory } from "@app/services/user/user-dal"; import { + AccessScope, OrgMembershipRole, OrgMembershipStatus, ProjectMembershipRole, @@ -39,8 +43,8 @@ const createUserWithGhostUser = async ( ) => { const projectKeyDAL = projectKeyDALFactory(knex); const userDAL = userDALFactory(knex); - const projectMembershipDAL = projectMembershipDALFactory(knex); - const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(knex); + const membershipDAL = membershipUserDALFactory(knex); + const membershipRoleDAL = membershipRoleDALFactory(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. @@ -63,25 +67,36 @@ const createUserWithGhostUser = async ( .onConflict("userId") .merge(); - await knex(TableName.OrgMembership) + const [orgMembership] = await knex(TableName.Membership) .insert({ - orgId, - userId: ghostUser.id, - role: OrgMembershipRole.Admin, + scope: AccessScope.Organization, + scopeOrgId: orgId, + actorUserId: ghostUser.id, status: OrgMembershipStatus.Accepted, isActive: true }) .returning("*"); - const [projectMembership] = await knex(TableName.ProjectMembership) + await knex(TableName.MembershipRole).insert([ + { + membershipId: orgMembership.id, + role: OrgMembershipRole.Admin + } + ]); + + const [projectMembership] = await knex(TableName.Membership) .insert({ - userId: ghostUser.id, - projectId + actorUserId: ghostUser.id, + scopeProjectId: projectId, + scope: AccessScope.Project, + scopeOrgId: orgId, + status: OrgMembershipStatus.Accepted, + isActive: true }) .returning("*"); - await knex(TableName.ProjectUserMembershipRole).insert({ - projectMembershipId: projectMembership.id, + await knex(TableName.MembershipRole).insert({ + membershipId: projectMembership.id, role: ProjectMembershipRole.Admin }); @@ -142,17 +157,16 @@ const createUserWithGhostUser = async ( }); // Create a membership for the user - const userProjectMembership = await projectMembershipDAL.create( + const userProjectMembership = await membershipDAL.create( { - projectId, - userId: user.id + scopeProjectId: projectId, + scope: AccessScope.Project, + actorUserId: user.id, + scopeOrgId: orgId }, knex ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, - knex - ); + await membershipRoleDAL.create({ membershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, knex); // Create a project key for the user await projectKeyDAL.create( @@ -181,7 +195,21 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const [project] = await knex(TableName.Project) .insert({ @@ -195,10 +223,11 @@ export async function seed(knex: Knex): Promise { }) .returning("*"); - const userOrgMembership = await knex(TableName.OrgMembership) + const userOrgMembership = await knex(TableName.Membership) .where({ - orgId: seedData1.organization.id, - userId: seedData1.id + scopeOrgId: seedData1.organization.id, + actorUserId: seedData1.id, + scope: AccessScope.Organization }) .first(); diff --git a/backend/src/db/seeds/4-project-v3.ts b/backend/src/db/seeds/4-project-v3.ts index f89b965a6..920497a82 100644 --- a/backend/src/db/seeds/4-project-v3.ts +++ b/backend/src/db/seeds/4-project-v3.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { ProjectMembershipRole, ProjectType, ProjectVersion, TableName } from "../schemas"; +import { AccessScope, ProjectMembershipRole, ProjectType, ProjectVersion, TableName } from "../schemas"; import { seedData1 } from "../seed-data"; export const DEFAULT_PROJECT_ENVS = [ @@ -23,15 +23,17 @@ export async function seed(knex: Knex): Promise { }) .returning("*"); - const projectMembershipV3 = await knex(TableName.ProjectMembership) + const projectMembershipV3 = await knex(TableName.Membership) .insert({ - projectId: projectV2.id, - userId: seedData1.id + scopeProjectId: projectV2.id, + actorUserId: seedData1.id, + scope: AccessScope.Project, + scopeOrgId: seedData1.organization.id }) .returning("*"); - await knex(TableName.ProjectUserMembershipRole).insert({ + await knex(TableName.MembershipRole).insert({ role: ProjectMembershipRole.Admin, - projectMembershipId: projectMembershipV3[0].id + membershipId: projectMembershipV3[0].id }); // create default environments and default folders diff --git a/backend/src/db/seeds/5-machine-identity.ts b/backend/src/db/seeds/5-machine-identity.ts index ae3d04514..4e4e3eb7f 100644 --- a/backend/src/db/seeds/5-machine-identity.ts +++ b/backend/src/db/seeds/5-machine-identity.ts @@ -1,22 +1,37 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { initLogger, logger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; -import { IdentityAuthMethod, OrgMembershipRole, ProjectMembershipRole, TableName } from "../schemas"; +import { AccessScope, 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(); initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); // Inserts seed entries await knex(TableName.Identity).insert([ @@ -25,7 +40,8 @@ export async function seed(knex: Knex): Promise { // @ts-ignore id: seedData1.machineIdentity.id, name: seedData1.machineIdentity.name, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + orgId: seedData1.organization.id } ]); const identityUa = await knex(TableName.IdentityUniversalAuth) @@ -78,34 +94,47 @@ export async function seed(knex: Knex): Promise { isClientSecretRevoked: false } ]); - await knex(TableName.IdentityOrgMembership).insert([ + const [orgMembership] = await knex(TableName.Membership) + .insert([ + { + actorIdentityId: seedData1.machineIdentity.id, + scopeOrgId: seedData1.organization.id, + scope: AccessScope.Organization + } + ]) + .returning("*"); + await knex(TableName.MembershipRole).insert([ { - identityId: seedData1.machineIdentity.id, - orgId: seedData1.organization.id, + membershipId: orgMembership.id, role: OrgMembershipRole.Admin } ]); - const identityProjectMembership = await knex(TableName.IdentityProjectMembership) + const identityProjectMembership = await knex(TableName.Membership) .insert({ - identityId: seedData1.machineIdentity.id, - projectId: seedData1.project.id + actorIdentityId: seedData1.machineIdentity.id, + scopeOrgId: seedData1.organization.id, + scope: AccessScope.Project, + scopeProjectId: seedData1.project.id }) .returning("*"); - await knex(TableName.IdentityProjectMembershipRole).insert({ + await knex(TableName.MembershipRole).insert({ role: ProjectMembershipRole.Admin, - projectMembershipId: identityProjectMembership[0].id + membershipId: identityProjectMembership[0].id }); - const identityProjectMembershipV3 = await knex(TableName.IdentityProjectMembership) + + const identityProjectMembershipV3 = await knex(TableName.Membership) .insert({ - identityId: seedData1.machineIdentity.id, - projectId: seedData1.projectV3.id + actorIdentityId: seedData1.machineIdentity.id, + scopeOrgId: seedData1.organization.id, + scope: AccessScope.Project, + scopeProjectId: seedData1.projectV3.id }) .returning("*"); - await knex(TableName.IdentityProjectMembershipRole).insert({ + await knex(TableName.MembershipRole).insert({ role: ProjectMembershipRole.Admin, - projectMembershipId: identityProjectMembershipV3[0].id + membershipId: identityProjectMembershipV3[0].id }); } diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 33ebe910d..d5876782d 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -8,6 +8,35 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => { + try { + // Try to find as profile first using internal access + await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + return "profile"; + } catch (profileError) { + try { + await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + return "template"; + } catch (templateError) { + server.log.debug( + { + identifier, + profileError: profileError instanceof Error ? profileError.message : "Unknown error", + templateError: templateError instanceof Error ? templateError.message : "Unknown error" + }, + "EST identifier not found as profile or template" + ); + return null; + } + } + }; + // add support for CSR bodies server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => { try { @@ -59,11 +88,29 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = return; } - const certificateTemplateId = urlFragments.slice(-2)[0]; - const estConfig = await server.services.certificateTemplate.getEstConfiguration({ - isInternal: true, - certificateTemplateId - }); + const identifier = urlFragments.slice(-2)[0]; + + const identifierType = await getIdentifierType(identifier); + if (!identifierType) { + res.raw.statusCode = 404; + res.raw.setHeader("Content-Type", "text/plain"); + res.raw.write("Certificate template or profile not found"); + res.raw.flushHeaders(); + return; + } + + let estConfig; + if (identifierType === "profile") { + estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + } else { + estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + } if (!estConfig.isEnabled) { throw new BadRequestError({ @@ -95,14 +142,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simpleenroll", + url: "/:identifier/simpleenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -112,9 +159,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleEnrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleEnroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -122,14 +183,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simplereenroll", + url: "/:identifier/simplereenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -139,9 +200,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleReenrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleReenroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -149,13 +224,13 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "GET", - url: "/:certificateTemplateId/cacerts", + url: "/:identifier/cacerts", config: { rateLimit: readLimit }, schema: { params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -165,8 +240,20 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.getCaCertsByProfile({ + profileId: identifier + }); + } return server.services.certificateEst.getCaCerts({ - certificateTemplateId: req.params.certificateTemplateId + certificateTemplateId: identifier }); } }); diff --git a/backend/src/ee/routes/v1/deprecated-project-role-router.ts b/backend/src/ee/routes/v1/deprecated-project-role-router.ts index dc361626d..f8f73579e 100644 --- a/backend/src/ee/routes/v1/deprecated-project-role-router.ts +++ b/backend/src/ee/routes/v1/deprecated-project-role-router.ts @@ -1,7 +1,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; -import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; +import { AccessScope, ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { backfillPermissionV1SchemaToV2Schema, @@ -13,7 +13,6 @@ import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types"; export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,14 +54,16 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true)) ); - const role = await server.services.projectRole.createRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.SLUG, - projectSlug: req.params.projectSlug + const { id: projectId } = await server.services.convertor.projectSlugToId({ + slug: req.params.projectSlug, + orgId: req.permission.orgId + }); + const role = await server.services.role.createRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId }, data: { ...req.body, @@ -73,7 +74,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId, event: { type: EventType.CREATE_PROJECT_ROLE, metadata: { @@ -86,7 +87,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -131,12 +132,21 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv ? JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true))) : undefined; - const role = await server.services.projectRole.updateRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - roleId: req.params.roleId, + const { id: projectId } = await server.services.convertor.projectSlugToId({ + slug: req.params.projectSlug, + orgId: req.permission.orgId + }); + + const role = await server.services.role.updateRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId + }, + selector: { + id: req.params.roleId + }, data: { ...req.body, permissions: stringifiedPermissions @@ -146,7 +156,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.UPDATE_PROJECT_ROLE, metadata: { @@ -159,7 +169,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -188,18 +198,27 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - roleId: req.params.roleId + const { id: projectId } = await server.services.convertor.projectSlugToId({ + slug: req.params.projectSlug, + orgId: req.permission.orgId + }); + + const role = await server.services.role.deleteRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId + }, + selector: { + id: req.params.roleId + } }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.DELETE_PROJECT_ROLE, metadata: { @@ -210,7 +229,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -238,17 +257,21 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.SLUG, - projectSlug: req.params.projectSlug - } + const { id: projectId } = await server.services.convertor.projectSlugToId({ + slug: req.params.projectSlug, + orgId: req.permission.orgId }); - return { roles }; + + const { roles } = await server.services.role.listRoles({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId + }, + data: {} + }); + return { roles: roles.map((el) => ({ ...el, projectId: el.projectId as string })) }; } }); @@ -265,78 +288,30 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv }), response: { 200: z.object({ - role: SanitizedRoleSchemaV1.omit({ version: true }) + role: SanitizedRoleSchemaV1 }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.getRoleBySlug({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.SLUG, - projectSlug: req.params.projectSlug - }, - roleSlug: req.params.slug + const { id: projectId } = await server.services.convertor.projectSlugToId({ + slug: req.params.projectSlug, + orgId: req.permission.orgId }); - return { role }; - } - }); - - server.route({ - method: "GET", - url: "/:projectId/permissions", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - projectId: z.string().trim() - }), - response: { - 200: z.object({ - data: z.object({ - membership: z.object({ - id: z.string(), - roles: z - .object({ - role: z.string() - }) - .array() - }), - assumedPrivilegeDetails: z - .object({ - actorId: z.string(), - actorType: z.string(), - actorName: z.string(), - actorEmail: z.string().optional() - }) - .optional(), - permissions: z.any().array() - }) - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const { permissions, membership, assumedPrivilegeDetails } = await server.services.projectRole.getUserPermission( - req.permission.id, - req.params.projectId, - req.permission.authMethod, - req.permission.orgId - ); - - return { - data: { - permissions, - membership, - assumedPrivilegeDetails + const role = await server.services.role.getRoleBySlug({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId + }, + selector: { + slug: req.params.slug } - }; + }); + + return { role: { ...role, projectId: role.projectId as string } }; } }); }; diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index f64d3c979..5a6cea20f 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -1,7 +1,7 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; import { backfillPermissionV1SchemaToV2Schema } from "@app/ee/services/permission/project-permission"; import { ApiDocsTags, IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { UnauthorizedError } from "@app/lib/errors"; @@ -15,7 +15,7 @@ import { ProjectSpecificPrivilegePermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { server.route({ @@ -56,6 +56,10 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F if (!permissions && !privilegePermission) { throw new UnauthorizedError({ message: "Permission or privilegePermission must be provided" }); } + const { id: projectId } = await server.services.convertor.projectSlugToId({ + orgId: req.permission.orgId, + slug: req.body.projectSlug + }); const permission = privilegePermission ? privilegePermission.actions.map((action) => ({ @@ -64,19 +68,35 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F conditions: privilegePermission.conditions })) : permissions!; - const privilege = await server.services.identityProjectAdditionalPrivilege.create({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - ...req.body, - slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), - isTemporary: false, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-error this is valid ts - permissions: backfillPermissionV1SchemaToV2Schema(permission) + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.createAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + data: { + actorId: req.body.identityId, + actorType: ActorType.IDENTITY, + ...req.body, + isTemporary: false, + name: req.body.slug || slugify(alphaNumericNanoId(8)), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error this is valid ts + permissions: backfillPermissionV1SchemaToV2Schema(permission) + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + identityId: req.body.identityId, + projectMembershipId: projectId, + projectId, + slug: privilege.name + } + }; } }); @@ -106,7 +126,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission ).optional(), temporaryMode: z - .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), temporaryRange: z .string() @@ -138,19 +158,39 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F })) : permissions!; - const privilege = await server.services.identityProjectAdditionalPrivilege.create({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - ...req.body, - slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), - isTemporary: true, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-error this is valid ts - permissions: backfillPermissionV1SchemaToV2Schema(permission) + const { id: projectId } = await server.services.convertor.projectSlugToId({ + orgId: req.permission.orgId, + slug: req.body.projectSlug }); - return { privilege }; + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.createAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + data: { + actorId: req.body.identityId, + actorType: ActorType.IDENTITY, + ...req.body, + isTemporary: true, + name: req.body.slug || slugify(alphaNumericNanoId(8)), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error this is valid ts + permissions: backfillPermissionV1SchemaToV2Schema(permission) + } + }); + + return { + privilege: { + ...privilege, + identityId: req.body.identityId, + projectMembershipId: projectId, + projectId, + slug: privilege.name + } + }; } }); @@ -183,7 +223,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ).optional(), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z - .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), temporaryRange: z .string() @@ -216,18 +256,36 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F conditions: privilegePermission.conditions })) : permissions!; - const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - slug: req.body.privilegeSlug, - identityId: req.body.identityId, - projectSlug: req.body.projectSlug, + + const { id: projectId } = await server.services.convertor.projectSlugToId({ + orgId: req.permission.orgId, + slug: req.body.projectSlug + }); + + const { privilege: privilegeDoc } = await server.services.convertor.additionalPrivilegeNameToDoc( + req.body.privilegeSlug, + projectId + ); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.updateAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + selector: { + actorId: req.body.identityId, + actorType: ActorType.IDENTITY, + id: privilegeDoc.id + }, data: { + ...req.body, ...updatedInfo, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error this is valid ts permissions: permission ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -235,7 +293,16 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F : undefined } }); - return { privilege }; + + return { + privilege: { + ...privilege, + identityId: req.body.identityId, + projectMembershipId: projectId, + projectId, + slug: privilege.name + } + }; } }); @@ -267,16 +334,39 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilege.deleteBySlug({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - slug: req.body.privilegeSlug, - identityId: req.body.identityId, - projectSlug: req.body.projectSlug + const { id: projectId } = await server.services.convertor.projectSlugToId({ + orgId: req.permission.orgId, + slug: req.body.projectSlug }); - return { privilege }; + + const { privilegeId } = await server.services.convertor.additionalPrivilegeNameToDoc( + req.body.privilegeSlug, + projectId + ); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.deleteAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + selector: { + actorId: req.body.identityId, + actorType: ActorType.IDENTITY, + id: privilegeId + } + }); + + return { + privilege: { + ...privilege, + identityId: req.body.identityId, + projectMembershipId: projectId, + projectId, + slug: privilege.name + } + }; } }); @@ -310,15 +400,39 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsBySlug({ - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - slug: req.params.privilegeSlug, - ...req.query + const { id: projectId } = await server.services.convertor.projectSlugToId({ + orgId: req.permission.orgId, + slug: req.query.projectSlug }); - return { privilege }; + + const { privilegeId } = await server.services.convertor.additionalPrivilegeNameToDoc( + req.params.privilegeSlug, + projectId + ); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.getAdditionalPrivilegeById({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + selector: { + actorId: req.query.identityId, + actorType: ActorType.IDENTITY, + id: privilegeId + } + }); + + return { + privilege: { + ...privilege, + identityId: req.query.identityId, + projectMembershipId: projectId, + projectId, + slug: privilege.name + } + }; } }); @@ -349,15 +463,32 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privileges = await server.services.identityProjectAdditionalPrivilege.listIdentityProjectPrivileges({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.query + const { id: projectId } = await server.services.convertor.projectSlugToId({ + orgId: req.permission.orgId, + slug: req.query.projectSlug }); + + const { additionalPrivileges: privileges } = await server.services.additionalPrivilege.listAdditionalPrivileges({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + selector: { + actorId: req.query.identityId, + actorType: ActorType.IDENTITY + } + }); + return { - privileges + privileges: privileges.map((privilege) => ({ + ...privilege, + identityId: req.query.identityId, + projectMembershipId: projectId, + projectId, + slug: privilege.name + })) }; } }); diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 56d450df3..31847b503 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -23,6 +23,12 @@ import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; +import { PAM_ACCOUNT_REGISTER_ROUTER_MAP } from "./pam-account-routers"; +import { registerPamAccountRouter } from "./pam-account-routers/pam-account-router"; +import { registerPamFolderRouter } from "./pam-folder-router"; +import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers"; +import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router"; +import { registerPamSessionRouter } from "./pam-session-router"; import { registerPITRouter } from "./pit-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; @@ -42,12 +48,14 @@ import { registerSshCertRouter } from "./ssh-certificate-router"; import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; import { registerSshHostGroupRouter } from "./ssh-host-group-router"; import { registerSshHostRouter } from "./ssh-host-router"; +import { registerSubOrgRouter } from "./sub-org-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization await server.register(registerOrgRoleRouter, { prefix: "/organization" }); + await server.register(registerSubOrgRouter, { prefix: "/sub-organizations" }); await server.register(registerLicenseRouter, { prefix: "/organizations" }); // depreciated in favour of infisical workspace @@ -166,4 +174,40 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/kmip" } ); + + await server.register( + async (pamRouter) => { + await pamRouter.register(registerPamFolderRouter, { prefix: "/folders" }); + await pamRouter.register(registerPamSessionRouter, { prefix: "/sessions" }); + + await pamRouter.register( + async (pamAccountRouter) => { + await pamAccountRouter.register(registerPamAccountRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(PAM_ACCOUNT_REGISTER_ROUTER_MAP).map(([provider, router]) => + pamAccountRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/accounts" } + ); + + await pamRouter.register( + async (pamResourceRouter) => { + await pamResourceRouter.register(registerPamResourceRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) => + pamResourceRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/resources" } + ); + }, + { prefix: "/pam" } + ); }; diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index 9a1f4902c..6fcf05d99 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -128,7 +128,8 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { 200: z.object({ id: z.string(), value: z.string(), - algorithm: z.string() + algorithm: z.string(), + kmipMetadata: z.record(z.any()).nullish() }) } }, @@ -433,7 +434,8 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { body: z.object({ key: z.string(), name: z.string(), - algorithm: z.nativeEnum(SymmetricKeyAlgorithm) + algorithm: z.nativeEnum(SymmetricKeyAlgorithm), + kmipMetadata: z.record(z.any()).nullish() }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index 17923975d..2ccdce93a 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -58,7 +58,7 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const plan = await server.services.license.getOrgPlan({ actorId: req.permission.id, actor: req.permission.type, - actorOrgId: req.permission.orgId, + actorOrgId: req.permission.rootOrgId, actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, refreshCache: req.query.refreshCache diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 070462d47..591458bb4 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,12 +1,37 @@ +import { packRules } from "@casl/ability/extra"; import { z } from "zod"; -import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { AccessScope, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { OrgPermissionSchema, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const INVALID_SUBORG_PERMISSIONS = [ + OrgPermissionSubjects.Sso, + OrgPermissionSubjects.Ldap, + OrgPermissionSubjects.Scim, + OrgPermissionSubjects.GithubOrgSync, + OrgPermissionSubjects.GithubOrgSyncManual, + OrgPermissionSubjects.Billing, + OrgPermissionSubjects.SubOrganization +]; + +const validateSubOrganizationSubjects = (permissions: unknown) => { + const invalidPermissionSubjects = (permissions as { subject: OrgPermissionSubjects }[]) + .filter((el) => INVALID_SUBORG_PERMISSIONS.includes(el.subject)) + .map((el) => el.subject); + if (invalidPermissionSubjects.length) { + const deduplication = Array.from(new Set(invalidPermissionSubjects)); + throw new BadRequestError({ + message: `Suborganization contains invalid permission subjects: ${deduplication.join(",")}` + }); + } +}; + export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -25,8 +50,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { ), name: z.string().trim(), description: z.string().trim().nullish(), - // TODO(scott): once UI refactored permissions: OrgPermissionSchema.array() - permissions: z.any().array() + permissions: OrgPermissionSchema.array() }), response: { 200: z.object({ @@ -36,13 +60,23 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const role = await server.services.orgRole.createRole( - req.permission.id, - req.params.organizationId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const isSubOrganization = req.permission.rootOrgId !== req.permission.orgId; + if (isSubOrganization) { + validateSubOrganizationSubjects(req.body.permissions); + } + + const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions)); + const role = await server.services.role.createRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.params.organizationId + }, + data: { + ...req.body, + permissions: stringifiedPermissions + } + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -59,7 +93,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { } }); - return { role }; + return { role: { ...role, orgId: role.orgId as string } }; } }); @@ -82,14 +116,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const role = await server.services.orgRole.getRole( - req.permission.id, - req.params.organizationId, - req.params.roleId, - req.permission.authMethod, - req.permission.orgId - ); - return { role }; + const role = await server.services.role.getRoleById({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.params.organizationId + }, + selector: { + id: req.params.roleId + } + }); + return { role: { ...role, orgId: role.orgId as string } }; } }); @@ -114,8 +151,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .optional(), name: z.string().trim().optional(), description: z.string().trim().nullish(), - // TODO(scott): once UI refactored permissions: OrgPermissionSchema.array().optional() - permissions: z.any().array().optional() + permissions: OrgPermissionSchema.array().optional() }), response: { 200: z.object({ @@ -125,14 +161,26 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const role = await server.services.orgRole.updateRole( - req.permission.id, - req.params.organizationId, - req.params.roleId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const isSubOrganization = req.permission.rootOrgId !== req.permission.orgId; + if (isSubOrganization && req.body.permissions) { + validateSubOrganizationSubjects(req.body.permissions); + } + + const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined; + const role = await server.services.role.updateRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.params.organizationId + }, + selector: { + id: req.params.roleId + }, + data: { + ...req.body, + permissions: stringifiedPermissions + } + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -149,7 +197,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { } }); - return { role }; + return { role: { ...role, orgId: role.orgId as string } }; } }); @@ -172,13 +220,16 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const role = await server.services.orgRole.deleteRole( - req.permission.id, - req.params.organizationId, - req.params.roleId, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.role.deleteRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.params.organizationId + }, + selector: { + id: req.params.roleId + } + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -189,7 +240,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { } }); - return { role }; + return { role: { ...role, orgId: role.orgId as string } }; } }); @@ -206,22 +257,26 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ data: z.object({ - roles: OrgRolesSchema.omit({ permissions: true }) - .merge(z.object({ permissions: z.unknown() })) - .array() + roles: OrgRolesSchema.omit({ permissions: true }).array() }) }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const roles = await server.services.orgRole.listRoles( - req.permission.id, - req.params.organizationId, - req.permission.authMethod, - req.permission.orgId - ); - return { data: { roles } }; + const { roles } = await server.services.role.listRoles({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: {} + }); + return { + data: { + roles: roles.map((el) => ({ ...el, orgId: el.orgId as string })) + } + }; } }); @@ -237,20 +292,33 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - membership: OrgMembershipsSchema, + memberships: z + .object({ + id: z.string(), + roles: z + .object({ + role: z.string() + }) + .array() + }) + .array(), permissions: z.any().array() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { permissions, membership } = await server.services.orgRole.getUserPermission( - req.permission.id, - req.params.organizationId, - req.permission.authMethod, - req.permission.orgId - ); - return { permissions, membership }; + const { permissions, memberships } = await server.services.role.getUserPermission({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + } + }); + return { + permissions, + memberships + }; } }); }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts new file mode 100644 index 000000000..60d621467 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -0,0 +1,34 @@ +import { + CreateMySQLAccountSchema, + SanitizedMySQLAccountWithResourceSchema, + UpdateMySQLAccountSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { + CreatePostgresAccountSchema, + SanitizedPostgresAccountWithResourceSchema, + UpdatePostgresAccountSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; + +import { registerPamResourceEndpoints } from "./pam-account-endpoints"; + +export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record Promise> = { + [PamResource.Postgres]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Postgres, + accountResponseSchema: SanitizedPostgresAccountWithResourceSchema, + createAccountSchema: CreatePostgresAccountSchema, + updateAccountSchema: UpdatePostgresAccountSchema + }); + }, + [PamResource.MySQL]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.MySQL, + accountResponseSchema: SanitizedMySQLAccountWithResourceSchema, + createAccountSchema: CreateMySQLAccountSchema, + updateAccountSchema: UpdateMySQLAccountSchema + }); + } +}; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts new file mode 100644 index 000000000..44e2a5ea1 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { TPamAccount } from "@app/ee/services/pam-resource/pam-resource-types"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamResourceEndpoints = ({ + server, + resourceType, + createAccountSchema, + updateAccountSchema, + accountResponseSchema +}: { + server: FastifyZodProvider; + resourceType: PamResource; + createAccountSchema: z.ZodType<{ + credentials: C["credentials"]; + resourceId: C["resourceId"]; + folderId?: C["folderId"]; + name: C["name"]; + description?: C["description"]; + rotationEnabled: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; + }>; + updateAccountSchema: z.ZodType<{ + credentials?: C["credentials"]; + name?: C["name"]; + description?: C["description"]; + rotationEnabled?: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; + }>; + accountResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM account", + body: createAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.create(req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_CREATE, + metadata: { + resourceId: req.body.resourceId, + resourceType, + folderId: req.body.folderId, + name: req.body.name, + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "PATCH", + url: "/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + body: updateAccountSchema, + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.updateById( + { + ...req.body, + accountId: req.params.accountId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_UPDATE, + metadata: { + accountId: req.params.accountId, + resourceId: account.resourceId, + resourceType, + name: req.body.name, + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds + } + } + }); + + return { account }; + } + }); + + server.route({ + method: "DELETE", + url: "/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM account", + params: z.object({ + accountId: z.string().uuid() + }), + response: { + 200: z.object({ + account: accountResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const account = await server.services.pamAccount.deleteById(req.params.accountId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: account.projectId, + event: { + type: EventType.PAM_ACCOUNT_DELETE, + metadata: { + accountId: req.params.accountId, + accountName: account.name, + resourceId: account.resourceId, + resourceType + } + } + }); + + return { account }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts new file mode 100644 index 000000000..d2e0183ff --- /dev/null +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; + +import { PamFoldersSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SanitizedAccountSchema = z.union([ + SanitizedPostgresAccountWithResourceSchema, + SanitizedMySQLAccountWithResourceSchema +]); + +export const registerPamAccountRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM accounts", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + accounts: SanitizedAccountSchema.array(), + folders: PamFoldersSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamAccount.list(req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.PAM_ACCOUNT_LIST, + metadata: { + accountCount: response.accounts.length, + folderCount: response.folders.length + } + } + }); + + return response; + } + }); + + server.route({ + method: "POST", + url: "/access", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Access PAM account", + body: z.object({ + accountId: z.string().uuid(), + duration: z + .string() + .min(1) + .transform((val, ctx) => { + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid duration format. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return parsedMs; + }) + }), + response: { + 200: z.object({ + sessionId: z.string(), + resourceType: z.nativeEnum(PamResource), + relayClientCertificate: z.string(), + relayClientPrivateKey: z.string(), + relayServerCertificateChain: z.string(), + gatewayClientCertificate: z.string(), + gatewayClientPrivateKey: z.string(), + gatewayServerCertificateChain: z.string(), + relayHost: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + // To prevent type errors when accessing req.auth + if (req.auth.authMode !== AuthMode.JWT) { + throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." }); + } + + const response = await server.services.pamAccount.access( + { + actorEmail: req.auth.user.email ?? "", + actorIp: req.realIp, + actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(), + actorUserAgent: req.auditLogInfo.userAgent ?? "", + ...req.body + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: response.projectId, + event: { + type: EventType.PAM_ACCOUNT_ACCESS, + metadata: { + accountId: req.body.accountId, + accountName: response.account.name, + duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined + } + } + }); + + return response; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-folder-router.ts b/backend/src/ee/routes/v1/pam-folder-router.ts new file mode 100644 index 000000000..cd2506aba --- /dev/null +++ b/backend/src/ee/routes/v1/pam-folder-router.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +import { PamFoldersSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { isValidFolderName } from "@app/lib/validator"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamFolderRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM folder", + body: z.object({ + projectId: z.string().uuid(), + parentId: z.string().uuid().nullable().optional(), + name: z + .string() + .trim() + .refine((name) => isValidFolderName(name), { + message: "Folder name can only contain alphanumeric characters, dashes, and underscores." + }), + description: z.string().trim().max(512).nullable().optional() + }), + response: { + 200: z.object({ + folder: PamFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const folder = await server.services.pamFolder.createFolder(req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.body.projectId, + event: { + type: EventType.PAM_FOLDER_CREATE, + metadata: { + name: req.body.name, + description: req.body.description, + parentId: req.body.parentId + } + } + }); + + return { folder }; + } + }); + + server.route({ + method: "PATCH", + url: "/:folderId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM folder", + params: z.object({ + folderId: z.string().uuid() + }), + body: z.object({ + name: z + .string() + .trim() + .optional() + .refine((name) => (name ? isValidFolderName(name) : true), { + message: "Folder name can only contain alphanumeric characters, dashes, and underscores." + }), + description: z.string().trim().max(512).nullable().optional() + }), + response: { + 200: z.object({ + folder: PamFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const folder = await server.services.pamFolder.updateFolder( + { + ...req.body, + id: req.params.folderId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: folder.projectId, + event: { + type: EventType.PAM_FOLDER_UPDATE, + metadata: { + folderId: req.params.folderId, + name: req.body.name, + description: req.body.description + } + } + }); + + return { folder }; + } + }); + + server.route({ + method: "DELETE", + url: "/:folderId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM folder", + params: z.object({ + folderId: z.string().uuid() + }), + response: { + 200: z.object({ + folder: PamFoldersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const folder = await server.services.pamFolder.deleteFolder(req.params.folderId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: folder.projectId, + event: { + type: EventType.PAM_FOLDER_DELETE, + metadata: { + folderName: folder.name, + folderId: req.params.folderId + } + } + }); + + return { folder }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts new file mode 100644 index 000000000..c6c0afcca --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -0,0 +1,34 @@ +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { + CreatePostgresResourceSchema, + SanitizedPostgresResourceSchema, + UpdatePostgresResourceSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + CreateMySQLResourceSchema, + MySQLResourceSchema, + UpdateMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; + +import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; + +export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record Promise> = { + [PamResource.Postgres]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Postgres, + resourceResponseSchema: SanitizedPostgresResourceSchema, + createResourceSchema: CreatePostgresResourceSchema, + updateResourceSchema: UpdatePostgresResourceSchema + }); + }, + [PamResource.MySQL]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.MySQL, + resourceResponseSchema: MySQLResourceSchema, + createResourceSchema: CreateMySQLResourceSchema, + updateResourceSchema: UpdateMySQLResourceSchema + }); + } +}; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts new file mode 100644 index 000000000..ffbeae5c0 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -0,0 +1,200 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { TPamResource } from "@app/ee/services/pam-resource/pam-resource-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerPamResourceEndpoints = ({ + server, + resourceType, + createResourceSchema, + updateResourceSchema, + resourceResponseSchema +}: { + server: FastifyZodProvider; + resourceType: PamResource; + createResourceSchema: z.ZodType<{ + projectId: T["projectId"]; + connectionDetails: T["connectionDetails"]; + gatewayId: T["gatewayId"]; + name: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; + }>; + updateResourceSchema: z.ZodType<{ + connectionDetails?: T["connectionDetails"]; + gatewayId?: T["gatewayId"]; + name?: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; + }>; + resourceResponseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: "/:resourceId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PAM resource", + params: z.object({ + resourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.getById(req.params.resourceId, resourceType, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: resource.projectId, + event: { + type: EventType.PAM_RESOURCE_GET, + metadata: { + resourceId: resource.id, + resourceType: resource.resourceType, + name: resource.name + } + } + }); + + return { resource }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create PAM resource", + body: createResourceSchema, + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.create( + { + ...req.body, + resourceType + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.body.projectId, + event: { + type: EventType.PAM_RESOURCE_CREATE, + metadata: { + resourceType, + gatewayId: req.body.gatewayId, + name: req.body.name + } + } + }); + + return { resource }; + } + }); + + server.route({ + method: "PATCH", + url: "/:resourceId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM resource", + params: z.object({ + resourceId: z.string().uuid() + }), + body: updateResourceSchema, + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.updateById( + { + ...req.body, + resourceId: req.params.resourceId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: resource.projectId, + event: { + type: EventType.PAM_RESOURCE_UPDATE, + metadata: { + resourceId: req.params.resourceId, + resourceType, + gatewayId: req.body.gatewayId, + name: req.body.name + } + } + }); + + return { resource }; + } + }); + + server.route({ + method: "DELETE", + url: "/:resourceId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete PAM resource", + params: z.object({ + resourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resource: resourceResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const resource = await server.services.pamResource.deleteById(req.params.resourceId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: resource.projectId, + event: { + type: EventType.PAM_RESOURCE_DELETE, + metadata: { + resourceId: req.params.resourceId, + resourceType + } + } + }); + + return { resource }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts new file mode 100644 index 000000000..6563c86c7 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + MySQLResourceListItemSchema, + SanitizedMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { + PostgresResourceListItemSchema, + SanitizedPostgresResourceSchema +} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]); + +const ResourceOptionsSchema = z.discriminatedUnion("resource", [ + PostgresResourceListItemSchema, + MySQLResourceListItemSchema +]); + +export const registerPamResourceRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM resource types", + response: { + 200: z.object({ + resourceOptions: ResourceOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: () => { + const resourceOptions = server.services.pamResource.listResourceOptions(); + + return { resourceOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM resources", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + resources: SanitizedResourceSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamResource.list(req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.PAM_RESOURCE_LIST, + metadata: { + count: response.resources.length + } + } + }); + + return response; + } + }); +}; diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts new file mode 100644 index 000000000..5fe10e434 --- /dev/null +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -0,0 +1,224 @@ +import { z } from "zod"; + +import { PamSessionsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]); + +export const registerPamSessionRouter = async (server: FastifyZodProvider) => { + // Meant to be hit solely by gateway identities + server.route({ + method: "GET", + url: "/:sessionId/credentials", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PAM session credentials and start session", + params: z.object({ + sessionId: z.string().uuid() + }), + response: { + 200: z.object({ + credentials: SessionCredentialsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials( + req.params.sessionId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_START, + metadata: { + sessionId: req.params.sessionId, + accountName: account.name + } + } + }); + + return { credentials }; + } + }); + + // Meant to be hit solely by gateway identities + server.route({ + method: "POST", + url: "/:sessionId/logs", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update PAM session logs", + params: z.object({ + sessionId: z.string().uuid() + }), + body: z.object({ + logs: PamSessionCommandLogSchema.array() + }), + response: { + 200: z.object({ + session: PamSessionsSchema.omit({ + encryptedLogsBlob: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { session, projectId } = await server.services.pamSession.updateLogsById( + { + sessionId: req.params.sessionId, + logs: req.body.logs + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_LOGS_UPDATE, + metadata: { + sessionId: req.params.sessionId, + accountName: session.accountName + } + } + }); + + return { session }; + } + }); + + // Meant to be hit solely by gateway identities + server.route({ + method: "POST", + url: "/:sessionId/end", + config: { + rateLimit: writeLimit + }, + schema: { + description: "End PAM session", + params: z.object({ + sessionId: z.string().uuid() + }), + response: { + 200: z.object({ + session: PamSessionsSchema.omit({ + encryptedLogsBlob: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { session, projectId } = await server.services.pamSession.endSessionById( + req.params.sessionId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_END, + metadata: { + sessionId: req.params.sessionId, + accountName: session.accountName + } + } + }); + + return { session }; + } + }); + + server.route({ + method: "GET", + url: "/:sessionId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PAM session", + params: z.object({ + sessionId: z.string().uuid() + }), + response: { + 200: z.object({ + session: SanitizedSessionSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamSession.getById(req.params.sessionId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: response.session.projectId, + event: { + type: EventType.PAM_SESSION_GET, + metadata: { + sessionId: req.params.sessionId + } + } + }); + + return response; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PAM sessions", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + sessions: SanitizedSessionSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const response = await server.services.pamSession.list(req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.PAM_SESSION_LIST, + metadata: { + count: response.sessions.length + } + } + }); + + return response; + } + }); +}; diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 5a20ad893..acf34cb3b 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,7 +1,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; -import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; +import { AccessScope, ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; @@ -11,7 +11,6 @@ import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,13 +54,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions)); - const role = await server.services.projectRole.createRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.ID, + const role = await server.services.role.createRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, projectId: req.params.projectId }, data: { @@ -73,7 +70,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.CREATE_PROJECT_ROLE, metadata: { @@ -86,7 +83,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -133,12 +130,16 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined; - const role = await server.services.projectRole.updateRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - roleId: req.params.roleId, + const role = await server.services.role.updateRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + id: req.params.roleId + }, data: { ...req.body, permissions: stringifiedPermissions @@ -148,7 +149,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.UPDATE_PROJECT_ROLE, metadata: { @@ -161,7 +162,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -192,18 +193,22 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - roleId: req.params.roleId + const role = await server.services.role.deleteRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + id: req.params.roleId + } }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.DELETE_PROJECT_ROLE, metadata: { @@ -214,7 +219,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -244,17 +249,16 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.ID, + const { roles } = await server.services.role.listRoles({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, projectId: req.params.projectId - } + }, + data: {} }); - return { roles }; + return { roles: roles.map((el) => ({ ...el, projectId: el.projectId as string })) }; } }); @@ -273,24 +277,25 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - role: SanitizedRoleSchema.omit({ version: true }) + role: SanitizedRoleSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.getRoleBySlug({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.ID, + const role = await server.services.role.getRoleBySlug({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, projectId: req.params.projectId }, - roleSlug: req.params.roleSlug + selector: { + slug: req.params.roleSlug + } }); - return { role }; + + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -307,14 +312,16 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ data: z.object({ - membership: z.object({ - id: z.string(), - roles: z - .object({ - role: z.string() - }) - .array() - }), + memberships: z + .object({ + id: z.string(), + roles: z + .object({ + role: z.string() + }) + .array() + }) + .array(), assumedPrivilegeDetails: z .object({ actorId: z.string(), @@ -330,17 +337,19 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { permissions, membership, assumedPrivilegeDetails } = await server.services.projectRole.getUserPermission( - req.permission.id, - req.params.projectId, - req.permission.authMethod, - req.permission.orgId - ); + const { permissions, memberships, assumedPrivilegeDetails } = await server.services.role.getUserPermission({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: req.params.projectId, + orgId: req.permission.orgId + } + }); return { data: { permissions, - membership, + memberships, assumedPrivilegeDetails } }; diff --git a/backend/src/ee/routes/v1/relay-router.ts b/backend/src/ee/routes/v1/relay-router.ts index f3d006b10..766025c21 100644 --- a/backend/src/ee/routes/v1/relay-router.ts +++ b/backend/src/ee/routes/v1/relay-router.ts @@ -146,4 +146,85 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => { }); } }); + + server.route({ + method: "POST", + url: "/heartbeat-instance-relay", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: (req, _, next) => { + const authHeader = req.headers.authorization; + + if (!appCfg.RELAY_AUTH_SECRET) { + throw new UnauthorizedError({ + message: "Relay authentication not configured" + }); + } + + if (!authHeader) { + throw new UnauthorizedError({ + message: "Missing authorization header" + }); + } + + const expectedHeader = `Bearer ${appCfg.RELAY_AUTH_SECRET}`; + if ( + authHeader.length === expectedHeader.length && + crypto.nativeCrypto.timingSafeEqual(Buffer.from(authHeader), Buffer.from(expectedHeader)) + ) { + return next(); + } + + throw new UnauthorizedError({ + message: "Invalid relay auth secret" + }); + }, + handler: async (req) => { + await server.services.relay.heartbeat({ + name: req.body.name + }); + + return { message: "Successfully triggered heartbeat" }; + } + }); + + server.route({ + method: "POST", + url: "/heartbeat-org-relay", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: slugSchema({ min: 1, max: 32, field: "name" }) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + await server.services.relay.heartbeat({ + name: req.body.name, + identityId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + return { message: "Successfully triggered heartbeat" }; + } + }); }; diff --git a/backend/src/ee/routes/v1/secret-router.ts b/backend/src/ee/routes/v1/secret-router.ts index b9eeb7729..9b7f9f35f 100644 --- a/backend/src/ee/routes/v1/secret-router.ts +++ b/backend/src/ee/routes/v1/secret-router.ts @@ -11,7 +11,6 @@ const AccessListEntrySchema = z .object({ allowedActions: z.nativeEnum(ProjectPermissionSecretActions).array(), id: z.string(), - membershipId: z.string(), name: z.string() }) .array(); diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts new file mode 100644 index 000000000..200130488 --- /dev/null +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; + +import { OrganizationsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, SUB_ORGANIZATIONS } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedSubOrganizationSchema = OrganizationsSchema.pick({ + id: true, + name: true, + slug: true, + createdAt: true, + updatedAt: true, + parentOrgId: true +}); + +export const registerSubOrgRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "Create a sub organization", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: slugSchema().describe(SUB_ORGANIZATIONS.CREATE.name) + }), + response: { + 200: z.object({ + organization: sanitizedSubOrganizationSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { organization } = await server.services.subOrganization.createSubOrg({ + name: req.body.name, + permissionActor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_SUB_ORGANIZATION, + metadata: { + name: req.body.name, + organizationId: organization.id + } + } + }); + + return { organization }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "List of sub organizations", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + limit: z.coerce.number().min(1).max(1000).default(25).describe(SUB_ORGANIZATIONS.LIST.limit), + offset: z.coerce.number().min(0).default(0).describe(SUB_ORGANIZATIONS.LIST.offset), + isAccessible: z + .enum(["true", "false"]) + .optional() + .transform((value) => value === "true") + .describe(SUB_ORGANIZATIONS.LIST.isAccessible) + }), + response: { + 200: z.object({ + organizations: sanitizedSubOrganizationSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { organizations } = await server.services.subOrganization.listSubOrgs({ + permissionActor: req.permission, + data: { + limit: req.query.limit, + offset: req.query.offset, + isAccessible: req.query.isAccessible + } + }); + + return { organizations }; + } + }); + + server.route({ + method: "PATCH", + url: "/:subOrgId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SubOrganizations], + description: "Update a sub organization", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + subOrgId: z.string().trim().describe(SUB_ORGANIZATIONS.UPDATE.subOrgId) + }), + body: z.object({ + name: slugSchema().describe(SUB_ORGANIZATIONS.UPDATE.name) + }), + response: { + 200: z.object({ + organization: sanitizedSubOrganizationSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { organization } = await server.services.subOrganization.updateSubOrg({ + subOrgId: req.params.subOrgId, + name: req.body.name, + permissionActor: req.permission + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_SUB_ORGANIZATION, + metadata: { + name: req.body.name, + organizationId: organization.id + } + } + }); + + return { organization }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index df8512ada..926b22231 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -1,17 +1,18 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; -import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedUserProjectAdditionalPrivilegeSchema } from "@app/server/routes/sanitizedSchema/user-additional-privilege"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { server.route({ @@ -34,7 +35,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr z.object({ isTemporary: z.literal(true), temporaryMode: z - .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), temporaryRange: z .string() @@ -55,17 +56,31 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const privilege = await server.services.projectUserAdditionalPrivilege.create({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - projectMembershipId: req.body.projectMembershipId, - ...req.body.type, - slug: req.body.slug || slugify(alphaNumericNanoId(8)), - permissions: req.body.permissions + const { userId, membership } = await server.services.convertor.userMembershipIdToUserId( + req.body.projectMembershipId, + AccessScope.Project, + req.permission.orgId + ); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.createAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: membership.scopeProjectId as string, + orgId: req.permission.orgId + }, + data: { + actorId: userId, + actorType: ActorType.USER, + ...req.body.type, + name: req.body.slug || slugify(alphaNumericNanoId(8)), + permissions: req.body.permissions + } }); - return { privilege }; + + return { + privilege: { ...privilege, userId, projectId: membership.scopeProjectId as string, slug: privilege.name } + }; } }); @@ -91,7 +106,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr z.object({ isTemporary: z.literal(true).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z - .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), temporaryRange: z .string() @@ -113,21 +128,41 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const privilege = await server.services.projectUserAdditionalPrivilege.updateById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - ...req.body, - ...req.body.type, - permissions: req.body.permissions - ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore-error this is valid ts - req.body.permissions - : undefined, - privilegeId: req.params.privilegeId + const data = await server.services.convertor.additionalPrivilegeIdToDoc(req.params.privilegeId); + if (!data.privilege.actorUserId) + throw new NotFoundError({ message: `Privilege with id ${req.params.privilegeId} not found` }); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.updateAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: data.privilege.projectId as string, + orgId: req.permission.orgId + }, + data: { + ...req.body, + ...req.body.type, + permissions: req.body.permissions + ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-error this is valid ts + req.body.permissions + : undefined + }, + selector: { + id: req.params.privilegeId, + actorId: data.privilege.actorUserId, + actorType: ActorType.USER + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + userId: data.privilege.actorUserId, + projectId: data.privilege.projectId as string, + slug: privilege.name + } + }; } }); @@ -149,14 +184,32 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const privilege = await server.services.projectUserAdditionalPrivilege.deleteById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - privilegeId: req.params.privilegeId + const data = await server.services.convertor.additionalPrivilegeIdToDoc(req.params.privilegeId); + if (!data.privilege.actorUserId) + throw new NotFoundError({ message: `Privilege with id ${req.params.privilegeId} not found` }); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.deleteAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: data.privilege.projectId as string, + orgId: req.permission.orgId + }, + selector: { + id: req.params.privilegeId, + actorId: data.privilege.actorUserId, + actorType: ActorType.USER + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + userId: data.privilege.actorUserId, + projectId: data.privilege.projectId as string, + slug: privilege.name + } + }; } }); @@ -178,14 +231,33 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const privileges = await server.services.projectUserAdditionalPrivilege.listPrivileges({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - projectMembershipId: req.query.projectMembershipId + const { userId, membership } = await server.services.convertor.userMembershipIdToUserId( + req.query.projectMembershipId, + AccessScope.Project, + req.permission.orgId + ); + + const { additionalPrivileges: privileges } = await server.services.additionalPrivilege.listAdditionalPrivileges({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: membership.scopeProjectId as string, + orgId: req.permission.orgId + }, + selector: { + actorId: userId, + actorType: ActorType.USER + } }); - return { privileges }; + + return { + privileges: privileges.map((privilege) => ({ + ...privilege, + userId: membership.actorUserId as string, + projectId: membership.scopeProjectId as string, + slug: privilege.name + })) + }; } }); @@ -207,14 +279,32 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const privilege = await server.services.projectUserAdditionalPrivilege.getPrivilegeDetailsById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - privilegeId: req.params.privilegeId + const data = await server.services.convertor.additionalPrivilegeIdToDoc(req.params.privilegeId); + if (!data.privilege.actorUserId) + throw new NotFoundError({ message: `Privilege with id ${req.params.privilegeId} not found` }); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.getAdditionalPrivilegeById({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: data.privilege.projectId as string, + orgId: req.permission.orgId + }, + selector: { + id: req.params.privilegeId, + actorId: data.privilege.actorUserId, + actorType: ActorType.USER + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + userId: data.privilege.actorUserId, + projectId: data.privilege.projectId as string, + slug: privilege.name + } + }; } }); }; diff --git a/backend/src/ee/routes/v2/deprecated-project-role-router.ts b/backend/src/ee/routes/v2/deprecated-project-role-router.ts index 326bda06a..0a44d4d21 100644 --- a/backend/src/ee/routes/v2/deprecated-project-role-router.ts +++ b/backend/src/ee/routes/v2/deprecated-project-role-router.ts @@ -1,7 +1,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; -import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; +import { AccessScope, ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; @@ -11,7 +11,6 @@ import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types"; export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,13 +54,11 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv handler: async (req) => { const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions)); - const role = await server.services.projectRole.createRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.ID, + const role = await server.services.role.createRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, projectId: req.params.projectId }, data: { @@ -73,7 +70,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: req.params.projectId, event: { type: EventType.CREATE_PROJECT_ROLE, metadata: { @@ -86,7 +83,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -133,12 +130,16 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined; - const role = await server.services.projectRole.updateRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - roleId: req.params.roleId, + const role = await server.services.role.updateRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + id: req.params.roleId + }, data: { ...req.body, permissions: stringifiedPermissions @@ -148,7 +149,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.UPDATE_PROJECT_ROLE, metadata: { @@ -161,7 +162,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -192,18 +193,22 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - roleId: req.params.roleId + const role = await server.services.role.deleteRole({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + id: req.params.roleId + } }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: role.projectId, + projectId: role.projectId as string, event: { type: EventType.DELETE_PROJECT_ROLE, metadata: { @@ -214,7 +219,7 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv } }); - return { role }; + return { role: { ...role, projectId: role.projectId as string } }; } }); @@ -244,17 +249,16 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.ID, + const { roles } = await server.services.role.listRoles({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, projectId: req.params.projectId - } + }, + data: {} }); - return { roles }; + return { roles: roles.map((el) => ({ ...el, projectId: el.projectId as string })) }; } }); @@ -273,24 +277,25 @@ export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProv }), response: { 200: z.object({ - role: SanitizedRoleSchema.omit({ version: true }) + role: SanitizedRoleSchema }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.getRoleBySlug({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - filter: { - type: ProjectRoleServiceIdentifierType.ID, + const role = await server.services.role.getRoleBySlug({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, projectId: req.params.projectId }, - roleSlug: req.params.roleSlug + selector: { + slug: req.params.roleSlug + } }); - return { role }; + + return { role: { ...role, projectId: role.projectId as string } }; } }); }; diff --git a/backend/src/ee/routes/v2/gateway-router.ts b/backend/src/ee/routes/v2/gateway-router.ts index a7e656a64..56284729d 100644 --- a/backend/src/ee/routes/v2/gateway-router.ts +++ b/backend/src/ee/routes/v2/gateway-router.ts @@ -1,6 +1,7 @@ import z from "zod"; import { GatewaysV2Schema } from "@app/db/schemas"; +import { zodBuffer } from "@app/lib/zod"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -130,4 +131,25 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => { return gateway; } }); + + server.route({ + method: "GET", + url: "/pam-session-key", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: zodBuffer + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const pamSessionKey = await server.services.gatewayV2.getPamSessionKey({ + orgPermission: req.permission + }); + + return pamSessionKey; + } + }); }; diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index a6d4459e4..f8ac34b4b 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -1,7 +1,7 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-types"; +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { ApiDocsTags, IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs"; @@ -11,7 +11,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchema/identitiy-additional-privilege"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { server.route({ @@ -43,7 +43,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F z.object({ isTemporary: z.literal(true), temporaryMode: z - .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.temporaryMode), temporaryRange: z .string() @@ -64,18 +64,31 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilegeV2.create({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - projectId: req.body.projectId, - identityId: req.body.identityId, - ...req.body.type, - slug: req.body.slug || slugify(alphaNumericNanoId(8)), - permissions: req.body.permissions + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.createAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: req.body.projectId, + orgId: req.permission.orgId + }, + data: { + actorId: req.body.identityId, + actorType: ActorType.IDENTITY, + ...req.body.type, + name: req.body.slug || slugify(alphaNumericNanoId(8)), + permissions: req.body.permissions + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + identityId: req.body.identityId, + projectMembershipId: req.body.projectId, + projectId: req.body.projectId, + slug: privilege.name + } + }; } }); @@ -108,7 +121,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F z.object({ isTemporary: z.literal(true).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.isTemporary), temporaryMode: z - .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.temporaryMode), temporaryRange: z .string() @@ -129,19 +142,36 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilegeV2.updateById({ - actorId: req.permission.id, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - id: req.params.id, + const { privilege: privilegeDoc } = await server.services.convertor.additionalPrivilegeIdToDoc(req.params.id); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.updateAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: privilegeDoc.projectId as string, + orgId: req.permission.orgId + }, + selector: { + id: req.params.id, + actorId: privilegeDoc.actorIdentityId as string, + actorType: ActorType.IDENTITY + }, data: { ...req.body, ...req.body.type, permissions: req.body.permissions || undefined } }); - return { privilege }; + + return { + privilege: { + ...privilege, + identityId: privilegeDoc.actorIdentityId as string, + projectMembershipId: privilegeDoc.projectId as string, + projectId: privilegeDoc.projectId as string, + slug: privilege.name + } + }; } }); @@ -171,14 +201,31 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilegeV2.deleteById({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - id: req.params.id + const { privilege: privilegeDoc } = await server.services.convertor.additionalPrivilegeIdToDoc(req.params.id); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.deleteAdditionalPrivilege({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: privilegeDoc.projectId as string, + orgId: req.permission.orgId + }, + selector: { + id: req.params.id, + actorId: privilegeDoc.actorIdentityId as string, + actorType: ActorType.IDENTITY + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + identityId: privilegeDoc.actorIdentityId as string, + projectMembershipId: privilegeDoc.projectId as string, + projectId: privilegeDoc.projectId as string, + slug: privilege.name + } + }; } }); @@ -208,14 +255,31 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilegeV2.getPrivilegeDetailsById({ - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - id: req.params.id + const { privilege: privilegeDoc } = await server.services.convertor.additionalPrivilegeIdToDoc(req.params.id); + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.getAdditionalPrivilegeById({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: privilegeDoc.projectId as string, + orgId: req.permission.orgId + }, + selector: { + id: req.params.id, + actorId: privilegeDoc.actorIdentityId as string, + actorType: ActorType.IDENTITY + } }); - return { privilege }; + + return { + privilege: { + ...privilege, + identityId: privilegeDoc.actorIdentityId as string, + projectMembershipId: privilegeDoc.projectId as string, + projectId: privilegeDoc.projectId as string, + slug: privilege.name + } + }; } }); @@ -249,15 +313,36 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilegeV2.getPrivilegeDetailsBySlug({ - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - slug: req.params.privilegeSlug, - ...req.query + const { id: projectId } = await server.services.convertor.projectSlugToId({ + slug: req.query.projectSlug, + orgId: req.permission.orgId }); - return { privilege }; + + const { additionalPrivilege: privilege } = await server.services.additionalPrivilege.getAdditionalPrivilegeByName( + { + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId, + orgId: req.permission.orgId + }, + selector: { + name: req.params.privilegeSlug, + actorId: req.query.identityId, + actorType: ActorType.IDENTITY + } + } + ); + + return { + privilege: { + ...privilege, + identityId: req.query.identityId, + projectMembershipId: privilege.projectId as string, + projectId, + slug: privilege.name + } + }; } }); @@ -288,15 +373,27 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privileges = await server.services.identityProjectAdditionalPrivilegeV2.listIdentityProjectPrivileges({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.query + const { additionalPrivileges: privileges } = await server.services.additionalPrivilege.listAdditionalPrivileges({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: req.query.projectId, + orgId: req.permission.orgId + }, + selector: { + actorId: req.query.identityId, + actorType: ActorType.IDENTITY + } }); + return { - privileges + privileges: privileges.map((privilege) => ({ + ...privilege, + identityId: req.query.identityId, + projectMembershipId: privilege.projectId as string, + projectId: req.query.projectId, + slug: privilege.name + })) }; } }); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index ccd32b6fe..585c24e1c 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -3,8 +3,9 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; +import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,7 +15,6 @@ import { TAccessApprovalRequestDALFactory } from "../access-approval-request/acc import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-request/access-approval-request-reviewer-dal"; import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TAccessApprovalPolicyApproverDALFactory, TAccessApprovalPolicyBypasserDALFactory @@ -38,11 +38,11 @@ type TAccessApprovalPolicyServiceFactoryDep = { projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; accessApprovalRequestDAL: Pick; - additionalPrivilegeDAL: Pick; + additionalPrivilegeDAL: Pick; accessApprovalRequestReviewerDAL: Pick; accessApprovalPolicyEnvironmentDAL: TAccessApprovalPolicyEnvironmentDALFactory; }; @@ -83,12 +83,11 @@ export const accessApprovalPolicyServiceFactory = ({ return policyId ? policy && policy.id !== policyId : Boolean(policy); }; - const verifyProjectUserMembership = async (userIds: string[], projectId: string) => { + const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => { if (userIds.length === 0) return; - const projectMemberships = await projectMembershipDAL.find({ - $in: { userId: userIds }, - projectId - }); + const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter( + (v) => v.projectId === projectId + ); if (projectMemberships.length !== userIds.length) { const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId)); @@ -191,6 +190,7 @@ export const accessApprovalPolicyServiceFactory = ({ if (approverUserIds.length > 0) { await verifyProjectUserMembership( approverUserIds.map((au) => au.id), + project.orgId, project.id ); } @@ -445,7 +445,7 @@ export const accessApprovalPolicyServiceFactory = ({ // Validate user bypassers if (bypasserUserIds.length > 0) { - await verifyProjectUserMembership(bypasserUserIds, accessApprovalPolicy.projectId); + await verifyProjectUserMembership(bypasserUserIds, actorOrgId, accessApprovalPolicy.projectId); } // Validate group bypassers @@ -513,6 +513,7 @@ export const accessApprovalPolicyServiceFactory = ({ if (approverUserIds.length > 0) { await verifyProjectUserMembership( approverUserIds.map((au) => au.id), + actorOrgId, accessApprovalPolicy.projectId ); } @@ -650,7 +651,7 @@ export const accessApprovalPolicyServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const { membership } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId: project.id, @@ -658,9 +659,6 @@ export const accessApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index f3972fc1c..3bea66696 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -3,9 +3,10 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { AccessApprovalRequestsSchema, + AccessScope, TableName, TAccessApprovalRequests, - TOrgMemberships, + TMemberships, TUserGroupMembership, TUsers } from "@app/db/schemas"; @@ -244,11 +245,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR const docs = await db .replicaNode()(TableName.AccessApprovalRequest) .whereIn(`${TableName.AccessApprovalRequest}.policyId`, policyIds) - .leftJoin( - TableName.ProjectUserAdditionalPrivilege, + TableName.AdditionalPrivilege, `${TableName.AccessApprovalRequest}.privilegeId`, - `${TableName.ProjectUserAdditionalPrivilege}.id` + `${TableName.AdditionalPrivilege}.id` ) .leftJoin( TableName.AccessApprovalPolicy, @@ -276,7 +276,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR `${TableName.UserGroupMembership}.groupId` ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) - .leftJoin( TableName.AccessApprovalPolicyBypasser, `${TableName.AccessApprovalPolicy}.id`, @@ -294,24 +293,24 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR `requestedByUser.id` ) - .leftJoin( - db(TableName.OrgMembership).as("approverOrgMembership"), - `${TableName.AccessApprovalPolicyApprover}.approverUserId`, - `approverOrgMembership.userId` - ) - - .leftJoin( - db(TableName.OrgMembership).as("approverGroupOrgMembership"), - `${TableName.Users}.id`, - `approverGroupOrgMembership.userId` - ) - - .leftJoin( - db(TableName.OrgMembership).as("reviewerOrgMembership"), - `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, - `reviewerOrgMembership.userId` - ) - + .leftJoin(db(TableName.Membership).as("approverOrgMembership"), (qb) => { + qb.on( + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.actorUserId` + ).andOn(`approverOrgMembership.scope`, db.raw("?", [AccessScope.Organization])); + }) + .leftJoin(db(TableName.Membership).as("approverGroupOrgMembership"), (qb) => { + qb.on(`${TableName.Users}.id`, `approverGroupOrgMembership.actorUserId`).andOn( + `approverGroupOrgMembership.scope`, + db.raw("?", [AccessScope.Organization]) + ); + }) + .leftJoin(db(TableName.Membership).as("reviewerOrgMembership"), (qb) => { + qb.on( + `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.actorUserId` + ).andOn(`reviewerOrgMembership.scope`, db.raw("?", [AccessScope.Organization])); + }) .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) @@ -360,22 +359,22 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR db.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), db.ref("lastName").withSchema("requestedByUser").as("requestedByUserLastName"), - db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeUserId"), - db.ref("projectId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeMembershipId"), + db.ref("actorUserId").withSchema(TableName.AdditionalPrivilege).as("privilegeUserId"), + db.ref("projectId").withSchema(TableName.AdditionalPrivilege).as("privilegeMembershipId"), - db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"), - db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"), - db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"), + db.ref("isTemporary").withSchema(TableName.AdditionalPrivilege).as("privilegeIsTemporary"), + db.ref("temporaryMode").withSchema(TableName.AdditionalPrivilege).as("privilegeTemporaryMode"), + db.ref("temporaryRange").withSchema(TableName.AdditionalPrivilege).as("privilegeTemporaryRange"), db .ref("temporaryAccessStartTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) + .withSchema(TableName.AdditionalPrivilege) .as("privilegeTemporaryAccessStartTime"), db .ref("temporaryAccessEndTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) + .withSchema(TableName.AdditionalPrivilege) .as("privilegeTemporaryAccessEndTime"), - db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions") + db.ref("permissions").withSchema(TableName.AdditionalPrivilege).as("privilegePermissions") ) .orderBy(`${TableName.AccessApprovalRequest}.createdAt`, "desc"); @@ -408,7 +407,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR privilege: doc.privilegeId ? { membershipId: doc.privilegeMembershipId, - userId: doc.privilegeUserId, + userId: doc.privilegeUserId || "", projectId: doc.projectId, isTemporary: doc.privilegeIsTemporary, temporaryMode: doc.privilegeTemporaryMode, @@ -773,9 +772,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR ) .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .leftJoin( - TableName.ProjectUserAdditionalPrivilege, + TableName.AdditionalPrivilege, `${TableName.AccessApprovalRequest}.privilegeId`, - `${TableName.ProjectUserAdditionalPrivilege}.id` + `${TableName.AdditionalPrivilege}.id` ) .leftJoin( diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index d69c6da79..1027995a7 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -1,7 +1,7 @@ import slugify from "@sindresorhus/slugify"; import msFn from "ms"; -import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; +import { ActionProjectType, ProjectMembershipRole, TemporaryPermissionMode } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -10,12 +10,12 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { EnforcementLevel } from "@app/lib/types"; import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; import { TriggerFeature } from "@app/lib/workflow-integrations/types"; +import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; -import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -26,15 +26,13 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; import { verifyRequestedPermissions } from "./access-approval-request-fns"; import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal"; import { ApprovalStatus, TAccessApprovalRequestServiceFactory } from "./access-approval-request-types"; type TSecretApprovalRequestServiceFactoryDep = { - additionalPrivilegeDAL: Pick; + additionalPrivilegeDAL: Pick; permissionService: Pick; accessApprovalPolicyApproverDAL: Pick; projectEnvDAL: Pick; @@ -59,7 +57,6 @@ type TSecretApprovalRequestServiceFactoryDep = { "create" | "find" | "findOne" | "transaction" | "delete" >; groupDAL: Pick; - projectMembershipDAL: Pick; smtpService: Pick; userDAL: Pick< TUserDALFactory, @@ -125,7 +122,7 @@ export const accessApprovalRequestServiceFactory = ({ if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); // Anyone can create an access approval request. - const { membership } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId: project.id, @@ -133,9 +130,6 @@ export const accessApprovalRequestServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } const requestedByUser = await userDAL.findById(actorId); if (!requestedByUser) throw new ForbiddenRequestError({ message: "User not found" }); @@ -340,7 +334,7 @@ export const accessApprovalRequestServiceFactory = ({ }); } - const { membership, hasRole } = await permissionService.getProjectPermission({ + const { hasRole } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -349,10 +343,6 @@ export const accessApprovalRequestServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } - const isApprover = policy.approvers.find((approver) => approver.userId === actorId); if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) { @@ -496,7 +486,7 @@ export const accessApprovalRequestServiceFactory = ({ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const { membership } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId: project.id, @@ -504,9 +494,6 @@ export const accessApprovalRequestServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); @@ -566,7 +553,7 @@ export const accessApprovalRequestServiceFactory = ({ slug: permissionEnvironment }); - const { membership, hasRole } = await permissionService.getProjectPermission({ + const { hasRole } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -575,10 +562,6 @@ export const accessApprovalRequestServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } - const isSelfApproval = actorId === accessApprovalRequest.requestedByUserId; const isSoftEnforcement = policy.enforcementLevel === EnforcementLevel.Soft; const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId); @@ -724,9 +707,9 @@ export const accessApprovalRequestServiceFactory = ({ // Permanent access const privilege = await additionalPrivilegeDAL.create( { - userId: accessApprovalRequest.requestedByUserId, + actorUserId: accessApprovalRequest.requestedByUserId, projectId: accessApprovalRequest.projectId, - slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + name: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions) }, tx @@ -739,12 +722,12 @@ export const accessApprovalRequestServiceFactory = ({ const privilege = await additionalPrivilegeDAL.create( { - userId: accessApprovalRequest.requestedByUserId, + actorUserId: accessApprovalRequest.requestedByUserId, projectId: accessApprovalRequest.projectId, - slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, + name: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions), isTemporary: true, // Explicitly set to true for the privilege - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryMode: TemporaryPermissionMode.Relative, temporaryRange: accessApprovalRequest.temporaryRange!, temporaryAccessStartTime: startTime, temporaryAccessEndTime: new Date(startTime.getTime() + relativeTempAllocatedTimeInMs) @@ -830,7 +813,7 @@ export const accessApprovalRequestServiceFactory = ({ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const { membership } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId: project.id, @@ -838,9 +821,6 @@ export const accessApprovalRequestServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } const count = await accessApprovalRequestDAL.getCount({ projectId: project.id, policyId }); diff --git a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts index 5dd0fd4ba..b3cd34ac9 100644 --- a/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts +++ b/backend/src/ee/services/audit-log-stream/audit-log-stream-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { AxiosError } from "axios"; -import { TAuditLogs } from "@app/db/schemas"; +import { OrganizationActionScope, TAuditLogs } from "@app/db/schemas"; import { decryptLogStream, decryptLogStreamCredentials, @@ -45,13 +45,14 @@ export const auditLogStreamServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -94,13 +95,14 @@ export const auditLogStreamServiceFactory = ({ const logStream = await auditLogStreamDAL.findById(logStreamId); if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - logStream.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -160,13 +162,14 @@ export const auditLogStreamServiceFactory = ({ const logStream = await auditLogStreamDAL.findById(logStreamId); if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - logStream.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); @@ -185,14 +188,14 @@ export const auditLogStreamServiceFactory = ({ const logStream = await auditLogStreamDAL.findById(logStreamId); if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${logStreamId}' not found` }); - - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - logStream.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -206,13 +209,14 @@ export const auditLogStreamServiceFactory = ({ }; const list = async (actor: OrgServiceActor) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); 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 ece5edaf9..eab7792f8 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; @@ -47,13 +47,14 @@ export const auditLogServiceFactory = ({ ); } else { // Organization-wide logs - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAuditLogsActions.Read, 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 ab72dbc65..6ceaa7778 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -173,6 +173,9 @@ export enum EventType { UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth", GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", + CREATE_SUB_ORGANIZATION = "create-sub-organization", + UPDATE_SUB_ORGANIZATION = "update-sub-organization", + ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", @@ -352,9 +355,18 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", GET_CERTIFICATE_TEMPLATE = "get-certificate-template", + LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", + CREATE_CERTIFICATE_PROFILE = "create-certificate-profile", + UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile", + DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile", + GET_CERTIFICATE_PROFILE = "get-certificate-profile", + LIST_CERTIFICATE_PROFILES = "list-certificate-profiles", + ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", + SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", + ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -500,7 +512,28 @@ export enum EventType { DASHBOARD_LIST_SECRETS = "dashboard-list-secrets", DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", - DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value" + DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value", + + PAM_SESSION_START = "pam-session-start", + PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update", + PAM_SESSION_END = "pam-session-end", + PAM_SESSION_GET = "pam-session-get", + PAM_SESSION_LIST = "pam-session-list", + PAM_FOLDER_CREATE = "pam-folder-create", + PAM_FOLDER_UPDATE = "pam-folder-update", + PAM_FOLDER_DELETE = "pam-folder-delete", + PAM_ACCOUNT_LIST = "pam-account-list", + PAM_ACCOUNT_ACCESS = "pam-account-access", + PAM_ACCOUNT_CREATE = "pam-account-create", + PAM_ACCOUNT_UPDATE = "pam-account-update", + PAM_ACCOUNT_DELETE = "pam-account-delete", + PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation", + PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed", + PAM_RESOURCE_LIST = "pam-resource-list", + PAM_RESOURCE_GET = "pam-resource-get", + PAM_RESOURCE_CREATE = "pam-resource-create", + PAM_RESOURCE_UPDATE = "pam-resource-update", + PAM_RESOURCE_DELETE = "pam-resource-delete" } export const filterableSecretEvents: EventType[] = [ @@ -588,6 +621,22 @@ interface GetSecretsEvent { }; } +interface CreateSubOrganizationEvent { + type: EventType.CREATE_SUB_ORGANIZATION; + metadata: { + name: string; + organizationId: string; + }; +} + +interface UpdateSubOrganizationEvent { + type: EventType.UPDATE_SUB_ORGANIZATION; + metadata: { + name: string; + organizationId: string; + }; +} + type TSecretMetadata = { key: string; value: string }[]; interface GetSecretEvent { @@ -2493,46 +2542,6 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } -interface CreateCertificateTemplate { - type: EventType.CREATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface GetCertificateTemplate { - type: EventType.GET_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - -interface UpdateCertificateTemplate { - type: EventType.UPDATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface DeleteCertificateTemplate { - type: EventType.DELETE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - interface OrgAdminAccessProjectEvent { type: EventType.ORG_ADMIN_ACCESS_PROJECT; metadata: { @@ -2579,6 +2588,138 @@ interface GetCertificateTemplateEstConfig { }; } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + projectId: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface ListCertificateTemplates { + type: EventType.LIST_CERTIFICATE_TEMPLATES; + metadata: { + projectId: string; + }; +} + +interface CreateCertificateProfile { + type: EventType.CREATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + projectId: string; + enrollmentType: string; + }; +} + +interface UpdateCertificateProfile { + type: EventType.UPDATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface DeleteCertificateProfile { + type: EventType.DELETE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface GetCertificateProfile { + type: EventType.GET_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface ListCertificateProfiles { + type: EventType.LIST_CERTIFICATE_PROFILES; + metadata: { + projectId: string; + }; +} + +interface IssueCertificateFromProfile { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + commonName: string; + profileName: string; + }; +} + +interface SignCertificateFromProfile { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + profileName: string; + commonName: string; + }; +} + +interface OrderCertificateFromProfile { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + orderId: string; + profileName: string; + }; +} + interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { @@ -3687,7 +3828,190 @@ interface OrgRoleDeleteEvent { }; } +interface PamSessionStartEvent { + type: EventType.PAM_SESSION_START; + metadata: { + sessionId: string; + accountName: string; + }; +} + +interface PamSessionLogsUpdateEvent { + type: EventType.PAM_SESSION_LOGS_UPDATE; + metadata: { + sessionId: string; + accountName: string; + }; +} + +interface PamSessionEndEvent { + type: EventType.PAM_SESSION_END; + metadata: { + sessionId: string; + accountName: string; + }; +} + +interface PamSessionGetEvent { + type: EventType.PAM_SESSION_GET; + metadata: { + sessionId: string; + }; +} + +interface PamSessionListEvent { + type: EventType.PAM_SESSION_LIST; + metadata: { + count: number; + }; +} + +interface PamFolderCreateEvent { + type: EventType.PAM_FOLDER_CREATE; + metadata: { + parentId?: string | null; + name: string; + description?: string | null; + }; +} + +interface PamFolderUpdateEvent { + type: EventType.PAM_FOLDER_UPDATE; + metadata: { + folderId: string; + name?: string; + description?: string | null; + }; +} + +interface PamFolderDeleteEvent { + type: EventType.PAM_FOLDER_DELETE; + metadata: { + folderId: string; + folderName: string; + }; +} + +interface PamAccountListEvent { + type: EventType.PAM_ACCOUNT_LIST; + metadata: { + accountCount: number; + folderCount: number; + }; +} + +interface PamAccountAccessEvent { + type: EventType.PAM_ACCOUNT_ACCESS; + metadata: { + accountId: string; + accountName: string; + duration?: string; + }; +} + +interface PamAccountCreateEvent { + type: EventType.PAM_ACCOUNT_CREATE; + metadata: { + resourceId: string; + resourceType: string; + folderId?: string | null; + name: string; + description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds?: number | null; + }; +} + +interface PamAccountUpdateEvent { + type: EventType.PAM_ACCOUNT_UPDATE; + metadata: { + accountId: string; + resourceId: string; + resourceType: string; + name?: string; + description?: string | null; + rotationEnabled?: boolean; + rotationIntervalSeconds?: number | null; + }; +} + +interface PamAccountDeleteEvent { + type: EventType.PAM_ACCOUNT_DELETE; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + }; +} + +interface PamAccountCredentialRotationEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + }; +} + +interface PamAccountCredentialRotationFailedEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + errorMessage: string; + }; +} + +interface PamResourceListEvent { + type: EventType.PAM_RESOURCE_LIST; + metadata: { + count: number; + }; +} + +interface PamResourceGetEvent { + type: EventType.PAM_RESOURCE_GET; + metadata: { + resourceId: string; + resourceType: string; + name: string; + }; +} + +interface PamResourceCreateEvent { + type: EventType.PAM_RESOURCE_CREATE; + metadata: { + resourceType: string; + gatewayId: string; + name: string; + }; +} + +interface PamResourceUpdateEvent { + type: EventType.PAM_RESOURCE_UPDATE; + metadata: { + resourceId: string; + resourceType: string; + gatewayId?: string; + name?: string; + }; +} + +interface PamResourceDeleteEvent { + type: EventType.PAM_RESOURCE_DELETE; + metadata: { + resourceId: string; + resourceType: string; + }; +} + export type Event = + | CreateSubOrganizationEvent + | UpdateSubOrganizationEvent | GetSecretsEvent | GetSecretEvent | CreateSecretEvent @@ -3876,13 +4200,22 @@ export type Event = | LoadProjectKmsBackupEvent | OrgAdminAccessProjectEvent | OrgAdminBypassSSOEvent - | CreateCertificateTemplate - | UpdateCertificateTemplate - | GetCertificateTemplate - | DeleteCertificateTemplate | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig + | CreateCertificateTemplate + | UpdateCertificateTemplate + | DeleteCertificateTemplate + | GetCertificateTemplate + | ListCertificateTemplates + | CreateCertificateProfile + | UpdateCertificateProfile + | DeleteCertificateProfile + | GetCertificateProfile + | ListCertificateProfiles + | IssueCertificateFromProfile + | SignCertificateFromProfile + | OrderCertificateFromProfile | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration @@ -4020,4 +4353,24 @@ export type Event = | ProjectRoleDeleteEvent | OrgRoleCreateEvent | OrgRoleUpdateEvent - | OrgRoleDeleteEvent; + | OrgRoleDeleteEvent + | PamSessionStartEvent + | PamSessionLogsUpdateEvent + | PamSessionEndEvent + | PamSessionGetEvent + | PamSessionListEvent + | PamFolderCreateEvent + | PamFolderUpdateEvent + | PamFolderDeleteEvent + | PamAccountListEvent + | PamAccountAccessEvent + | PamAccountCreateEvent + | PamAccountUpdateEvent + | PamAccountDeleteEvent + | PamAccountCredentialRotationEvent + | PamAccountCredentialRotationFailedEvent + | PamResourceListEvent + | PamResourceGetEvent + | PamResourceCreateEvent + | PamResourceUpdateEvent + | PamResourceDeleteEvent; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index 659e07bca..d2d683a84 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { @@ -134,13 +134,14 @@ export const dynamicSecretServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - gateway?.orgId ?? gatewayv2?.orgId, + orgId: gateway?.orgId || gatewayv2?.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, @@ -297,13 +298,14 @@ export const dynamicSecretServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: gateway?.orgId || gatewayv2?.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index f4d43f23f..1bcfa3938 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -34,6 +34,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { const $getClient = async (providerInputs: z.infer) => { const connection = new ElasticSearchClient({ + requestTimeout: 30_000, node: { url: new URL(`${providerInputs.host}:${providerInputs.port}`), ...(providerInputs.ca && { diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index d515c5973..9614f3298 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -3,6 +3,7 @@ import { STSServiceException } from "@aws-sdk/client-sts"; import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; +import { OrganizationActionScope } from "@app/db/schemas"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; @@ -51,13 +52,14 @@ export const externalKmsServiceFactory = ({ actorOrgId, actorAuthMethod }: TCreateExternalKmsDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Kms); const plan = await licenseService.getPlan(actorOrgId); @@ -154,13 +156,14 @@ export const externalKmsServiceFactory = ({ actorAuthMethod }: TUpdateExternalKmsDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); const plan = await licenseService.getPlan(kmsDoc.orgId); @@ -257,13 +260,14 @@ export const externalKmsServiceFactory = ({ const deleteById = async ({ actor, id: kmsId, actorId, actorOrgId, actorAuthMethod }: TDeleteExternalKmsDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id }); @@ -278,13 +282,14 @@ export const externalKmsServiceFactory = ({ }; const list = async ({ actor, actorId, actorOrgId, actorAuthMethod }: TListExternalKmsDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); const externalKmsDocs = await externalKmsDAL.find({ orgId: actorOrgId }); @@ -294,13 +299,14 @@ export const externalKmsServiceFactory = ({ const findById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id: kmsId }: TGetExternalKmsByIdDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); @@ -342,13 +348,14 @@ export const externalKmsServiceFactory = ({ name: kmsName }: TGetExternalKmsBySlugDTO) => { const kmsDoc = await kmsDAL.findOne({ name: kmsName, orgId: actorOrgId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - kmsDoc.orgId, + orgId: kmsDoc.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Kms); const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id }); diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts index e67d4e890..7e41de91c 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-constants.ts @@ -1,2 +1,3 @@ export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1"; export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2"; +export const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3"; diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts index da9d3c1ef..1896192cd 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-dal.ts @@ -10,20 +10,34 @@ export type TGatewayV2DALFactory = ReturnType; export const gatewayV2DalFactory = (db: TDbClient) => { const orm = ormify(db, TableName.GatewayV2); - const find = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + const find = async ( + filter: TFindFilter & { isHeartbeatStale?: boolean }, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { try { + const { isHeartbeatStale, ...regularFilter } = filter; + const query = (tx || db.replicaNode())(TableName.GatewayV2) // eslint-disable-next-line @typescript-eslint/no-misused-promises - .where(buildFindFilter(filter, TableName.GatewayV2)) + .where(buildFindFilter(regularFilter, TableName.GatewayV2)) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.GatewayV2}.identityId`) - .join( - TableName.IdentityOrgMembership, - `${TableName.IdentityOrgMembership}.identityId`, - `${TableName.GatewayV2}.identityId` - ) .select(selectAllTableCols(TableName.GatewayV2)) .select(db.ref("name").withSchema(TableName.Identity).as("identityName")); + if (isHeartbeatStale) { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + void query.where(`${TableName.GatewayV2}.heartbeat`, "<", oneHourAgo); + void query.where((v) => { + void v + .whereNull(`${TableName.GatewayV2}.healthAlertedAt`) + .orWhere( + `${TableName.GatewayV2}.healthAlertedAt`, + "<", + db.ref("heartbeat").withSchema(TableName.GatewayV2) + ); + }); + } + if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index e8ccc8a5e..fd4954a00 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -3,13 +3,15 @@ import net from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { TRelays } from "@app/db/schemas"; +import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; import { GatewayProxyProtocol } from "@app/lib/gateway/types"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts } from "@app/services/certificate/certificate-fns"; @@ -20,13 +22,18 @@ import { } from "@app/services/certificate-authority/certificate-authority-fns"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { NotificationType } from "@app/services/notification/notification-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TLicenseServiceFactory } from "../license/license-service"; +import { PamResource } from "../pam-resource/pam-resource-enums"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TRelayDALFactory } from "../relay/relay-dal"; import { TRelayServiceFactory } from "../relay/relay-service"; -import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants"; +import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID, PAM_INFO_OID } from "./gateway-v2-constants"; import { TGatewayV2DALFactory } from "./gateway-v2-dal"; import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal"; @@ -38,6 +45,9 @@ type TGatewayV2ServiceFactoryDep = { gatewayV2DAL: TGatewayV2DALFactory; relayDAL: TRelayDALFactory; permissionService: TPermissionServiceFactory; + orgDAL: Pick; + notificationService: Pick; + smtpService: Pick; }; export type TGatewayV2ServiceFactory = ReturnType; @@ -49,7 +59,10 @@ export const gatewayV2ServiceFactory = ({ relayService, gatewayV2DAL, relayDAL, - permissionService + permissionService, + orgDAL, + notificationService, + smtpService }: TGatewayV2ServiceFactoryDep) => { const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { const orgLicensePlan = await licenseService.getPlan(orgId); @@ -60,13 +73,14 @@ export const gatewayV2ServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, actorId, orgId, actorAuthMethod, - orgId - ); + actorOrgId: orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, @@ -245,13 +259,14 @@ export const gatewayV2ServiceFactory = ({ }; const listGateways = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.ListGateways, @@ -414,6 +429,176 @@ export const gatewayV2ServiceFactory = ({ }; }; + const getPAMConnectionDetails = async ({ + gatewayId, + sessionId, + duration, + resourceType, + host, + port, + actorMetadata + }: { + gatewayId: string; + sessionId: string; + resourceType: PamResource; + duration?: number; + host: string; + port: number; + actorMetadata: { id: string; type: ActorType; name: string }; + }) => { + const gateway = await gatewayV2DAL.findById(gatewayId); + if (!gateway) { + return; + } + + const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: gateway.orgId }); + if (!orgGatewayConfig) { + throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` }); + } + + if (!gateway.relayId) { + throw new BadRequestError({ + message: "Gateway is not associated with a relay" + }); + } + + const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId); + if (!orgLicensePlan.gateway) { + throw new BadRequestError({ + message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways." + }); + } + + const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgGatewayConfig.orgId + }); + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + + const rootGatewayCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedRootGatewayCaCertificate + }) + ); + + const gatewayClientCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaCertificate + }) + ); + + const gatewayServerCaCert = new x509.X509Certificate( + orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayServerCaCertificate + }) + ); + + const gatewayClientCaPrivateKey = orgKmsDecryptor({ + cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey + }); + + const gatewayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({ + key: gatewayClientCaPrivateKey, + format: "der", + type: "pkcs8" + }); + + const importedGatewayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey( + "pkcs8", + gatewayClientCaSkObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const clientCertIssuedAt = new Date(); + const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000)); + const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const clientCertSerialNumber = createSerialNumber(); + + const routingInfo = { + targetHost: host, + targetPort: port + }; + + const routingExtension = new x509.Extension( + GATEWAY_ROUTING_INFO_OID, + false, + Buffer.from(JSON.stringify(routingInfo)) + ); + + const pamInfoExtension = new x509.Extension( + PAM_INFO_OID, + false, + Buffer.from( + JSON.stringify({ + sessionId, + resourceType + }) + ) + ); + + const actorExtension = new x509.Extension( + GATEWAY_ACTOR_OID, + false, + Buffer.from(JSON.stringify({ type: actorMetadata.type, id: actorMetadata.id, name: actorMetadata.name })) + ); + + const clientCert = await x509.X509CertificateGenerator.create({ + serialNumber: clientCertSerialNumber, + subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${actorMetadata.type}:${gatewayId}`, + issuer: gatewayClientCaCert.subject, + notAfter: clientCertExpiration, + notBefore: clientCertIssuedAt, + signingKey: importedGatewayClientCaPrivateKey, + publicKey: clientKeys.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(gatewayClientCaCert, false), + await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy + new x509.KeyUsagesExtension( + // eslint-disable-next-line no-bitwise + x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] | + x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] | + x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT], + true + ), + new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true), + routingExtension, + actorExtension, + pamInfoExtension + ] + }); + + const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); + + const relayCredentials = await relayService.getCredentialsForClient({ + relayId: gateway.relayId, + orgId: gateway.orgId, + orgName: gateway.orgName, + gatewayId, + gatewayName: gateway.name, + duration + }); + + return { + relayHost: relayCredentials.relayHost, + gateway: { + clientCertificate: clientCert.toString("pem"), + clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(), + serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert]) + }, + relay: { + clientCertificate: relayCredentials.clientCertificate, + clientPrivateKey: relayCredentials.clientPrivateKey, + serverCertificateChain: relayCredentials.serverCertificateChain + } + }; + }; + const registerGateway = async ({ orgId, actorId, @@ -632,27 +817,145 @@ export const gatewayV2ServiceFactory = ({ throw new NotFoundError({ message: `Gateway ${id} not found` }); } - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - gateway.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: gateway.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway ); - return gatewayV2DAL.deleteById(gateway.id); + try { + return await gatewayV2DAL.deleteById(gateway.id); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation + ) { + throw new BadRequestError({ + message: "Failed to delete gateway because it is attached to active resources" + }); + } + + throw err; + } + }; + + const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => { + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + return gatewayV2DAL.transaction(async (tx) => { + const gateway = await gatewayV2DAL.findOne( + { + identityId: orgPermission.id + }, + tx + ); + + if (!gateway) { + throw new NotFoundError({ message: "Gateway not found" }); + } + + const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + if (gateway.encryptedPamSessionKey) { + return decryptor({ cipherTextBlob: gateway.encryptedPamSessionKey }); + } + + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.GatewayPamSessionKey(gateway.id)]); + + const newPamSessionKey = crypto.randomBytes(32); + const { cipherTextBlob: encryptedPamSessionKey } = encryptor({ plainText: newPamSessionKey }); + + await gatewayV2DAL.updateById(gateway.id, { encryptedPamSessionKey }, tx); + + return newPamSessionKey; + }); + }; + + const healthcheckNotify = async () => { + const unhealthyGateways = await gatewayV2DAL.find({ + isHeartbeatStale: true + }); + + if (unhealthyGateways.length === 0) return; + + logger.warn( + { gatewayIds: unhealthyGateways.map((g) => g.id) }, + "Found gateways with last heartbeat over an hour ago. Sending notifications." + ); + + const gatewaysByOrg = groupBy(unhealthyGateways, (gw) => gw.orgId); + + for await (const [orgId, gateways] of Object.entries(gatewaysByOrg)) { + try { + const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin); + if (admins.length === 0) { + logger.warn({ orgId }, "Organization has no admins to notify about unhealthy gateway."); + // eslint-disable-next-line no-continue + continue; + } + + const gatewayNames = gateways.map((g) => `"${g.name}"`).join(", "); + const body = `The following gateway(s) in your organization may be offline as they haven't reported a heartbeat in over an hour: ${gatewayNames}. Please check their status.`; + + await notificationService.createUserNotifications( + admins.map((admin) => ({ + userId: admin.user.id, + orgId, + type: NotificationType.GATEWAY_HEALTH_ALERT, + title: "Gateway Health Alert", + body, + link: "/organization/networking" + })) + ); + + await smtpService.sendMail({ + recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), + subjectLine: "Gateway Health Alert", + substitutions: { + type: "gateway", + names: gatewayNames + }, + template: SmtpTemplates.HealthAlert + }); + + await Promise.all(gateways.map((gw) => gatewayV2DAL.updateById(gw.id, { healthAlertedAt: new Date() }))); + } catch (error) { + logger.error(error, `Failed to send gateway health notifications for organization [orgId=${orgId}]`); + } + } }; return { listGateways, registerGateway, getPlatformConnectionDetailsByGatewayId, + getPAMConnectionDetails, deleteGatewayById, - heartbeat + heartbeat, + getPamSessionKey, + healthcheckNotify }; }; diff --git a/backend/src/ee/services/gateway/gateway-dal.ts b/backend/src/ee/services/gateway/gateway-dal.ts index c21ff31c0..58f59d313 100644 --- a/backend/src/ee/services/gateway/gateway-dal.ts +++ b/backend/src/ee/services/gateway/gateway-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { GatewaysSchema, TableName, TGateways } from "@app/db/schemas"; +import { AccessScope, GatewaysSchema, TableName, TGateways } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; @@ -17,17 +17,14 @@ export const gatewayDALFactory = (db: TDbClient) => { // eslint-disable-next-line @typescript-eslint/no-misused-promises .where(buildFindFilter(filter, TableName.Gateway, ["orgId"])) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Gateway}.identityId`) - .join( - TableName.IdentityOrgMembership, - `${TableName.IdentityOrgMembership}.identityId`, - `${TableName.Gateway}.identityId` - ) + .join(TableName.Membership, `${TableName.Membership}.actorIdentityId`, `${TableName.Gateway}.identityId`) .select(selectAllTableCols(TableName.Gateway)) - .select(db.ref("orgId").withSchema(TableName.IdentityOrgMembership).as("identityOrgId")) - .select(db.ref("name").withSchema(TableName.Identity).as("identityName")); + .select(db.ref("scopeOrgId").withSchema(TableName.Membership).as("identityOrgId")) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")) + .where(`${TableName.Membership}.scope`, AccessScope.Organization); if (filter.orgId) { - void query.where(`${TableName.IdentityOrgMembership}.orgId`, filter.orgId); + void query.where(`${TableName.Membership}.scopeOrgId`, filter.orgId); } if (limit) void query.limit(limit); if (offset) void query.offset(offset); @@ -39,7 +36,7 @@ export const gatewayDALFactory = (db: TDbClient) => { return docs.map((el) => ({ ...GatewaysSchema.parse(el), - orgId: el.identityOrgId as string, // todo(daniel): figure out why typescript is not inferring this as a string + orgId: el.identityOrgId, identity: { id: el.identityId, name: el.identityName } })); } catch (error) { diff --git a/backend/src/ee/services/gateway/gateway-service.ts b/backend/src/ee/services/gateway/gateway-service.ts index 5c8ad80bf..261640cc9 100644 --- a/backend/src/ee/services/gateway/gateway-service.ts +++ b/backend/src/ee/services/gateway/gateway-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { z } from "zod"; +import { OrganizationActionScope } from "@app/db/schemas"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -68,13 +69,14 @@ export const gatewayServiceFactory = ({ "Gateway handshake failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan." }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, actorId, orgId, actorAuthMethod, - orgId - ); + actorOrgId: orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway @@ -480,13 +482,14 @@ export const gatewayServiceFactory = ({ }; const listGateways = async ({ orgPermission }: TListGatewaysDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway @@ -501,13 +504,14 @@ export const gatewayServiceFactory = ({ }; const getGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway @@ -521,13 +525,14 @@ export const gatewayServiceFactory = ({ }; const updateGatewayById = async ({ orgPermission, id, name }: TUpdateGatewayByIdDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.EditGateways, OrgPermissionSubjects.Gateway @@ -542,13 +547,14 @@ export const gatewayServiceFactory = ({ }; const deleteGatewayById = async ({ orgPermission, id }: TGetGatewayByIdDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index 7c4ad15eb..d2713f269 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -6,13 +6,15 @@ import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; import RE2 from "re2"; -import { OrgMembershipRole } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { retryWithBackoff } from "@app/lib/retry"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TGroupDALFactory } from "../group/group-dal"; @@ -77,11 +79,10 @@ type TGithubOrgSyncServiceFactoryDep = { "findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete" >; groupDAL: Pick; + membershipRoleDAL: Pick; + membershipGroupDAL: Pick; licenseService: Pick; - orgMembershipDAL: Pick< - TOrgMembershipDALFactory, - "find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId" - >; + orgMembershipDAL: Pick; }; export type TGithubOrgSyncServiceFactory = ReturnType; @@ -93,7 +94,9 @@ export const githubOrgSyncServiceFactory = ({ userGroupMembershipDAL, groupDAL, licenseService, - orgMembershipDAL + orgMembershipDAL, + membershipRoleDAL, + membershipGroupDAL }: TGithubOrgSyncServiceFactoryDep) => { const createGithubOrgSync = async ({ githubOrgName, @@ -101,13 +104,14 @@ export const githubOrgSyncServiceFactory = ({ githubOrgAccessToken, isActive }: TCreateGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSync); const plan = await licenseService.getPlan(orgPermission.orgId); @@ -159,13 +163,14 @@ export const githubOrgSyncServiceFactory = ({ githubOrgAccessToken, isActive }: TUpdateGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + scope: OrganizationActionScope.ParentOrganization, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); const plan = await licenseService.getPlan(orgPermission.orgId); @@ -223,13 +228,14 @@ export const githubOrgSyncServiceFactory = ({ }; const deleteGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: orgPermission.type, + actorId: orgPermission.id, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); @@ -253,13 +259,14 @@ export const githubOrgSyncServiceFactory = ({ }; const getGithubOrgSync = async ({ orgPermission }: TDeleteGithubOrgSyncDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: orgPermission.id, + actor: orgPermission.type, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); @@ -368,6 +375,23 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); + const memberships = await membershipGroupDAL.insertMany( + newGroups.map((el) => ({ + actorGroupId: el.id, + scope: AccessScope.Organization, + scopeOrgId: orgId + })), + tx + ); + + await membershipRoleDAL.insertMany( + memberships.map((el) => ({ + membershipId: el.id, + role: OrgMembershipRole.Member + })), + tx + ); + await userGroupMembershipDAL.insertMany( newGroups.map((el) => ({ groupId: el.id, @@ -402,13 +426,14 @@ export const githubOrgSyncServiceFactory = ({ }; const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: orgPermission.id, + actor: orgPermission.type, + orgId: orgPermission.orgId, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); @@ -489,13 +514,14 @@ export const githubOrgSyncServiceFactory = ({ }; const syncAllTeams = async ({ orgPermission }: TSyncAllTeamsDTO): Promise => { - const { permission } = await permissionService.getOrgPermission( - orgPermission.type, - orgPermission.id, - orgPermission.orgId, - orgPermission.authMethod, - orgPermission.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor: orgPermission.type, + orgId: orgPermission.orgId, + actorId: orgPermission.id, + actorAuthMethod: orgPermission.authMethod, + actorOrgId: orgPermission.orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionActions.Edit, @@ -694,6 +720,23 @@ export const githubOrgSyncServiceFactory = ({ tx ); + const memberships = await membershipGroupDAL.insertMany( + newGroups.map((el) => ({ + actorGroupId: el.id, + scope: AccessScope.Organization, + scopeOrgId: orgPermission.orgId + })), + tx + ); + + await membershipRoleDAL.insertMany( + memberships.map((el) => ({ + membershipId: el.id, + role: OrgMembershipRole.Member + })), + tx + ); + newGroups.forEach((group) => { if (!existingTeamsMap[group.name]) { existingTeamsMap[group.name] = []; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 708fbdbd3..6fb02207d 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TGroups } from "@app/db/schemas"; +import { AccessScope, TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; @@ -20,7 +20,7 @@ export const groupDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Groups)); if (limit) void query.limit(limit); - if (offset) void query.limit(offset); + if (offset && offset > 0) void query.offset(offset); if (sort) { void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); } @@ -36,14 +36,21 @@ export const groupDALFactory = (db: TDbClient) => { try { const docs = await (tx || db.replicaNode())(TableName.Groups) .where(`${TableName.Groups}.orgId`, orgId) - .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .join(TableName.Membership, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .select(selectAllTableCols(TableName.Groups)) // cr stands for custom role - .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) - .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) - .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) - .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)); + .select(db.ref("id").as("crId").withSchema(TableName.Role)) + .select(db.ref("name").as("crName").withSchema(TableName.Role)) + .select(db.ref("role").withSchema(TableName.MembershipRole)) + .select(db.ref("customRoleId").as("roleId").withSchema(TableName.MembershipRole)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.Role)) + .select(db.ref("description").as("crDescription").withSchema(TableName.Role)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.Role)); + return docs.map(({ crId, crDescription, crSlug, crPermission, crName, ...el }) => ({ ...el, customRole: el.roleId @@ -81,9 +88,11 @@ export const groupDALFactory = (db: TDbClient) => { }) => { try { const query = db - .replicaNode()(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .leftJoin(TableName.UserGroupMembership, (bd) => { bd.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( `${TableName.UserGroupMembership}.groupId`, @@ -92,7 +101,7 @@ export const groupDALFactory = (db: TDbClient) => { ); }) .select( - db.ref("id").withSchema(TableName.OrgMembership), + db.ref("id").withSchema(TableName.Membership), db.ref("groupId").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"), db.ref("email").withSchema(TableName.Users), @@ -160,8 +169,10 @@ export const groupDALFactory = (db: TDbClient) => { const findGroupsByProjectId = async (projectId: string, tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.Groups) - .join(TableName.GroupProjectMembership, `${TableName.Groups}.id`, `${TableName.GroupProjectMembership}.groupId`) - .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .join(TableName.Membership, `${TableName.Membership}.actorGroupId`, `${TableName.Groups}.id`) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorGroupId`) .select(selectAllTableCols(TableName.Groups)); return docs; } catch (error) { @@ -172,11 +183,16 @@ export const groupDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { try { const doc = await (tx || db.replicaNode())(TableName.Groups) - .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) + .join(TableName.Membership, `${TableName.Membership}.actorGroupId`, `${TableName.Groups}.id`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .where(`${TableName.Groups}.id`, id) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) .select( selectAllTableCols(TableName.Groups), - db.ref("slug").as("customRoleSlug").withSchema(TableName.OrgRoles) + db.ref("slug").as("customRoleSlug").withSchema(TableName.Role), + db.ref("customRoleId").as("roleId").withSchema(TableName.MembershipRole), + db.ref("role").withSchema(TableName.MembershipRole) ) .first(); @@ -186,12 +202,36 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findOne = async (filter: Partial, tx?: Knex): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.Groups) + .join(TableName.Membership, `${TableName.Membership}.actorGroupId`, `${TableName.Groups}.id`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where((queryBuilder) => { + Object.entries(filter).forEach(([key, value]) => { + void queryBuilder.where(`${TableName.Groups}.${key}`, value); + }); + }) + .select( + selectAllTableCols(TableName.Groups), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").as("roleId").withSchema(TableName.MembershipRole) + ) + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + return { ...groupOrm, findGroups, findByOrgId, findAllGroupPossibleMembers, findGroupsByProjectId, - findById + findById, + findOne }; }; diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index 56f8df6c0..c4384cc4e 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -1,6 +1,6 @@ import { Knex } from "knex"; -import { ProjectVersion, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; +import { AccessScope, ProjectVersion, SecretKeyEncoding, TableName, TUsers } from "@app/db/schemas"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError, ScimRequestError } from "@app/lib/errors"; @@ -16,7 +16,7 @@ const addAcceptedUsersToGroup = async ({ group, userGroupMembershipDAL, userDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, projectDAL, projectBotDAL, @@ -42,13 +42,15 @@ const addAcceptedUsersToGroup = async ({ const projectIds = Array.from( new Set( ( - await groupProjectDAL.find( + await membershipGroupDAL.find( { - groupId: group.id + actorGroupId: group.id, + scopeOrgId: group.orgId, + scope: AccessScope.Project }, { tx } ) - ).map((gp) => gp.projectId) + ).map((gp) => gp.scopeProjectId as string) ) ); @@ -167,11 +169,11 @@ export const addUsersToGroupByUserIds = async ({ userDAL, userGroupMembershipDAL, orgDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, - tx: outerTx + tx: outerTx, + membershipGroupDAL }: TAddUsersToGroupByUserIds) => { const processAddition = async (tx: Knex) => { const foundMembers = await userDAL.find( @@ -214,15 +216,18 @@ export const addUsersToGroupByUserIds = async ({ // check if all user(s) are part of the organization const existingUserOrgMemberships = await orgDAL.findMembership( { - [`${TableName.OrgMembership}.orgId` as "orgId"]: group.orgId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: group.orgId, + scope: AccessScope.Organization, $in: { - [`${TableName.OrgMembership}.userId` as "userId"]: userIds + [`${TableName.Membership}.actorUserId` as "actorUserId"]: userIds } }, { tx } ); - const existingUserOrgMembershipsUserIdsSet = new Set(existingUserOrgMemberships.map((u) => u.userId)); + const existingUserOrgMembershipsUserIdsSet = new Set( + existingUserOrgMemberships.map((u) => u.actorUserId as string) + ); userIds.forEach((userId) => { if (!existingUserOrgMembershipsUserIdsSet.has(userId)) @@ -250,7 +255,7 @@ export const addUsersToGroupByUserIds = async ({ group, userDAL, userGroupMembershipDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, projectDAL, projectBotDAL, @@ -292,9 +297,9 @@ export const removeUsersFromGroupByUserIds = async ({ userIds, userDAL, userGroupMembershipDAL, - groupProjectDAL, projectKeyDAL, - tx: outerTx + tx: outerTx, + membershipGroupDAL }: TRemoveUsersFromGroupByUserIds) => { const processRemoval = async (tx: Knex) => { const foundMembers = await userDAL.find({ @@ -352,13 +357,15 @@ export const removeUsersFromGroupByUserIds = async ({ const projectIds = Array.from( new Set( ( - await groupProjectDAL.find( + await membershipGroupDAL.find( { - groupId: group.id + scope: AccessScope.Project, + actorGroupId: group.id, + scopeOrgId: group.orgId }, { tx } ) - ).map((gp) => gp.projectId) + ).map((gp) => gp.scopeProjectId as string) ) ); @@ -422,11 +429,11 @@ export const convertPendingGroupAdditionsToGroupMemberships = async ({ userIds, userDAL, userGroupMembershipDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, - tx: outerTx + tx: outerTx, + membershipGroupDAL }: TConvertPendingGroupAdditionsToGroupMemberships) => { const processConversion = async (tx: Knex) => { const users = await userDAL.find( @@ -463,7 +470,7 @@ export const convertPendingGroupAdditionsToGroupMemberships = async ({ group: pendingGroupAddition.group, userDAL, userGroupMembershipDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, projectDAL, projectBotDAL, diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index d4d30b5ae..956d7853a 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -1,11 +1,12 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole, TRoles } from "@app/db/schemas"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -35,8 +36,9 @@ type TGroupServiceFactoryDep = { TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" >; - groupProjectDAL: Pick; - orgDAL: Pick; + membershipGroupDAL: Pick; + membershipRoleDAL: Pick; + orgDAL: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, "findOne" | "delete" | "filterProjectsByUserMembership" | "transaction" | "insertMany" | "find" @@ -46,7 +48,7 @@ type TGroupServiceFactoryDep = { projectKeyDAL: Pick; permissionService: Pick< TPermissionServiceFactory, - "getOrgPermission" | "getOrgPermissionByRole" | "invalidateProjectPermissionCache" + "getOrgPermission" | "getOrgPermissionByRoles" | "invalidateProjectPermissionCache" >; licenseService: Pick; oidcConfigDAL: Pick; @@ -57,7 +59,6 @@ export type TGroupServiceFactory = ReturnType; export const groupServiceFactory = ({ userDAL, groupDAL, - groupProjectDAL, orgDAL, userGroupMembershipDAL, projectDAL, @@ -65,18 +66,21 @@ export const groupServiceFactory = ({ projectKeyDAL, permissionService, licenseService, - oidcConfigDAL + oidcConfigDAL, + membershipGroupDAL, + membershipRoleDAL }: TGroupServiceFactoryDep) => { const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); @@ -85,25 +89,23 @@ export const groupServiceFactory = ({ message: "Failed to create group due to plan restriction. Upgrade plan to create group." }); - const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( - role, - actorOrgId - ); - const isCustomRole = Boolean(customRole); + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + const isCustomRole = Boolean(rolePermissionDetails?.role); if (role !== OrgMembershipRole.NoAccess) { const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups, permission, - rolePermission + rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to create group", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups ), @@ -125,7 +127,25 @@ export const groupServiceFactory = ({ slug: slug || slugify(`${name}-${alphaNumericNanoId(4)}`), orgId: actorOrgId, role: isCustomRole ? OrgMembershipRole.Custom : role, - roleId: customRole?.id + roleId: null + }, + tx + ); + + const membership = await membershipGroupDAL.create( + { + actorGroupId: newGroup.id, + scope: AccessScope.Organization, + scopeOrgId: actorOrgId + }, + tx + ); + + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: isCustomRole ? OrgMembershipRole.Custom : role, + customRoleId: rolePermissionDetails?.role?.id }, tx ); @@ -148,13 +168,14 @@ export const groupServiceFactory = ({ }: TUpdateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); @@ -169,32 +190,31 @@ export const groupServiceFactory = ({ throw new NotFoundError({ message: `Failed to find group with ID ${id}` }); } - let customRole: TOrgRoles | undefined; + let customRole: TRoles | undefined; if (role) { - const { permission: rolePermission, role: customOrgRole } = await permissionService.getOrgPermissionByRole( - role, - group.orgId - ); + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], group.orgId); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + const isCustomRole = Boolean(rolePermissionDetails?.role); - const isCustomRole = Boolean(customOrgRole); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups, permission, - rolePermission + rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to update group", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups ), details: { missingPermissions: permissionBoundary.missingPermissions } }); - if (isCustomRole) customRole = customOrgRole; + if (isCustomRole) customRole = rolePermissionDetails?.role; } const updatedGroup = await groupDAL.transaction(async (tx) => { @@ -208,48 +228,58 @@ export const groupServiceFactory = ({ } } - const [updated] = await groupDAL.update( - { - id: group.id - }, - { - name, - slug: slug ? slugify(slug) : undefined, - ...(role - ? { - role: customRole ? OrgMembershipRole.Custom : role, - roleId: customRole?.id ?? null - } - : {}) - }, - tx - ); + let updated = group; + + if (name || slug) { + [updated] = await groupDAL.update( + { + id: group.id + }, + { + name, + slug: slug ? slugify(slug) : undefined + }, + tx + ); + } + + if (role) { + const membership = await membershipGroupDAL.findOne( + { + scope: AccessScope.Organization, + actorGroupId: updated.id, + scopeOrgId: updated.orgId + }, + tx + ); + await membershipRoleDAL.delete({ membershipId: membership.id }, tx); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: customRole ? OrgMembershipRole.Custom : role, + customRoleId: customRole?.id ?? null + }, + tx + ); + } return updated; }); - if (role) { - const groupProjects = await groupProjectDAL.find({ groupId: group.id }); - await Promise.allSettled([ - ...groupProjects.map((groupProject) => - permissionService.invalidateProjectPermissionCache(groupProject.projectId) - ) - ]); - } - return updatedGroup; }; const deleteGroup = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); @@ -259,34 +289,29 @@ export const groupServiceFactory = ({ message: "Failed to delete group due to plan restriction. Upgrade plan to delete group." }); - const groupProjects = await groupProjectDAL.find({ groupId: id }); - const [group] = await groupDAL.delete({ id, orgId: actorOrgId }); - await Promise.allSettled([ - ...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId)) - ]); - return group; }; const getGroupById = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TGetGroupByIdDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findById(id); - if (!group) { + if (!group || group.orgId !== actorOrgId) { throw new NotFoundError({ message: `Cannot find group with ID ${id}` }); @@ -309,13 +334,14 @@ export const groupServiceFactory = ({ }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findOne({ @@ -344,13 +370,14 @@ export const groupServiceFactory = ({ const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists @@ -376,22 +403,23 @@ export const groupServiceFactory = ({ }); } - const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); // check if user has broader or equal to privileges than group const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups, permission, - groupRolePermission + rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to add user to more privileged group", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups ), @@ -410,17 +438,12 @@ export const groupServiceFactory = ({ userDAL, userGroupMembershipDAL, orgDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, projectDAL, projectBotDAL }); - const groupProjects = await groupProjectDAL.find({ groupId: group.id }); - await Promise.allSettled([ - ...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId)) - ]); - return users[0]; }; @@ -434,13 +457,14 @@ export const groupServiceFactory = ({ }: TRemoveUserFromGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists @@ -466,21 +490,22 @@ export const groupServiceFactory = ({ }); } - const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); // check if user has broader or equal to privileges than group const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups, permission, - groupRolePermission + rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to delete user from more privileged group", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups ), @@ -498,15 +523,10 @@ export const groupServiceFactory = ({ userIds: [user.id], userDAL, userGroupMembershipDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL }); - const groupProjects = await groupProjectDAL.find({ groupId: group.id }); - await Promise.allSettled([ - ...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId)) - ]); - return users[0]; }; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index e91f7a47b..4b0742201 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TGroups } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TGenericPermission } from "@app/lib/types"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -63,7 +63,7 @@ export type TAddUsersToGroup = { group: TGroups; userDAL: Pick; userGroupMembershipDAL: Pick; - groupProjectDAL: Pick; + membershipGroupDAL: Pick; projectKeyDAL: Pick; projectDAL: Pick; projectBotDAL: Pick; @@ -76,7 +76,7 @@ export type TAddUsersToGroupByUserIds = { userDAL: Pick; userGroupMembershipDAL: Pick; orgDAL: Pick; - groupProjectDAL: Pick; + membershipGroupDAL: Pick; projectKeyDAL: Pick; projectDAL: Pick; projectBotDAL: Pick; @@ -88,7 +88,7 @@ export type TRemoveUsersFromGroupByUserIds = { userIds: string[]; userDAL: Pick; userGroupMembershipDAL: Pick; - groupProjectDAL: Pick; + membershipGroupDAL: Pick; projectKeyDAL: Pick; tx?: Knex; }; @@ -100,7 +100,7 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds" >; - groupProjectDAL: Pick; + membershipGroupDAL: Pick; projectKeyDAL: Pick; projectDAL: Pick; projectBotDAL: Pick; diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts index 374459b0c..886eebe29 100644 --- a/backend/src/ee/services/group/user-group-membership-dal.ts +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { AccessScope, TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; @@ -18,21 +18,19 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { */ const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[], tx?: Knex) => { try { - const userProjectMemberships: string[] = await (tx || db.replicaNode())(TableName.ProjectMembership) - .where(`${TableName.ProjectMembership}.userId`, userId) - .whereIn(`${TableName.ProjectMembership}.projectId`, projectIds) - .pluck(`${TableName.ProjectMembership}.projectId`); + const userProjectMemberships: string[] = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.actorUserId`, userId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereIn(`${TableName.Membership}.scopeProjectId`, projectIds) + .pluck(`${TableName.Membership}.scopeProjectId`); const userGroupMemberships: string[] = await (tx || db.replicaNode())(TableName.UserGroupMembership) .where(`${TableName.UserGroupMembership}.userId`, userId) .whereNot(`${TableName.UserGroupMembership}.groupId`, groupId) - .join( - TableName.GroupProjectMembership, - `${TableName.UserGroupMembership}.groupId`, - `${TableName.GroupProjectMembership}.groupId` - ) - .whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds) - .pluck(`${TableName.GroupProjectMembership}.projectId`); + .join(TableName.Membership, `${TableName.UserGroupMembership}.groupId`, `${TableName.Membership}.actorGroupId`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereIn(`${TableName.Membership}.scopeProjectId`, projectIds) + .pluck(`${TableName.Membership}.scopeProjectId`); return new Set(userProjectMemberships.concat(userGroupMemberships)); } catch (error) { @@ -44,13 +42,10 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string, tx?: Knex) => { try { const usernameDocs: string[] = await (tx || db.replicaNode())(TableName.UserGroupMembership) - .join( - TableName.GroupProjectMembership, - `${TableName.UserGroupMembership}.groupId`, - `${TableName.GroupProjectMembership}.groupId` - ) + .join(TableName.Membership, `${TableName.UserGroupMembership}.groupId`, `${TableName.Membership}.actorGroupId`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) - .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .where(`${TableName.Membership}.scopeProjectId`, projectId) .whereIn(`${TableName.Users}.username`, usernames) .pluck(`${TableName.Users}.id`); @@ -73,24 +68,25 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { try { // get list of groups in the project with id [projectId] // that that are not the group with id [groupId] - const groups: string[] = await (tx || db.replicaNode())(TableName.GroupProjectMembership) - .where(`${TableName.GroupProjectMembership}.projectId`, projectId) - .whereNot(`${TableName.GroupProjectMembership}.groupId`, groupId) - .pluck(`${TableName.GroupProjectMembership}.groupId`); + const groups: string[] = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .whereNot(`${TableName.Membership}.actorGroupId`, groupId) + .pluck(`${TableName.Membership}.actorGroupId`); // main query const members = await (tx || db.replicaNode())(TableName.UserGroupMembership) .where(`${TableName.UserGroupMembership}.groupId`, groupId) .where(`${TableName.UserGroupMembership}.isPending`, false) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) - .leftJoin(TableName.ProjectMembership, (bd) => { - bd.on(`${TableName.Users}.id`, "=", `${TableName.ProjectMembership}.userId`).andOn( - `${TableName.ProjectMembership}.projectId`, + .leftJoin(TableName.Membership, (bd) => { + bd.on(`${TableName.Users}.id`, "=", `${TableName.Membership}.actorUserId`).andOn( + `${TableName.Membership}.scopeProjectId`, "=", db.raw("?", [projectId]) ); }) - .whereNull(`${TableName.ProjectMembership}.userId`) + .whereNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) .leftJoin( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, @@ -166,15 +162,17 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { const docs = await db .replicaNode()(TableName.UserGroupMembership) .join(TableName.Groups, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) - .join(TableName.OrgMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.OrgMembership}.userId`) + .join(TableName.Membership, `${TableName.UserGroupMembership}.userId`, `${TableName.Membership}.actorUserId`) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .where(`${TableName.UserGroupMembership}.userId`, userId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) .where(`${TableName.Groups}.orgId`, orgId) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), db.ref("name").withSchema(TableName.Groups).as("groupName"), - db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"), + db.ref("id").withSchema(TableName.Membership).as("orgMembershipId"), db.ref("firstName").withSchema(TableName.Users).as("firstName"), db.ref("lastName").withSchema(TableName.Users).as("lastName"), db.ref("slug").withSchema(TableName.Groups).as("groupSlug") @@ -191,15 +189,17 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { const docs = await db .replicaNode()(TableName.UserGroupMembership) .join(TableName.Groups, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) - .join(TableName.OrgMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.OrgMembership}.userId`) + .join(TableName.Membership, `${TableName.UserGroupMembership}.userId`, `${TableName.Membership}.actorUserId`) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .where(`${TableName.Groups}.id`, groupId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) .where(`${TableName.Groups}.orgId`, orgId) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), db.ref("name").withSchema(TableName.Groups).as("groupName"), - db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"), + db.ref("id").withSchema(TableName.Membership).as("orgMembershipId"), db.ref("firstName").withSchema(TableName.Users).as("firstName"), db.ref("lastName").withSchema(TableName.Users).as("lastName") ); diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts index 8eec7ceb7..400fa31e9 100644 --- a/backend/src/ee/services/hsm/hsm-fns.ts +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -1,8 +1,14 @@ import * as pkcs11js from "pkcs11js"; import { TEnvConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { THsmServiceFactory } from "./hsm-service"; import { HsmModule } from "./hsm-types"; export const initializeHsmModule = (envConfig: Pick) => { @@ -25,8 +31,9 @@ export const initializeHsmModule = (envConfig: Pick; + kmsRootConfigDAL: Pick; + licenseService?: Pick; +}) => { + const isHsmConfigured = await hsmService.isActive(); + + // null if the root kms config does not exist + let rootKmsConfigEncryptionStrategy: RootKeyEncryptionStrategy | null = null; + + const rootKmsConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID).catch(() => null); + + rootKmsConfigEncryptionStrategy = (rootKmsConfig?.encryptionStrategy || null) as RootKeyEncryptionStrategy | null; + if ( + rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.HSM && + licenseService && + !licenseService.onPremFeatures.hsm + ) { + throw new BadRequestError({ + message: "Your license does not include HSM integration. Please upgrade to the Enterprise plan to use HSM." + }); + } + + return { + rootKmsConfigEncryptionStrategy, + isHsmConfigured + }; +}; diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts index 0ed4c5faf..1207b1cd3 100644 --- a/backend/src/ee/services/hsm/hsm-service.ts +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -25,6 +25,8 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon const AES_KEY_SIZE = 256; const HMAC_KEY_SIZE = 256; + let pkcs11TestPassed = false; + const $withSession = async (callbackWithSession: SessionCallback): Promise => { const RETRY_INTERVAL = 200; // 200ms between attempts const MAX_TIMEOUT = 90_000; // 90 seconds maximum total time @@ -363,7 +365,9 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon return false; } - let pkcs11TestPassed = false; + if (pkcs11TestPassed) { + return true; + } try { pkcs11TestPassed = await $withSession($testPkcs11Module); @@ -371,7 +375,7 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon logger.error(err, "HSM: Error testing PKCS#11 module"); } - return envConfig.isHsmConfigured && isInitialized && pkcs11TestPassed; + return pkcs11TestPassed; }; const startService = async () => { @@ -460,10 +464,23 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon } }; + const randomBytes = async (length: number) => { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + const randomData = await $withSession((sessionHandle) => + pkcs11.C_GenerateRandom(sessionHandle, Buffer.alloc(length)) + ); + + return randomData; + }; + return { encrypt, startService, isActive, - decrypt + decrypt, + randomBytes }; }; diff --git a/backend/src/ee/services/hsm/hsm-types.ts b/backend/src/ee/services/hsm/hsm-types.ts index b688147f5..ada527329 100644 --- a/backend/src/ee/services/hsm/hsm-types.ts +++ b/backend/src/ee/services/hsm/hsm-types.ts @@ -1,5 +1,7 @@ import pkcs11js from "pkcs11js"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; + export type HsmModule = { pkcs11: pkcs11js.PKCS11; isInitialized: boolean; @@ -9,3 +11,8 @@ export enum HsmKeyType { AES = "AES", HMAC = "hmac" } + +export type THsmStatus = { + rootKmsConfigEncryptionStrategy: RootKeyEncryptionStrategy | null; + isHsmConfigured: boolean; +}; diff --git a/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts index ef071742d..10aa3b190 100644 --- a/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts +++ b/backend/src/ee/services/identity-auth-template/identity-auth-template-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { @@ -68,13 +69,14 @@ export const identityAuthTemplateServiceFactory = ({ templateFields: Record; } & Omit) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.CreateTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -113,13 +115,14 @@ export const identityAuthTemplateServiceFactory = ({ throw new NotFoundError({ message: "Template not found" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - template.orgId, + orgId: template.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.EditTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -227,13 +230,14 @@ export const identityAuthTemplateServiceFactory = ({ throw new NotFoundError({ message: "Template not found" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - template.orgId, + orgId: template.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.DeleteTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -282,13 +286,14 @@ export const identityAuthTemplateServiceFactory = ({ throw new NotFoundError({ message: "Template not found" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - template.orgId, + orgId: template.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -316,13 +321,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TListIdentityAuthTemplatesDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -352,13 +358,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TGetTemplatesByAuthMethodDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -385,13 +392,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TFindTemplateUsagesDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate @@ -415,13 +423,14 @@ export const identityAuthTemplateServiceFactory = ({ actorOrgId }: TUnlinkTemplateUsageDTO) => { await $checkPlan(actorOrgId); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-dal.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-dal.ts deleted file mode 100644 index a7d8794a4..000000000 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-dal.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TIdentityProjectAdditionalPrivilegeV2DALFactory = ReturnType< - typeof identityProjectAdditionalPrivilegeV2DALFactory ->; - -export const identityProjectAdditionalPrivilegeV2DALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege); - return orm; -}; diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts deleted file mode 100644 index 27b67367e..000000000 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { ForbiddenError, subject } from "@casl/ability"; -import { packRules } from "@casl/ability/extra"; - -import { ActionProjectType, TableName } from "@app/db/schemas"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; -import { ms } from "@app/lib/ms"; -import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; -import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; -import { ActorType } from "@app/services/auth/auth-type"; -import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; - -import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; -import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "../permission/project-permission"; -import { TIdentityProjectAdditionalPrivilegeV2DALFactory } from "./identity-project-additional-privilege-v2-dal"; -import { - IdentityProjectAdditionalPrivilegeTemporaryMode, - TCreateIdentityPrivilegeDTO, - TDeleteIdentityPrivilegeByIdDTO, - TGetIdentityPrivilegeDetailsByIdDTO, - TGetIdentityPrivilegeDetailsBySlugDTO, - TListIdentityPrivilegesDTO, - TUpdateIdentityPrivilegeByIdDTO -} from "./identity-project-additional-privilege-v2-types"; - -type TIdentityProjectAdditionalPrivilegeV2ServiceFactoryDep = { - identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeV2DALFactory; - identityProjectDAL: Pick; - projectDAL: Pick; - permissionService: Pick; -}; - -export type TIdentityProjectAdditionalPrivilegeV2ServiceFactory = ReturnType< - typeof identityProjectAdditionalPrivilegeV2ServiceFactory ->; - -export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({ - identityProjectAdditionalPrivilegeDAL, - identityProjectDAL, - projectDAL, - permissionService -}: TIdentityProjectAdditionalPrivilegeV2ServiceFactoryDep) => { - const create = async ({ - slug, - actor, - actorId, - projectId, - actorOrgId, - identityId, - permissions: customPermission, - actorAuthMethod, - ...dto - }: TCreateIdentityPrivilegeDTO) => { - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - const { permission: targetIdentityPermission, membership } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - // we need to validate that the privilege given is not higher than the assigning users permission - // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules - targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - targetIdentityPermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged identity", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - validateHandlebarTemplate("Identity Additional Privilege Create", JSON.stringify(customPermission || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug, - projectMembershipId: identityProjectMembership.id - }); - if (existingSlug) throw new BadRequestError({ message: "Additional privilege with provided slug already exists" }); - - const packedPermission = JSON.stringify(packRules(customPermission)); - if (!dto.isTemporary) { - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ - projectMembershipId: identityProjectMembership.id, - slug, - permissions: packedPermission - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - } - - const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ - projectMembershipId: identityProjectMembership.id, - slug, - permissions: packedPermission, - isTemporary: true, - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative, - temporaryRange: dto.temporaryRange, - temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - }; - - const updateById = async ({ - id, - data, - actorOrgId, - actor, - actorId, - actorAuthMethod - }: TUpdateIdentityPrivilegeByIdDTO) => { - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(id); - if (!identityPrivilege) throw new NotFoundError({ message: `Identity privilege with ${id} not found` }); - - const identityProjectMembership = await identityProjectDAL.findOne({ id: identityPrivilege.projectMembershipId }); - if (!identityProjectMembership) - throw new NotFoundError({ - message: `Failed to find identity with membership ${identityPrivilege.projectMembershipId}` - }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) - ); - const { permission: targetIdentityPermission, membership } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityProjectMembership.identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - // we need to validate that the privilege given is not higher than the assigning users permission - // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules - targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - targetIdentityPermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged identity", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - validateHandlebarTemplate("Identity Additional Privilege Update", JSON.stringify(data.permissions || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - if (data?.slug) { - const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug: data.slug, - projectMembershipId: identityProjectMembership.id - }); - if (existingSlug && existingSlug.id !== identityPrivilege.id) - throw new BadRequestError({ message: "Additional privilege with provided slug already exists" }); - } - - const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; - const packedPermission = data.permissions ? JSON.stringify(packRules(data.permissions)) : undefined; - if (isTemporary) { - const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; - const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange; - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { - slug: data.slug, - permissions: packedPermission, - isTemporary: data.isTemporary, - temporaryRange: data.temporaryRange, - temporaryMode: data.temporaryMode, - temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), - temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - } - - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { - slug: data.slug, - permissions: packedPermission, - isTemporary: false, - temporaryAccessStartTime: null, - temporaryAccessEndTime: null, - temporaryRange: null, - temporaryMode: null - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - }; - - const deleteById = async ({ actorId, id, actor, actorOrgId, actorAuthMethod }: TDeleteIdentityPrivilegeByIdDTO) => { - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(id); - if (!identityPrivilege) throw new NotFoundError({ message: `Identity privilege with ${id} not found` }); - - const identityProjectMembership = await identityProjectDAL.findOne({ id: identityPrivilege.projectMembershipId }); - if (!identityProjectMembership) - throw new NotFoundError({ - message: `Failed to find identity with membership ${identityPrivilege.projectMembershipId}` - }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) - ); - const { permission: identityRolePermission } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityProjectMembership.identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - identityRolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged identity", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...deletedPrivilege, - permissions: unpackPermissions(deletedPrivilege.permissions) - }; - }; - - const getPrivilegeDetailsById = async ({ - id, - actorOrgId, - actor, - actorId, - actorAuthMethod - }: TGetIdentityPrivilegeDetailsByIdDTO) => { - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(id); - if (!identityPrivilege) throw new NotFoundError({ message: `Identity privilege with ${id} not found` }); - - const identityProjectMembership = await identityProjectDAL.findOne({ id: identityPrivilege.projectMembershipId }); - if (!identityProjectMembership) - throw new NotFoundError({ - message: `Failed to find identity with membership ${identityPrivilege.projectMembershipId}` - }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Read, - subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) - ); - - return { - ...identityPrivilege, - permissions: unpackPermissions(identityPrivilege.permissions) - }; - }; - - const getPrivilegeDetailsBySlug = async ({ - identityId, - slug, - projectSlug, - actorOrgId, - actor, - actorId, - actorAuthMethod - }: TGetIdentityPrivilegeDetailsBySlugDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug ${slug} not found` }); - const projectId = project.id; - - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Read, - subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) - ); - - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug, - projectMembershipId: identityProjectMembership.id - }); - if (!identityPrivilege) throw new NotFoundError({ message: "Identity additional privilege not found" }); - - return { - ...identityPrivilege, - permissions: unpackPermissions(identityPrivilege.permissions) - }; - }; - - const listIdentityProjectPrivileges = async ({ - identityId, - actorOrgId, - actor, - actorId, - actorAuthMethod, - projectId - }: TListIdentityPrivilegesDTO) => { - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Read, - subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId }) - ); - - const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find( - { - projectMembershipId: identityProjectMembership.id - }, - { sort: [[`${TableName.IdentityProjectAdditionalPrivilege}.slug` as "slug", "asc"]] } - ); - return identityPrivileges; - }; - - return { - getPrivilegeDetailsById, - getPrivilegeDetailsBySlug, - listIdentityProjectPrivileges, - create, - updateById, - deleteById - }; -}; diff --git a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-types.ts b/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-types.ts deleted file mode 100644 index aab6b8510..000000000 --- a/backend/src/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-types.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { TProjectPermission } from "@app/lib/types"; - -import { TProjectPermissionV2Schema } from "../permission/project-permission"; - -export enum IdentityProjectAdditionalPrivilegeTemporaryMode { - Relative = "relative" -} - -export type TCreateIdentityPrivilegeDTO = { - permissions: TProjectPermissionV2Schema[]; - identityId: string; - projectId: string; - slug: string; -} & ( - | { - isTemporary: false; - } - | { - isTemporary: true; - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - } -) & - Omit; - -export type TUpdateIdentityPrivilegeByIdDTO = { id: string } & Omit & { - data: Partial<{ - permissions: TProjectPermissionV2Schema[]; - slug: string; - isTemporary: boolean; - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - }>; - }; - -export type TDeleteIdentityPrivilegeByIdDTO = Omit & { - id: string; -}; - -export type TGetIdentityPrivilegeDetailsByIdDTO = Omit & { - id: string; -}; - -export type TListIdentityPrivilegesDTO = Omit & { - identityId: string; - projectId: string; -}; - -export type TGetIdentityPrivilegeDetailsBySlugDTO = Omit & { - slug: string; - identityId: string; - projectSlug: string; -}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts deleted file mode 100644 index 26252f2d1..000000000 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TIdentityProjectAdditionalPrivilegeDALFactory = ReturnType< - typeof identityProjectAdditionalPrivilegeDALFactory ->; - -export const identityProjectAdditionalPrivilegeDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege); - return orm; -}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts deleted file mode 100644 index ddba76920..000000000 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability"; -import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; - -import { ActionProjectType } from "@app/db/schemas"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; -import { ms } from "@app/lib/ms"; -import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; -import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; -import { ActorType } from "@app/services/auth/auth-type"; -import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; -import { TProjectDALFactory } from "@app/services/project/project-dal"; - -import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; -import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { - ProjectPermissionIdentityActions, - ProjectPermissionSet, - ProjectPermissionSub -} from "../permission/project-permission"; -import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; -import { - IdentityProjectAdditionalPrivilegeTemporaryMode, - TCreateIdentityPrivilegeDTO, - TDeleteIdentityPrivilegeDTO, - TGetIdentityPrivilegeDetailsDTO, - TListIdentityPrivilegesDTO, - TUpdateIdentityPrivilegeDTO -} from "./identity-project-additional-privilege-types"; - -type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = { - identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory; - identityProjectDAL: Pick; - projectDAL: Pick; - permissionService: Pick; -}; - -export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< - typeof identityProjectAdditionalPrivilegeServiceFactory ->; - -const unpackPermissions = (permissions: unknown) => - UnpackedPermissionSchema.array().parse( - unpackRules((permissions || []) as PackRule>>[]) - ); - -export const identityProjectAdditionalPrivilegeServiceFactory = ({ - identityProjectAdditionalPrivilegeDAL, - identityProjectDAL, - permissionService, - projectDAL -}: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => { - const create = async ({ - slug, - actor, - actorId, - identityId, - projectSlug, - permissions: customPermission, - actorOrgId, - actorAuthMethod, - ...dto - }: TCreateIdentityPrivilegeDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const projectId = project.id; - - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const { permission: targetIdentityPermission } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - // we need to validate that the privilege given is not higher than the assigning users permission - // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules - targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - targetIdentityPermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged identity", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug, - projectMembershipId: identityProjectMembership.id - }); - if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); - - validateHandlebarTemplate("Identity Additional Privilege Create", JSON.stringify(customPermission || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - const packedPermission = JSON.stringify(packRules(customPermission)); - if (!dto.isTemporary) { - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ - projectMembershipId: identityProjectMembership.id, - slug, - permissions: packedPermission - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - } - - const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ - projectMembershipId: identityProjectMembership.id, - slug, - permissions: packedPermission, - isTemporary: true, - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative, - temporaryRange: dto.temporaryRange, - temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - }; - - const updateBySlug = async ({ - projectSlug, - slug, - identityId, - data, - actorOrgId, - actor, - actorId, - actorAuthMethod - }: TUpdateIdentityPrivilegeDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const projectId = project.id; - - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const { permission: targetIdentityPermission } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityProjectMembership.identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - // we need to validate that the privilege given is not higher than the assigning users permission - // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules - targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - targetIdentityPermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged identity", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug, - projectMembershipId: identityProjectMembership.id - }); - if (!identityPrivilege) { - throw new NotFoundError({ - message: `Identity additional privilege with slug '${slug}' not found for the specified identity with ID '${identityProjectMembership.identityId}'` - }); - } - if (data?.slug) { - const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug: data.slug, - projectMembershipId: identityProjectMembership.id - }); - if (existingSlug && existingSlug.id !== identityPrivilege.id) - throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); - } - - const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; - validateHandlebarTemplate("Identity Additional Privilege Update", JSON.stringify(data.permissions || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - const packedPermission = data.permissions ? JSON.stringify(packRules(data.permissions)) : undefined; - if (isTemporary) { - const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; - const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange; - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { - slug: data.slug, - permissions: packedPermission, - isTemporary: data.isTemporary, - temporaryRange: data.temporaryRange, - temporaryMode: data.temporaryMode, - temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), - temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - } - - const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { - slug: data.slug, - permissions: packedPermission, - isTemporary: false, - temporaryAccessStartTime: null, - temporaryAccessEndTime: null, - temporaryRange: null, - temporaryMode: null - }); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - }; - - const deleteBySlug = async ({ - actorId, - slug, - identityId, - projectSlug, - actor, - actorOrgId, - actorAuthMethod - }: TDeleteIdentityPrivilegeDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const projectId = project.id; - - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const { permission: identityRolePermission } = await permissionService.getProjectPermission({ - actor: ActorType.IDENTITY, - actorId: identityProjectMembership.identityId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - identityRolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to edit more privileged identity", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug, - projectMembershipId: identityProjectMembership.id - }); - if (!identityPrivilege) { - throw new NotFoundError({ - message: `Identity additional privilege with slug '${slug}' not found for the specified identity with ID '${identityProjectMembership.identityId}'` - }); - } - - const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); - - await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId); - - return { - ...deletedPrivilege, - permissions: unpackPermissions(deletedPrivilege.permissions) - }; - }; - - const getPrivilegeDetailsBySlug = async ({ - projectSlug, - identityId, - slug, - actorOrgId, - actor, - actorId, - actorAuthMethod - }: TGetIdentityPrivilegeDetailsDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const projectId = project.id; - - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Read, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug, - projectMembershipId: identityProjectMembership.id - }); - if (!identityPrivilege) { - throw new NotFoundError({ - message: `Identity additional privilege with slug '${slug}' not found for the specified identity with ID '${identityProjectMembership.identityId}'` - }); - } - return { - ...identityPrivilege, - permissions: unpackPermissions(identityPrivilege.permissions) - }; - }; - - const listIdentityProjectPrivileges = async ({ - identityId, - actorOrgId, - actor, - actorId, - actorAuthMethod, - projectSlug - }: TListIdentityPrivilegesDTO) => { - const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); - const projectId = project.id; - - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) - throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Read, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ - projectMembershipId: identityProjectMembership.id - }); - return identityPrivileges.map((el) => ({ - ...el, - permissions: unpackPermissions(el.permissions) - })); - }; - - return { - create, - updateBySlug, - deleteBySlug, - getPrivilegeDetailsBySlug, - listIdentityProjectPrivileges - }; -}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts deleted file mode 100644 index 6a0ecee5f..000000000 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { TProjectPermission } from "@app/lib/types"; - -import { TProjectPermissionV2Schema } from "../permission/project-permission"; - -export enum IdentityProjectAdditionalPrivilegeTemporaryMode { - Relative = "relative" -} - -export type TCreateIdentityPrivilegeDTO = { - permissions: TProjectPermissionV2Schema[]; - identityId: string; - projectSlug: string; - slug: string; -} & ( - | { - isTemporary: false; - } - | { - isTemporary: true; - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - } -) & - Omit; - -export type TUpdateIdentityPrivilegeDTO = { slug: string; identityId: string; projectSlug: string } & Omit< - TProjectPermission, - "projectId" -> & { - data: Partial<{ - permissions: TProjectPermissionV2Schema[]; - slug: string; - isTemporary: boolean; - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - }>; - }; - -export type TDeleteIdentityPrivilegeDTO = Omit & { - slug: string; - identityId: string; - projectSlug: string; -}; - -export type TGetIdentityPrivilegeDetailsDTO = Omit & { - slug: string; - identityId: string; - projectSlug: string; -}; - -export type TListIdentityPrivilegesDTO = Omit & { - identityId: string; - projectSlug: string; -}; diff --git a/backend/src/ee/services/kmip/kmip-operation-service.ts b/backend/src/ee/services/kmip/kmip-operation-service.ts index 2808108df..b3eace6bc 100644 --- a/backend/src/ee/services/kmip/kmip-operation-service.ts +++ b/backend/src/ee/services/kmip/kmip-operation-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -46,13 +47,14 @@ export const kmipOperationServiceFactory = ({ actorAuthMethod, actorOrgId }: TKmipCreateDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -78,13 +80,14 @@ export const kmipOperationServiceFactory = ({ }; const destroy = async ({ projectId, id, clientId, actor, actorId, actorOrgId, actorAuthMethod }: TKmipDestroyDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -131,13 +134,14 @@ export const kmipOperationServiceFactory = ({ }; const get = async ({ projectId, id, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipGetDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -183,18 +187,20 @@ export const kmipOperationServiceFactory = ({ algorithm: completeKeyDetails.internalKms.encryptionAlgorithm, isActive: !key.isDisabled, createdAt: key.createdAt, - updatedAt: key.updatedAt + updatedAt: key.updatedAt, + kmipMetadata: key.kmipMetadata as Record }; }; const activate = async ({ projectId, id, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipGetDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -225,13 +231,14 @@ export const kmipOperationServiceFactory = ({ }; const revoke = async ({ projectId, id, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipRevokeDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -286,13 +293,14 @@ export const kmipOperationServiceFactory = ({ actorAuthMethod, actorOrgId }: TKmipGetAttributesDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -338,13 +346,14 @@ export const kmipOperationServiceFactory = ({ }; const locate = async ({ projectId, clientId, actor, actorId, actorAuthMethod, actorOrgId }: TKmipLocateDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -373,15 +382,17 @@ export const kmipOperationServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + kmipMetadata }: TKmipRegisterDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); @@ -405,7 +416,8 @@ export const kmipOperationServiceFactory = ({ isReserved: false, projectId, keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, - orgId: project.orgId + orgId: project.orgId, + kmipMetadata }); return kmsKey; diff --git a/backend/src/ee/services/kmip/kmip-service.ts b/backend/src/ee/services/kmip/kmip-service.ts index 8daa5a37a..482eb41be 100644 --- a/backend/src/ee/services/kmip/kmip-service.ts +++ b/backend/src/ee/services/kmip/kmip-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { isValidIp } from "@app/lib/ip"; @@ -401,13 +401,14 @@ export const kmipServiceFactory = ({ }; const setupOrgKmip = async ({ caKeyAlgorithm, actorOrgId, actor, actorId, actorAuthMethod }: TSetupOrgKmipDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Setup, OrgPermissionSubjects.Kmip); const kmipConfig = await kmipOrgConfigDAL.findOne({ @@ -566,7 +567,14 @@ export const kmipServiceFactory = ({ }; const getOrgKmip = async ({ actorOrgId, actor, actorId, actorAuthMethod }: TGetOrgKmipDTO) => { - await permissionService.getOrgPermission(actor, actorId, actorOrgId, actorAuthMethod, actorOrgId); + await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); const kmipConfig = await kmipOrgConfigDAL.findOne({ orgId: actorOrgId @@ -759,13 +767,14 @@ export const kmipServiceFactory = ({ keyAlgorithm, hostnamesOrIps }: TRegisterServerDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionKmipActions.Proxy, OrgPermissionSubjects.Kmip); diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index 81d0d8766..695f46942 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -78,6 +78,7 @@ export type TKmipRegisterDTO = { name: string; key: string; algorithm: SymmetricKeyAlgorithm; + kmipMetadata?: Record | null; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 8643ecdac..86bfcc687 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -1,7 +1,14 @@ import { ForbiddenError } from "@casl/ability"; import { Knex } from "knex"; -import { OrgMembershipStatus, TableName, TLdapConfigsUpdate, TUsers } from "@app/db/schemas"; +import { + AccessScope, + OrganizationActionScope, + OrgMembershipStatus, + TableName, + TLdapConfigsUpdate, + TUsers +} from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -12,12 +19,12 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; -import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; @@ -49,13 +56,13 @@ import { TLdapGroupMapDALFactory } from "./ldap-group-map-dal"; type TLdapConfigServiceFactoryDep = { ldapConfigDAL: Pick; ldapGroupMapDAL: Pick; - orgMembershipDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; groupDAL: Pick; - groupProjectDAL: Pick; + membershipGroupDAL: Pick; + membershipRoleDAL: Pick; projectKeyDAL: Pick; projectDAL: Pick; projectBotDAL: Pick; @@ -87,9 +94,9 @@ export const ldapConfigServiceFactory = ({ ldapConfigDAL, ldapGroupMapDAL, orgDAL, - orgMembershipDAL, groupDAL, - groupProjectDAL, + membershipGroupDAL, + membershipRoleDAL, projectKeyDAL, projectDAL, projectBotDAL, @@ -119,7 +126,14 @@ export const ldapConfigServiceFactory = ({ groupSearchFilter, caCert }: TCreateLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -238,7 +252,14 @@ export const ldapConfigServiceFactory = ({ groupSearchFilter, caCert }: TUpdateLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -316,7 +337,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetLdapCfgDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); return getLdapCfg({ orgId @@ -388,25 +416,33 @@ export const ldapConfigServiceFactory = ({ await userDAL.transaction(async (tx) => { const [orgMembership] = await orgDAL.findMembership( { - [`${TableName.OrgMembership}.userId` as "userId"]: userAlias.userId, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: userAlias.userId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }, { tx } ); if (!orgMembership) { const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole); - await orgDAL.createMembership( + const membership = await orgDAL.createMembership( { - userId: userAlias.userId, - orgId, - role, - roleId, + actorUserId: userAlias.userId, + scopeOrgId: orgId, + scope: AccessScope.Organization, status: OrgMembershipStatus.Accepted, isActive: true }, tx ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role, + customRoleId: roleId + }, + tx + ); } else if (orgMembership.status === OrgMembershipStatus.Invited) { await orgDAL.updateMembershipById( orgMembership.id, @@ -459,8 +495,9 @@ export const ldapConfigServiceFactory = ({ const [orgMembership] = await orgDAL.findMembership( { - [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: newUserAlias.userId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }, { tx } ); @@ -469,16 +506,22 @@ export const ldapConfigServiceFactory = ({ await throwOnPlanSeatLimitReached(licenseService, orgId, UserAliasType.LDAP); const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole); - - await orgMembershipDAL.create( + const membership = await orgDAL.createMembership( { - userId: newUser.id, - inviteEmail: email.toLowerCase(), - orgId, - role, - roleId, + actorUserId: newUser.id, + scopeOrgId: orgId, + scope: AccessScope.Organization, status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later - isActive: true + isActive: true, + inviteEmail: email.toLowerCase() + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role, + customRoleId: roleId }, tx ); @@ -542,10 +585,10 @@ export const ldapConfigServiceFactory = ({ userDAL, userGroupMembershipDAL, orgDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, + membershipGroupDAL, tx }); } @@ -566,7 +609,7 @@ export const ldapConfigServiceFactory = ({ userIds: [newUser.id], userDAL, userGroupMembershipDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, tx }); @@ -634,7 +677,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetLdapGroupMapsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); const ldapConfig = await ldapConfigDAL.findOne({ @@ -663,7 +713,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateLdapGroupMapDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -717,7 +774,14 @@ export const ldapConfigServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteLdapGroupMapDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); @@ -756,7 +820,14 @@ export const ldapConfigServiceFactory = ({ caCert, url }: TTestLdapConnectionDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); const plan = await licenseService.getPlan(orgId); diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index f139ff2c1..d303859bb 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => { enterpriseSecretSyncs: false, enterpriseCertificateSyncs: false, enterpriseAppConnections: true, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-dal.ts b/backend/src/ee/services/license/license-dal.ts index cfea2573d..853f3a994 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { AccessScope, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; export type TLicenseDALFactory = ReturnType; @@ -9,15 +9,17 @@ export type TLicenseDALFactory = ReturnType; export const licenseDALFactory = (db: TDbClient) => { const countOfOrgMembers = async (orgId: string | null, tx?: Knex) => { try { - const doc = await (tx || db.replicaNode())(TableName.OrgMembership) - .where({ status: OrgMembershipStatus.Accepted }) + const doc = await (tx || db.replicaNode())(TableName.Membership) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) + .where({ status: OrgMembershipStatus.Accepted, scope: AccessScope.Organization }) .andWhere((bd) => { if (orgId) { - void bd.where({ orgId }); + void bd.where(`${TableName.Membership}.scopeOrgId`, orgId); } }) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) + .whereNull(`${TableName.Organization}.rootOrgId`) .count(); return Number(doc?.[0]?.count ?? 0); } catch (error) { @@ -25,27 +27,51 @@ export const licenseDALFactory = (db: TDbClient) => { } }; + const countOfOrgIdentities = async (orgId: string | null, tx?: Knex) => { + try { + // count org identities + const identityDoc = await (tx || db.replicaNode())(TableName.Identity) + .join(TableName.Organization, `${TableName.Identity}.orgId`, `${TableName.Organization}.id`) + .where((bd) => { + if (orgId) { + void bd.where(`${TableName.Organization}.rootOrgId`, orgId).orWhere(`${TableName.Organization}.id`, orgId); + } + }) + .count(); + + const identityCount = Number(identityDoc?.[0].count); + + return identityCount; + } catch (error) { + throw new DatabaseError({ error, name: "Count of Org Users + Identities" }); + } + }; + const countOrgUsersAndIdentities = async (orgId: string | null, tx?: Knex) => { try { // count org users - const userDoc = await (tx || db.replicaNode())(TableName.OrgMembership) - .where({ status: OrgMembershipStatus.Accepted }) + const userDoc = await (tx || db.replicaNode())(TableName.Membership) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) + .where({ status: OrgMembershipStatus.Accepted, scope: AccessScope.Organization }) + .whereNotNull(`${TableName.Membership}.actorUserId`) .andWhere((bd) => { if (orgId) { - void bd.where({ orgId }); + void bd.where(`${TableName.Membership}.scopeOrgId`, orgId); } }) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .where(`${TableName.Users}.isGhost`, false) + .whereNull(`${TableName.Organization}.rootOrgId`) .count(); const userCount = Number(userDoc?.[0].count); // count org identities - const identityDoc = await (tx || db.replicaNode())(TableName.IdentityOrgMembership) + const identityDoc = await (tx || db.replicaNode())(TableName.Identity) + .join(TableName.Organization, `${TableName.Identity}.orgId`, `${TableName.Organization}.id`) .where((bd) => { if (orgId) { - void bd.where({ orgId }); + void bd.where(`${TableName.Organization}.rootOrgId`, orgId).orWhere(`${TableName.Organization}.id`, orgId); } }) .count(); @@ -58,5 +84,5 @@ export const licenseDALFactory = (db: TDbClient) => { } }; - return { countOfOrgMembers, countOrgUsersAndIdentities }; + return { countOfOrgMembers, countOrgUsersAndIdentities, countOfOrgIdentities }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index cbe194f31..97061e3ca 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -28,6 +28,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ rbac: false, githubOrgSync: false, customRateLimits: false, + subOrganization: false, customAlerts: false, secretAccessInsights: false, auditLogs: false, @@ -66,7 +67,9 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enterpriseAppConnections: false, fips: false, eventSubscriptions: false, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, + pam: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 544eeae37..bbd6147ed 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -9,12 +9,12 @@ import { AxiosError } from "axios"; import { CronJob } from "cron"; import { Knex } from "knex"; +import { OrganizationActionScope } from "@app/db/schemas"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { getConfig } from "@app/lib/config/env"; +import { TEnvConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; -import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -40,16 +40,20 @@ import { TOrgPlanDTO, TOrgPlansTableDTO, TOrgPmtMethodsDTO, + TPlanBillingInfo, TStartOrgTrialDTO, TUpdateOrgBillingDetailsDTO } from "./license-types"; type TLicenseServiceFactoryDep = { - orgDAL: Pick; + envConfig: Pick< + TEnvConfig, + "LICENSE_SERVER_URL" | "LICENSE_SERVER_KEY" | "LICENSE_KEY" | "LICENSE_KEY_OFFLINE" | "INTERNAL_REGION" | "SITE_URL" + >; + orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; keyStore: Pick; - identityOrgMembershipDAL: TIdentityOrgDALFactory; projectDAL: TProjectDALFactory; }; @@ -66,27 +70,26 @@ export const licenseServiceFactory = ({ permissionService, licenseDAL, keyStore, - identityOrgMembershipDAL, - projectDAL + projectDAL, + envConfig }: TLicenseServiceFactoryDep) => { let isValidLicense = false; let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); let selfHostedLicense: TOfflineLicense | null = null; - const appCfg = getConfig(); const licenseServerCloudApi = setupLicenseRequestWithStore( - appCfg.LICENSE_SERVER_URL || "", + envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_CLOUD_LOGIN, - appCfg.LICENSE_SERVER_KEY || "", - appCfg.INTERNAL_REGION + envConfig.LICENSE_SERVER_KEY || "", + envConfig.INTERNAL_REGION ); const licenseServerOnPremApi = setupLicenseRequestWithStore( - appCfg.LICENSE_SERVER_URL || "", + envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_ON_PREM_LOGIN, - appCfg.LICENSE_KEY || "", - appCfg.INTERNAL_REGION + envConfig.LICENSE_KEY || "", + envConfig.INTERNAL_REGION ); const syncLicenseKeyOnPremFeatures = async (shouldThrow: boolean = false) => { @@ -120,7 +123,7 @@ export const licenseServiceFactory = ({ const init = async () => { try { - if (appCfg.LICENSE_SERVER_KEY) { + if (envConfig.LICENSE_SERVER_KEY) { const token = await licenseServerCloudApi.refreshLicense(); if (token) instanceType = InstanceType.Cloud; logger.info(`Instance type: ${InstanceType.Cloud}`); @@ -128,7 +131,7 @@ export const licenseServiceFactory = ({ return; } - if (appCfg.LICENSE_KEY) { + if (envConfig.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicense(); if (token) { await syncLicenseKeyOnPremFeatures(true); @@ -139,10 +142,10 @@ export const licenseServiceFactory = ({ return; } - if (appCfg.LICENSE_KEY_OFFLINE) { + if (envConfig.LICENSE_KEY_OFFLINE) { let isValidOfflineLicense = true; const contents: TOfflineLicenseContents = JSON.parse( - Buffer.from(appCfg.LICENSE_KEY_OFFLINE, "base64").toString("utf8") + Buffer.from(envConfig.LICENSE_KEY_OFFLINE, "base64").toString("utf8") ); const isVerified = await verifyOfflineLicense(JSON.stringify(contents.license), contents.signature); @@ -181,7 +184,7 @@ export const licenseServiceFactory = ({ }; const initializeBackgroundSync = async () => { - if (appCfg.LICENSE_KEY) { + if (envConfig.LICENSE_KEY) { logger.info("Setting up background sync process for refresh onPremFeatures"); const job = new CronJob("*/10 * * * *", syncLicenseKeyOnPremFeatures); job.start(); @@ -199,19 +202,35 @@ export const licenseServiceFactory = ({ return JSON.parse(cachedPlan) as TFeatureSet; } - const org = await orgDAL.findOrgById(orgId); + const org = await orgDAL.findRootOrgDetails(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const rootOrgId = org.id; + const { data: { currentPlan } } = await licenseServerCloudApi.request.get<{ currentPlan: TFeatureSet }>( `/api/license-server/v1/customers/${org.customerId}/cloud-plan` ); - const workspacesUsed = await projectDAL.countOfOrgProjects(orgId); + const workspacesUsed = await projectDAL.countOfOrgProjects(rootOrgId); currentPlan.workspacesUsed = workspacesUsed; - const membersUsed = await licenseDAL.countOfOrgMembers(orgId); + const membersUsed = await licenseDAL.countOfOrgMembers(rootOrgId); currentPlan.membersUsed = membersUsed; - const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); + const identityUsed = await licenseDAL.countOrgUsersAndIdentities(rootOrgId); + + if (currentPlan?.identitiesUsed && currentPlan.identitiesUsed !== identityUsed) { + try { + await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { + quantity: membersUsed, + quantityIdentities: identityUsed + }); + } catch (error) { + logger.error( + error, + `Update seats used: encountered an error when updating plan for customer [customerId=${org.customerId}]` + ); + } + } currentPlan.identitiesUsed = identityUsed; await keyStore.setItemWithExpiry( @@ -270,19 +289,20 @@ export const licenseServiceFactory = ({ }; const updateSubscriptionOrgMemberCount = async (orgId: string, tx?: Knex) => { - if (instanceType === InstanceType.Cloud) { - const org = await orgDAL.findOrgById(orgId); - if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const org = await orgDAL.findRootOrgDetails(orgId, tx); + if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); - const quantity = await licenseDAL.countOfOrgMembers(orgId, tx); - const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(orgId, tx); + const rootOrgId = org.id; + if (instanceType === InstanceType.Cloud) { + const quantity = await licenseDAL.countOfOrgMembers(rootOrgId, tx); + const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(rootOrgId, tx); if (org?.customerId) { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { quantity, quantityIdentities }); } - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + await keyStore.deleteItem(FEATURE_CACHE_KEY(rootOrgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null, tx); const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null, tx); @@ -293,7 +313,7 @@ export const licenseServiceFactory = ({ usedIdentitySeats }); } - await refreshPlan(orgId); + await refreshPlan(rootOrgId); }; // below all are api calls @@ -305,7 +325,14 @@ export const licenseServiceFactory = ({ actorAuthMethod, billingCycle }: TOrgPlansTableDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` @@ -322,7 +349,14 @@ export const licenseServiceFactory = ({ projectId, refreshCache }: TOrgPlanDTO) => { - await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); if (refreshCache) { await refreshPlan(orgId); } @@ -338,13 +372,20 @@ export const licenseServiceFactory = ({ actorAuthMethod, success_url }: TStartOrgTrialDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -370,13 +411,20 @@ export const licenseServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateOrgPortalSession) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: "Organization not found" @@ -397,8 +445,8 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, { - success_url: `${appCfg.SITE_URL}/organization/billing`, - cancel_url: `${appCfg.SITE_URL}/organization/billing` + success_url: `${envConfig.SITE_URL}/organization/billing`, + cancel_url: `${envConfig.SITE_URL}/organization/billing` } ); @@ -411,28 +459,56 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, { - return_url: `${appCfg.SITE_URL}/organization/billing` + return_url: `${envConfig.SITE_URL}/organization/billing` } ); return { url }; }; + const getUsageMetrics = async (orgId: string) => { + const [orgMembersUsed, identityUsed, projectCount] = await Promise.all([ + orgDAL.countAllOrgMembers(orgId), + licenseDAL.countOfOrgIdentities(orgId), + projectDAL.countOfOrgProjects(orgId) + ]); + + return { + orgMembersUsed, + identityUsed, + projectCount, + totalIdentities: identityUsed + orgMembersUsed + }; + }; + const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); } if (instanceType === InstanceType.Cloud) { - const { data } = await licenseServerCloudApi.request.get( + const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` ); - return data; + const { identityUsed, orgMembersUsed } = await getUsageMetrics(orgId); + + return { + ...data, + users: orgMembersUsed, + identities: identityUsed + }; } return { @@ -441,7 +517,9 @@ export const licenseServiceFactory = ({ interval: "month", intervalCount: 1, amount: 0, - quantity: 1 + quantity: 1, + users: 0, + identities: 0 }; }; @@ -485,27 +563,19 @@ export const licenseServiceFactory = ({ throw new Error(`Unsupported instance type for server-based plan table: ${instanceType}`); }; - const getUsageMetrics = async (orgId: string) => { - const [orgMembersUsed, identityUsed, projectCount] = await Promise.all([ - orgDAL.countAllOrgMembers(orgId), - identityOrgMembershipDAL.countAllOrgIdentities({ orgId }), - projectDAL.countOfOrgProjects(orgId) - ]); - - return { - orgMembersUsed, - identityUsed, - projectCount, - totalIdentities: identityUsed + orgMembersUsed - }; - }; - // returns org current plan feature table const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -539,10 +609,17 @@ export const licenseServiceFactory = ({ }; const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -564,13 +641,20 @@ export const licenseServiceFactory = ({ name, email }: TUpdateOrgBillingDetailsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -587,10 +671,17 @@ export const licenseServiceFactory = ({ }; const getOrgPmtMethods = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPmtMethodsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -614,13 +705,20 @@ export const licenseServiceFactory = ({ success_url, cancel_url }: TAddOrgPmtMethodDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -646,13 +744,20 @@ export const licenseServiceFactory = ({ orgId, pmtMethodId }: TDelOrgPmtMethodDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -678,10 +783,17 @@ export const licenseServiceFactory = ({ }; const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -696,13 +808,20 @@ export const licenseServiceFactory = ({ }; const addOrgTaxId = async ({ actorId, actor, actorAuthMethod, actorOrgId, orgId, type, value }: TAddOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -720,13 +839,20 @@ export const licenseServiceFactory = ({ }; const delOrgTaxId = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId, taxId }: TDelOrgTaxIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing ); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -740,10 +866,17 @@ export const licenseServiceFactory = ({ }; const getOrgTaxInvoices = async ({ actorId, actor, actorOrgId, actorAuthMethod, orgId }: TOrgInvoiceDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -757,10 +890,17 @@ export const licenseServiceFactory = ({ }; const getOrgLicenses = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgLicensesDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actorId, + actor, + orgId, + actorOrgId, + actorAuthMethod, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); - const organization = await orgDAL.findOrgById(orgId); + const organization = await orgDAL.findById(orgId); if (!organization) { throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` @@ -805,7 +945,6 @@ export const licenseServiceFactory = ({ getLicenseId, invalidateGetPlan, updateSubscriptionOrgMemberCount, - refreshPlan, getOrgPlan, getOrgPlansTableByBillCycle, startOrgTrial, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 266794074..c4ff6a8fa 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -22,6 +22,15 @@ export type TOfflineLicense = { features: TFeatureSet; }; +export type TPlanBillingInfo = { + currentPeriodStart: number; + currentPeriodEnd: number; + interval: "month" | "year"; + intervalCount: number; + amount: number; + quantity: number; +}; + export type TFeatureSet = { _id: null; slug: string | null; @@ -33,6 +42,7 @@ export type TFeatureSet = { membersUsed: number; identityLimit: null; identitiesUsed: number; + subOrganization: false; environmentLimit: null; environmentsUsed: 0; secretVersioning: true; @@ -78,8 +88,10 @@ export type TFeatureSet = { enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; + pkiLegacyTemplates: false; fips: false; eventSubscriptions: false; + pam: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index 445ace2b5..e80ec7cf5 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; -import { OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; import { TOidcConfigsUpdate } from "@app/db/schemas/oidc-configs"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; @@ -19,12 +19,12 @@ import { OrgServiceActor } from "@app/lib/types"; import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; -import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; @@ -62,11 +62,12 @@ type TOidcConfigServiceFactoryDep = { TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; - orgMembershipDAL: Pick; + membershipGroupDAL: Pick; + membershipRoleDAL: Pick; licenseService: Pick; tokenService: Pick; smtpService: Pick; - permissionService: Pick; + permissionService: Pick; oidcConfigDAL: Pick; groupDAL: Pick; userGroupMembershipDAL: Pick< @@ -78,7 +79,6 @@ type TOidcConfigServiceFactoryDep = { | "delete" | "filterProjectsByUserMembership" >; - groupProjectDAL: Pick; projectKeyDAL: Pick; projectDAL: Pick; projectBotDAL: Pick; @@ -90,7 +90,6 @@ export type TOidcConfigServiceFactory = ReturnType { - await permissionService.getUserOrgPermission(actor.id, orgId, actor.authMethod, actor.orgId); + await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: actor.id, + orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.ParentOrganization + }); const oidcConfig = await oidcConfigDAL.findOne({ orgId, diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts new file mode 100644 index 000000000..6ef7df76e --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -0,0 +1,63 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPamAccounts } from "@app/db/schemas"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +export type TPamAccountDALFactory = ReturnType; + +type PamAccountFindFilter = Parameters>[0]; + +export const pamAccountDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamAccount); + + const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => { + const query = (tx || db.replicaNode())(TableName.PamAccount) + .leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .select(selectAllTableCols(TableName.PamAccount)) + .select( + // resource + db.ref("name").withSchema(TableName.PamResource).as("resourceName"), + db.ref("resourceType").withSchema(TableName.PamResource), + db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource) + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter))); + } + + const accounts = await query; + + return accounts.map( + ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({ + ...account, + resourceId, + resource: { + id: resourceId, + name: resourceName, + resourceType, + encryptedRotationAccountCredentials + } + }) + ); + }; + + const findAccountsDueForRotation = async (tx?: Knex) => { + const dbClient = tx || db.replicaNode(); + + const accounts = await dbClient(TableName.PamAccount) + .innerJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .whereNotNull(`${TableName.PamResource}.encryptedRotationAccountCredentials`) + .whereNotNull(`${TableName.PamAccount}.rotationIntervalSeconds`) + .where(`${TableName.PamAccount}.rotationEnabled`, true) + .whereRaw( + `COALESCE("${TableName.PamAccount}"."lastRotatedAt", "${TableName.PamAccount}"."createdAt") + "${TableName.PamAccount}"."rotationIntervalSeconds" * interval '1 second' < NOW()` + ) + .select(selectAllTableCols(TableName.PamAccount)); + + return accounts; + }; + + return { ...orm, findWithResourceDetails, findAccountsDueForRotation }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts new file mode 100644 index 000000000..fdc440991 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -0,0 +1,61 @@ +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; + +export const encryptAccountCredentials = async ({ + projectId, + credentials, + kmsService +}: { + projectId: string; + credentials: TPamAccountCredentials; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAccountCredentials = async ({ + projectId, + encryptedCredentials, + kmsService +}: { + projectId: string; + encryptedCredentials: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; +}; + +export const decryptAccount = async ( + account: T, + projectId: string, + kmsService: Pick +): Promise => { + return { + ...account, + credentials: await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId, + kmsService + }) + } as T & { credentials: TPamAccountCredentials }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts new file mode 100644 index 000000000..fd3615013 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -0,0 +1,684 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; +import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionActions, + ProjectPermissionPamAccountActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; +import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; +import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; +import { PamResource } from "../pam-resource/pam-resource-enums"; +import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; +import { TPamSessionDALFactory } from "../pam-session/pam-session-dal"; +import { PamSessionStatus } from "../pam-session/pam-session-enums"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPamAccountDALFactory } from "./pam-account-dal"; +import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns"; +import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types"; + +type TPamAccountServiceFactoryDep = { + pamResourceDAL: TPamResourceDALFactory; + pamSessionDAL: TPamSessionDALFactory; + pamAccountDAL: TPamAccountDALFactory; + pamFolderDAL: TPamFolderDALFactory; + projectDAL: TProjectDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + gatewayV2Service: Pick< + TGatewayV2ServiceFactory, + "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" + >; + userDAL: TUserDALFactory; + auditLogService: Pick; +}; +export type TPamAccountServiceFactory = ReturnType; + +const ROTATION_CONCURRENCY_LIMIT = 10; + +export const pamAccountServiceFactory = ({ + pamResourceDAL, + pamSessionDAL, + pamAccountDAL, + pamFolderDAL, + projectDAL, + userDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service, + auditLogService +}: TPamAccountServiceFactoryDep) => { + const create = async ( + { + credentials, + resourceId, + name, + description, + folderId, + rotationEnabled, + rotationIntervalSeconds + }: TCreateAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + if (rotationEnabled && (rotationIntervalSeconds === undefined || rotationIntervalSeconds === null)) { + throw new BadRequestError({ + message: "Rotation interval must be defined when rotation is enabled." + }); + } + + const resource = await pamResourceDAL.findById(resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId, + projectId: resource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Create, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: name, + accountPath + }) + ); + + const connectionDetails = await decryptResourceConnectionDetails({ + projectId: resource.projectId, + encryptedConnectionDetails: resource.encryptedConnectionDetails, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + const validatedCredentials = await factory.validateAccountCredentials(credentials); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + projectId: resource.projectId, + kmsService + }); + + try { + const account = await pamAccountDAL.create({ + projectId: resource.projectId, + resourceId: resource.id, + encryptedCredentials, + name, + description, + folderId, + rotationEnabled, + rotationIntervalSeconds + }); + + return { + ...(await decryptAccount(account, resource.projectId, kmsService)), + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } + }; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Account with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const updateById = async ( + { accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const account = await pamAccountDAL.findById(accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Edit, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (description !== undefined) { + updateDoc.description = description; + } + + if (rotationEnabled !== undefined) { + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + updateDoc.rotationEnabled = rotationEnabled; + } + + if (rotationIntervalSeconds !== undefined) { + updateDoc.rotationIntervalSeconds = rotationIntervalSeconds; + } + + if (credentials !== undefined) { + const connectionDetails = await decryptResourceConnectionDetails({ + projectId: account.projectId, + encryptedConnectionDetails: resource.encryptedConnectionDetails, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...credentials }; + if (credentials.password === "__INFISICAL_UNCHANGED__") { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + const validatedCredentials = await factory.validateAccountCredentials(finalCredentials); + const encryptedCredentials = await encryptAccountCredentials({ + credentials: validatedCredentials, + projectId: account.projectId, + kmsService + }); + updateDoc.encryptedCredentials = encryptedCredentials; + } + + // If nothing was updated, return the fetched account + if (Object.keys(updateDoc).length === 0) { + return decryptAccount(account, account.projectId, kmsService); + } + + const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); + + return { + ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } + }; + }; + + const deleteById = async (id: string, actor: OrgServiceActor) => { + const account = await pamAccountDAL.findById(id); + if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Delete, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const deletedAccount = await pamAccountDAL.deleteById(id); + + return { + ...(await decryptAccount(deletedAccount, account.projectId, kmsService)), + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } + }; + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId }); + + const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders); + + const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : []; + + const decryptedAndPermittedAccounts: Array< + TPamAccounts & { + resource: Pick & { rotationCredentialsConfigured: boolean }; + credentials: TPamAccountCredentials; + } + > = []; + + for await (const account of accountsWithResourceDetails) { + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + // Check permission for each individual account + if ( + permission.can( + ProjectPermissionPamAccountActions.Read, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: account.resource.name, + accountName: account.name, + accountPath + }) + ) + ) { + // Decrypt the account only if the user has permission to read it + const decryptedAccount = await decryptAccount(account, account.projectId, kmsService); + decryptedAndPermittedAccounts.push({ + ...decryptedAccount, + resource: { + id: account.resource.id, + name: account.resource.name, + resourceType: account.resource.resourceType, + rotationCredentialsConfigured: !!account.resource.encryptedRotationAccountCredentials + } + }); + } + } + + return { + accounts: decryptedAndPermittedAccounts, + folders + }; + }; + + const access = async ( + { accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const account = await pamAccountDAL.findById(accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + const accountPath = await getFullPamFolderPath({ + pamFolderDAL, + folderId: account.folderId, + projectId: account.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Access, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath + }) + ); + + const session = await pamSessionDAL.create({ + accountName: account.name, + actorEmail, + actorIp, + actorName, + actorUserAgent, + projectId: account.projectId, + resourceName: resource.name, + resourceType: resource.resourceType, + status: PamSessionStatus.Starting, + accountId: account.id, + userId: actor.id, + expiresAt: new Date(Date.now() + duration) + }); + + const { connectionDetails, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + const user = await userDAL.findById(actor.id); + if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); + + const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ + gatewayId, + duration, + sessionId: session.id, + resourceType: resource.resourceType as PamResource, + host: connectionDetails.host, + port: connectionDetails.port, + actorMetadata: { + id: actor.id, + type: actor.type, + name: user.email ?? "" + } + }); + + if (!gatewayConnectionDetails) { + throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); + } + + return { + sessionId: session.id, + resourceType, + relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate, + relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey, + relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain, + gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate, + gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey, + gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, + relayHost: gatewayConnectionDetails.relayHost, + projectId: account.projectId, + account + }; + }; + + const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + // To be hit by gateways only + if (actor.type !== ActorType.IDENTITY) { + throw new ForbiddenRequestError({ message: "Only gateways can perform this action" }); + } + + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: project.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" }); + + // Verify that the session has not ended + if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) { + throw new BadRequestError({ message: "Session has ended or expired" }); + } + + // Verify that the session has not already had credentials fetched + if (session.status !== PamSessionStatus.Starting) { + throw new BadRequestError({ message: "Session has already been started" }); + } + + const account = await pamAccountDAL.findById(session.accountId); + if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` }); + + const resource = await pamResourceDAL.findById(account.resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); + + if (resource.gatewayIdentityId !== actor.id) { + throw new ForbiddenRequestError({ + message: "Identity does not have access to fetch the PAM session credentials" + }); + } + + const decryptedAccount = await decryptAccount(account, session.projectId, kmsService); + + const decryptedResource = await decryptResource(resource, session.projectId, kmsService); + + // Mark session as started + await pamSessionDAL.updateById(sessionId, { + status: PamSessionStatus.Active, + startedAt: new Date() + }); + + return { + credentials: { + ...decryptedResource.connectionDetails, + ...decryptedAccount.credentials + }, + projectId: project.id, + account + }; + }; + + const rotateAllDueAccounts = async () => { + const accounts = await pamAccountDAL.findAccountsDueForRotation(); + + for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) { + const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); + + const rotationPromises = batch.map(async (account) => + pamAccountDAL.transaction(async (tx) => { + let logResourceType = "unknown"; + try { + const resource = await pamResourceDAL.findById(account.resourceId, tx); + if (!resource || !resource.encryptedRotationAccountCredentials) return; + logResourceType = resource.resourceType; + + const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + if (!rotationAccountCredentials) return; + + const accountCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resourceType as PamResource]( + resourceType as PamResource, + connectionDetails, + gatewayId, + gatewayV2Service + ); + + const newCredentials = await factory.rotateAccountCredentials( + rotationAccountCredentials, + accountCredentials + ); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: newCredentials, + projectId: account.projectId, + kmsService + }); + + await pamAccountDAL.updateById( + account.id, + { + encryptedCredentials, + lastRotatedAt: new Date() + }, + tx + ); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: resource.id, + resourceType: logResourceType + } + } + }); + } catch (error) { + logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); + + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: account.resourceId, + resourceType: logResourceType, + errorMessage + } + } + }); + throw error; // Rollback transaction + } + }) + ); + + // eslint-disable-next-line no-await-in-loop + await Promise.all(rotationPromises); + } + }; + + return { + create, + updateById, + deleteById, + list, + access, + getSessionCredentials, + rotateAllDueAccounts + }; +}; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts new file mode 100644 index 000000000..4bbccc6fa --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -0,0 +1,20 @@ +import { TPamAccount } from "../pam-resource/pam-resource-types"; + +// DTOs +export type TCreateAccountDTO = Pick< + TPamAccount, + "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" +>; + +export type TUpdateAccountDTO = Partial> & { + accountId: string; +}; + +export type TAccessAccountDTO = { + accountId: string; + actorEmail: string; + actorIp: string; + actorName: string; + actorUserAgent: string; + duration: number; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-dal.ts b/backend/src/ee/services/pam-folder/pam-folder-dal.ts new file mode 100644 index 000000000..aa334618d --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-dal.ts @@ -0,0 +1,9 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TPamFolderDALFactory = ReturnType; +export const pamFolderDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamFolder); + return { ...orm }; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-fns.ts b/backend/src/ee/services/pam-folder/pam-folder-fns.ts new file mode 100644 index 000000000..bcba0ab3b --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-fns.ts @@ -0,0 +1,33 @@ +import { TPamFolderDALFactory } from "./pam-folder-dal"; + +type GetFullFolderPath = { + pamFolderDAL: Pick; + folderId?: string | null; + projectId: string; +}; + +export const getFullPamFolderPath = async ({ + pamFolderDAL, + folderId, + projectId +}: GetFullFolderPath): Promise => { + if (!folderId) return "/"; + + const folders = await pamFolderDAL.find({ projectId }); + const folderMap = new Map(folders.map((folder) => [folder.id, folder])); + + if (!folderMap.has(folderId)) return ""; + + const path: string[] = []; + let currentFolderId: string | null | undefined = folderId; + + while (currentFolderId) { + const folder = folderMap.get(currentFolderId); + if (!folder) break; + + path.unshift(folder.name); + currentFolderId = folder.parentId; + } + + return `/${path.join("/")}`; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-service.ts b/backend/src/ee/services/pam-folder/pam-folder-service.ts new file mode 100644 index 000000000..d7fb41f12 --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-service.ts @@ -0,0 +1,146 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType, TPamFolders } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { TPamFolderDALFactory } from "./pam-folder-dal"; +import { TCreateFolderDTO, TUpdateFolderDTO } from "./pam-folder-types"; + +type TPamFolderServiceFactoryDep = { + pamFolderDAL: TPamFolderDALFactory; + permissionService: Pick; + licenseService: Pick; +}; + +export type TPamFolderServiceFactory = ReturnType; + +export const pamFolderServiceFactory = ({ + pamFolderDAL, + permissionService, + licenseService +}: TPamFolderServiceFactoryDep) => { + const createFolder = async ({ name, description, parentId, projectId }: TCreateFolderDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamFolders); + + if (parentId) { + if (!(await pamFolderDAL.findOne({ id: parentId, projectId }))) { + throw new NotFoundError({ + message: `Parent folder '${parentId}' not found for project '${projectId}'` + }); + } + } + + try { + const folder = await pamFolderDAL.create({ + name, + description: description ?? null, + parentId: parentId || null, + projectId + }); + + return folder; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const folder = await pamFolderDAL.findById(id); + if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: folder.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamFolders); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (description !== undefined) { + updateDoc.description = description; + } + + if (Object.keys(updateDoc).length === 0) { + return folder; + } + + try { + const updatedFolder = await pamFolderDAL.updateById(id, updateDoc); + + return updatedFolder; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Folder with name '${name}' already exists for this path` + }); + } + + throw err; + } + }; + + const deleteFolder = async (id: string, actor: OrgServiceActor) => { + const folder = await pamFolderDAL.findById(id); + if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: folder.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamFolders); + + const deletedFolder = await pamFolderDAL.deleteById(id); + + return deletedFolder; + }; + + return { createFolder, updateFolder, deleteFolder }; +}; diff --git a/backend/src/ee/services/pam-folder/pam-folder-types.ts b/backend/src/ee/services/pam-folder/pam-folder-types.ts new file mode 100644 index 000000000..c8a435637 --- /dev/null +++ b/backend/src/ee/services/pam-folder/pam-folder-types.ts @@ -0,0 +1,13 @@ +// DTOs +export interface TCreateFolderDTO { + projectId: string; + parentId?: string | null; + name: string; + description?: string | null; +} + +export interface TUpdateFolderDTO { + id: string; + name?: string; + description?: string | null; +} diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts new file mode 100644 index 000000000..4010d208b --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts @@ -0,0 +1,8 @@ +import { MySQLResourceListItemSchema } from "./mysql-resource-schemas"; + +export const getMySQLResourceListItem = () => { + return { + name: MySQLResourceListItemSchema.shape.name.value, + resource: MySQLResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts new file mode 100644 index 000000000..8d3589a8a --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +// Resources +export const MySQLResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema.extend({ + // MySQL db in many cases the db will not be provided when making connection + database: z.string().trim() +}); +export const MySQLAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +const BaseMySQLResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.MySQL) }); + +export const MySQLResourceSchema = BaseMySQLResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedMySQLResourceSchema = BaseMySQLResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() +}); + +export const MySQLResourceListItemSchema = z.object({ + name: z.literal("MySQL"), + resource: z.literal(PamResource.MySQL) +}); + +export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const MySQLAccountSchema = BasePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema +}); + +export const CreateMySQLAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema +}); + +export const UpdateMySQLAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema.optional() +}); + +export const SanitizedMySQLAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: MySQLAccountCredentialsSchema.pick({ + username: true + }) +}); + +// Sessions +export const MySQLSessionCredentialsSchema = MySQLResourceConnectionDetailsSchema.and(MySQLAccountCredentialsSchema); diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts new file mode 100644 index 000000000..43c2eea51 --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + MySQLAccountCredentialsSchema, + MySQLAccountSchema, + MySQLResourceConnectionDetailsSchema, + MySQLResourceSchema +} from "./mysql-resource-schemas"; + +// Resources +export type TMySQLResource = z.infer; +export type TMySQLResourceConnectionDetails = z.infer; + +// Accounts +export type TMySQLAccount = z.infer; +export type TMySQLAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/pam-resource-dal.ts b/backend/src/ee/services/pam-resource/pam-resource-dal.ts new file mode 100644 index 000000000..1a408ca27 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-dal.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPamResourceDALFactory = ReturnType; +export const pamResourceDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamResource); + + const findById = async (id: string, tx?: Knex) => { + const doc = await (tx || db.replicaNode())(TableName.PamResource) + .join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) + .select(selectAllTableCols(TableName.PamResource)) + .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) + .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) + .where(`${TableName.PamResource}.id`, id) + .first(); + + return doc; + }; + + return { ...orm, findById }; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts new file mode 100644 index 000000000..dff1cc650 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -0,0 +1,4 @@ +export enum PamResource { + Postgres = "postgres", + MySQL = "mysql" +} diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts new file mode 100644 index 000000000..151fa7ea1 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -0,0 +1,10 @@ +import { PamResource } from "./pam-resource-enums"; +import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; +import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; + +type TPamResourceFactoryImplementation = TPamResourceFactory; + +export const PAM_RESOURCE_FACTORY_MAP: Record = { + [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, + [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts new file mode 100644 index 000000000..cad087d2f --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -0,0 +1,77 @@ +import { TPamResources } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns"; +import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; +import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; + +export const listResourceOptions = () => { + return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); +}; + +// Resource +export const encryptResourceConnectionDetails = async ({ + projectId, + connectionDetails, + kmsService +}: { + projectId: string; + connectionDetails: TPamResourceConnectionDetails; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(connectionDetails)) + }); + + return encryptedConnectionDetailsBlob; +}; + +export const decryptResourceConnectionDetails = async ({ + projectId, + encryptedConnectionDetails, + kmsService +}: { + projectId: string; + encryptedConnectionDetails: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedConnectionDetails + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails; +}; + +export const decryptResource = async ( + resource: TPamResources, + projectId: string, + kmsService: Pick +) => { + return { + ...resource, + connectionDetails: await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + projectId, + kmsService + }), + rotationAccountCredentials: resource.encryptedRotationAccountCredentials + ? await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId, + kmsService + }) + : null + } as TPamResource; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts new file mode 100644 index 000000000..7f6165d88 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas"; +import { slugSchema } from "@app/server/lib/schemas"; + +// Resources +export const BasePamResourceSchema = PamResourcesSchema.omit({ + encryptedConnectionDetails: true, + encryptedRotationAccountCredentials: true, + resourceType: true +}); + +export const BaseCreatePamResourceSchema = z.object({ + projectId: z.string().uuid(), + gatewayId: z.string().uuid(), + name: slugSchema({ field: "name" }) +}); + +export const BaseUpdatePamResourceSchema = z.object({ + gatewayId: z.string().uuid().optional(), + name: slugSchema({ field: "name" }).optional() +}); + +// Accounts +export const BasePamAccountSchema = PamAccountsSchema.omit({ + encryptedCredentials: true +}); + +export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ + resource: PamResourcesSchema.pick({ + id: true, + name: true, + resourceType: true + }).extend({ + rotationCredentialsConfigured: z.boolean() + }) +}); + +export const BaseCreatePamAccountSchema = z.object({ + resourceId: z.string().uuid(), + folderId: z.string().uuid().optional(), + name: slugSchema({ field: "name" }), + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean(), + rotationIntervalSeconds: z.number().min(3600).nullable().optional() +}); + +export const BaseUpdatePamAccountSchema = z.object({ + name: slugSchema({ field: "name" }).optional(), + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean().optional(), + rotationIntervalSeconds: z.number().min(3600).nullable().optional() +}); diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts new file mode 100644 index 000000000..d97905dbe --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -0,0 +1,298 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType, TPamResources } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { decryptAccountCredentials, encryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { TPamResourceDALFactory } from "./pam-resource-dal"; +import { PamResource } from "./pam-resource-enums"; +import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; +import { + decryptResource, + decryptResourceConnectionDetails, + encryptResourceConnectionDetails, + listResourceOptions +} from "./pam-resource-fns"; +import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; + +type TPamResourceServiceFactoryDep = { + pamResourceDAL: TPamResourceDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; + gatewayV2Service: Pick< + TGatewayV2ServiceFactory, + "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" + >; +}; + +export type TPamResourceServiceFactory = ReturnType; + +export const pamResourceServiceFactory = ({ + pamResourceDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service +}: TPamResourceServiceFactoryDep) => { + const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => { + const resource = await pamResourceDAL.findById(id); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); + + if (resource.resourceType !== resourceType) { + throw new BadRequestError({ + message: `Resource with ID '${id}' is not of type '${resourceType}'` + }); + } + + return decryptResource(resource, resource.projectId, kmsService); + }; + + const create = async ( + { resourceType, connectionDetails, gatewayId, name, projectId, rotationAccountCredentials }: TCreateResourceDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources); + + const factory = PAM_RESOURCE_FACTORY_MAP[resourceType]( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service + ); + + const validatedConnectionDetails = await factory.validateConnection(); + const encryptedConnectionDetails = await encryptResourceConnectionDetails({ + connectionDetails: validatedConnectionDetails, + projectId, + kmsService + }); + + let encryptedRotationAccountCredentials: Buffer | null = null; + + if (rotationAccountCredentials) { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(rotationAccountCredentials); + + encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId, + kmsService + }); + } + + const resource = await pamResourceDAL.create({ + resourceType, + encryptedConnectionDetails, + gatewayId, + name, + projectId, + encryptedRotationAccountCredentials + }); + + return decryptResource(resource, projectId, kmsService); + }; + + const updateById = async ( + { connectionDetails, resourceId, name, rotationAccountCredentials }: TUpdateResourceDTO, + actor: OrgServiceActor + ) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + const resource = await pamResourceDAL.findById(resourceId); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources); + + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (connectionDetails !== undefined) { + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + connectionDetails, + resource.gatewayId, + gatewayV2Service + ); + const validatedConnectionDetails = await factory.validateConnection(); + const encryptedConnectionDetails = await encryptResourceConnectionDetails({ + connectionDetails: validatedConnectionDetails, + projectId: resource.projectId, + kmsService + }); + updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; + } + + if (rotationAccountCredentials !== undefined) { + updateDoc.encryptedRotationAccountCredentials = null; + + if (rotationAccountCredentials) { + const decryptedConnectionDetails = + connectionDetails ?? + (await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + projectId: resource.projectId, + kmsService + })); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + decryptedConnectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...rotationAccountCredentials }; + if ( + resource.encryptedRotationAccountCredentials && + rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__" + ) { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + try { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials); + + updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + } catch (err) { + if (err instanceof BadRequestError) { + throw new BadRequestError({ + message: `Rotation Account Error: ${err.message}` + }); + } + + throw err; + } + } + } + + // If nothing was updated, return the fetched resource + if (Object.keys(updateDoc).length === 0) { + return decryptResource(resource, resource.projectId, kmsService); + } + + const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc); + + return decryptResource(updatedResource, resource.projectId, kmsService); + }; + + const deleteById = async (id: string, actor: OrgServiceActor) => { + const resource = await pamResourceDAL.findById(id); + if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: resource.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources); + + try { + const deletedResource = await pamResourceDAL.deleteById(id); + return await decryptResource(deletedResource, resource.projectId, kmsService); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation + ) { + throw new BadRequestError({ + message: "Failed to delete resource because it is attached to active PAM accounts" + }); + } + throw err; + } + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); + + const resources = await pamResourceDAL.find({ projectId }); + + return { + resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))) + }; + }; + + return { + getById, + create, + updateById, + deleteById, + list, + listResourceOptions + }; +}; diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts new file mode 100644 index 000000000..1ca9db3e2 --- /dev/null +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -0,0 +1,54 @@ +import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { + TMySQLAccount, + TMySQLAccountCredentials, + TMySQLResource, + TMySQLResourceConnectionDetails +} from "./mysql/mysql-resource-types"; +import { PamResource } from "./pam-resource-enums"; +import { + TPostgresAccount, + TPostgresAccountCredentials, + TPostgresResource, + TPostgresResourceConnectionDetails +} from "./postgres/postgres-resource-types"; + +// Resource types +export type TPamResource = TPostgresResource | TMySQLResource; +export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; + +// Account types +export type TPamAccount = TPostgresAccount | TMySQLAccount; +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; + +// Resource DTOs +export type TCreateResourceDTO = Pick< + TPamResource, + "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" +>; + +export type TUpdateResourceDTO = Partial> & { + resourceId: string; +}; + +// Resource factory +export type TPamResourceFactoryValidateConnection = () => Promise; +export type TPamResourceFactoryValidateAccountCredentials = ( + credentials: C +) => Promise; +export type TPamResourceFactoryRotateAccountCredentials = ( + rotationAccountCredentials: C, + currentCredentials: C +) => Promise; + +export type TPamResourceFactory = ( + resourceType: PamResource, + connectionDetails: T, + gatewayId: string, + gatewayV2Service: Pick +) => { + validateConnection: TPamResourceFactoryValidateConnection; + validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; + rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials; +}; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-fns.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-fns.ts new file mode 100644 index 000000000..a3329a9fb --- /dev/null +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-fns.ts @@ -0,0 +1,8 @@ +import { PostgresResourceListItemSchema } from "./postgres-resource-schemas"; + +export const getPostgresResourceListItem = () => { + return { + name: PostgresResourceListItemSchema.shape.name.value, + resource: PostgresResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts new file mode 100644 index 000000000..bbe83a3a4 --- /dev/null +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; +export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +// Resources +const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) }); + +export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedPostgresResourceSchema = BasePostgresResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() +}); + +export const PostgresResourceListItemSchema = z.object({ + name: z.literal("PostgreSQL"), + resource: z.literal(PamResource.Postgres) +}); + +export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() +}); + +export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const PostgresAccountSchema = BasePamAccountSchema.extend({ + credentials: PostgresAccountCredentialsSchema +}); + +export const CreatePostgresAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: PostgresAccountCredentialsSchema +}); + +export const UpdatePostgresAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: PostgresAccountCredentialsSchema.optional() +}); + +export const SanitizedPostgresAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: PostgresAccountCredentialsSchema.pick({ + username: true + }) +}); + +// Sessions +export const PostgresSessionCredentialsSchema = PostgresResourceConnectionDetailsSchema.and( + PostgresAccountCredentialsSchema +); diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-types.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-types.ts new file mode 100644 index 000000000..223ba6790 --- /dev/null +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + PostgresAccountCredentialsSchema, + PostgresAccountSchema, + PostgresResourceConnectionDetailsSchema, + PostgresResourceSchema +} from "./postgres-resource-schemas"; + +// Resources +export type TPostgresResource = z.infer; +export type TPostgresResourceConnectionDetails = z.infer; + +// Accounts +export type TPostgresAccount = z.infer; +export type TPostgresAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts new file mode 100644 index 000000000..a171b39b5 --- /dev/null +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -0,0 +1,342 @@ +import knex from "knex"; +import mysql, { Connection } from "mysql2/promise"; +import * as pg from "pg"; +import tls, { PeerCertificate } from "tls"; + +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { PamResource } from "../../pam-resource-enums"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../../pam-resource-types"; +import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test"; +const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password"; +const SIMPLE_QUERY = "select 1"; + +export interface SqlResourceConnection { + /** + * Check and see if the connection is good or not. + * + * @param connectOnly when true, if we only want to know that making the connection is possible or not, + * we don't care about authentication failures + * @returns Promise to be resolved when the connection is good, otherwise an error will be errbacked + */ + validate: (connectOnly: boolean) => Promise; + + /** + * Rotate password and return the new credentials. + * + * @param currentCredentials the current credentials to rotate + * + * @returns Promise to be resolved with the new credentials + */ + rotateCredentials: (currentCredentials: TSqlAccountCredentials) => Promise; + + /** + * Close the connection. + * + * @returns Promise for closing the connection + */ + close: () => Promise; +} + +const makeSqlConnection = ( + proxyPort: number, + config: { + connectionDetails: TSqlResourceConnectionDetails; + resourceType: PamResource; + username?: string; + password?: string; + } +): SqlResourceConnection => { + const { connectionDetails, resourceType, username, password } = config; + const { host, sslEnabled, sslRejectUnauthorized, sslCertificate } = connectionDetails; + const actualUsername = username ?? TEST_CONNECTION_USERNAME; // Use provided username or fallback + const actualPassword = password ?? TEST_CONNECTION_PASSWORD; // Use provided password or fallback + switch (config.resourceType) { + case PamResource.Postgres: { + const client = knex({ + client: "pg", + connection: { + host: "localhost", + port: proxyPort, + user: actualUsername, + password: actualPassword, + database: connectionDetails.database, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host, + // When using proxy, we need to bypass hostname validation since we connect to localhost + // but validate the certificate against the actual hostname + checkServerIdentity: (hostname: string, cert: PeerCertificate) => { + return tls.checkServerIdentity(host, cert); + } + } + : false + } + }); + return { + validate: async (connectOnly) => { + try { + await client.raw(SIMPLE_QUERY); + } catch (error) { + if (error instanceof pg.DatabaseError) { + // Hacky way to know if we successfully hit the database. + // TODO: potentially two approaches to solve the problem. + // 1. change the work flow, add account first then resource + // 2. modify relay to add a new endpoint for returning if the target host is healthy or not + // (like being able to do an auth handshake regardless pass or not) + if ( + connectOnly && + (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` || + error.message.includes("no pg_hba.conf entry for host")) + ) { + return; + } + } + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }, + rotateCredentials: async (currentCredentials) => { + const newPassword = alphaNumericNanoId(32); + // Note: The generated random password is not really going to make SQL Injection possible. + // The reason we are not using parameters binding is that the "ALTER USER" syntax is DDL, + // parameters binding is not supported. But just in case if the this code got copied + // around and repurposed, let's just do some naive escaping regardless + await client.raw(`ALTER USER :username: WITH PASSWORD '${newPassword.replace(/'/g, "''")}'`, { + username: currentCredentials.username + }); + return { username: currentCredentials.username, password: newPassword }; + }, + close: () => client.destroy() + }; + } + case PamResource.MySQL: { + return { + validate: async (connectOnly) => { + let client: Connection | null = null; + try { + // Notice: the reason we are not using Knex for mysql2 is because we don't need any fancy feature from Knex. + // mysql2 doesn't provide custom ssl verification function pass in. + // ref: https://github.com/sidorares/node-mysql2/blob/2543272a2ada8d8a07f74582549d7dd3fe948e2d/lib/base/connection.js#L358-L362 + // and then even I tried to workaround it with Knex's pool afterCreate hook, but then encounter a bug: + // ref: https://github.com/knex/knex/issues/5352 + // It appears that using Knex causing more troubles than not, we are just checking the connections, + // so it's much easier to create raw connection with the driver lib directly + client = await mysql.createConnection({ + host: "localhost", + port: proxyPort, + user: actualUsername, // Use provided username or fallback + password: actualPassword, // Use provided password or fallback + database: connectionDetails.database, + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate + } + : undefined + }); + await client.query(SIMPLE_QUERY); + } catch (error) { + if (connectOnly) { + // Hacky way to know if we successfully hit the database. + if ( + error instanceof Error && + error.message.startsWith(`Access denied for user '${TEST_CONNECTION_USERNAME}'@`) + ) { + return; + } + } + // TODO: handle other errors, and throw standardlized errors providing user-friendly msg + throw error; + } finally { + await client?.end(); + } + }, + rotateCredentials: async () => { + // TODO: the pwd rotation for MySQL is not supported yet + throw new BadRequestError({ + message: "Unsupported operation" + }); + }, + close: async () => {} + }; + } + default: + throw new BadRequestError({ + message: `Unhandled SQL Resource Connection Config: ${resourceType as PamResource}` + }); + } +}; + +export const executeWithGateway = async ( + config: { + connectionDetails: TSqlResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + username?: string; + password?: string; + }, + gatewayV2Service: Pick, + operation: (connection: SqlResourceConnection) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: connectionDetails.port + }); + + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + + return withGatewayV2Proxy( + async (proxyPort) => { + const connection = makeSqlConnection(proxyPort, config); + try { + return await operation(connection); + } finally { + await connection.close(); + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); +}; + +export const sqlResourceFactory: TPamResourceFactory = ( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service +) => { + const validateConnection = async () => { + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { + await client.validate(true); + }); + return connectionDetails; + } catch (error) { + if (error instanceof BadRequestError && error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + try { + await executeWithGateway( + { + connectionDetails, + gatewayId, + resourceType, + username: credentials.username, + password: credentials.password + }, + gatewayV2Service, + async (client) => { + await client.validate(false); + } + ); + return credentials; + } catch (error) { + // TODO: extract these logic into each SQL connection + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${credentials.username}"`) { + throw new BadRequestError({ + message: "Account credentials invalid: Username or password incorrect" + }); + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( + rotationAccountCredentials, + currentCredentials + ) => { + try { + return await executeWithGateway( + { + connectionDetails, + gatewayId, + resourceType, + username: rotationAccountCredentials.username, + password: rotationAccountCredentials.password + }, + gatewayV2Service, + (client) => client.rotateCredentials(currentCredentials) + ); + } catch (error) { + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${rotationAccountCredentials.username}"`) { + throw new BadRequestError({ + message: "Management credentials invalid: Username or password incorrect" + }); + } + + if (error.message.includes("permission denied")) { + throw new BadRequestError({ + message: `Management credentials lack permission to rotate password for user "${currentCredentials.username}"` + }); + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials + }; +}; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts new file mode 100644 index 000000000..96b6a6a24 --- /dev/null +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +// Resources +export const BaseSqlResourceConnectionDetailsSchema = z.object({ + host: z.string().trim().min(1).max(255), + port: z.coerce.number(), + database: z.string().trim().min(1).max(255), + sslEnabled: z.boolean(), + sslRejectUnauthorized: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +// Accounts +export const BaseSqlAccountCredentialsSchema = z.object({ + username: z.string().trim().min(1).max(63), + password: z.string().trim().min(1).max(256) +}); diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts new file mode 100644 index 000000000..a9ff49013 --- /dev/null +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts @@ -0,0 +1,9 @@ +import { TMySQLAccountCredentials, TMySQLResourceConnectionDetails } from "../../mysql/mysql-resource-types"; +import { + TPostgresAccountCredentials, + TPostgresResourceConnectionDetails +} from "../../postgres/postgres-resource-types"; + +export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type TSqlAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; diff --git a/backend/src/ee/services/pam-session/pam-session-dal.ts b/backend/src/ee/services/pam-session/pam-session-dal.ts new file mode 100644 index 000000000..f8b3a3393 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-dal.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPamSessionDALFactory = ReturnType; +export const pamSessionDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.PamSession); + + const findById = async (id: string, tx?: Knex) => { + const session = await (tx || db.replicaNode())(TableName.PamSession) + .leftJoin(TableName.PamAccount, `${TableName.PamSession}.accountId`, `${TableName.PamAccount}.id`) + .leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) + .select(selectAllTableCols(TableName.PamSession)) + .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) + .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) + .where(`${TableName.PamSession}.id`, id) + .first(); + + return session; + }; + + return { ...orm, findById }; +}; diff --git a/backend/src/ee/services/pam-session/pam-session-enums.ts b/backend/src/ee/services/pam-session/pam-session-enums.ts new file mode 100644 index 000000000..87731f577 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-enums.ts @@ -0,0 +1,6 @@ +export enum PamSessionStatus { + Starting = "starting", // Starting, user connecting to resource + Active = "active", // Active, user is connected to resource + Ended = "ended", // Ended by user + Terminated = "terminated" // Terminated by an admin +} diff --git a/backend/src/ee/services/pam-session/pam-session-fns.ts b/backend/src/ee/services/pam-session/pam-session-fns.ts new file mode 100644 index 000000000..4afe205b5 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-fns.ts @@ -0,0 +1,43 @@ +import { TPamSessions } from "@app/db/schemas"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; + +export const decryptSessionCommandLogs = async ({ + projectId, + encryptedLogs, + kmsService +}: { + projectId: string; + encryptedLogs: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedLogs + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog; +}; + +export const decryptSession = async ( + session: TPamSessions, + projectId: string, + kmsService: Pick +) => { + return { + ...session, + commandLogs: session.encryptedLogsBlob + ? await decryptSessionCommandLogs({ + projectId, + encryptedLogs: session.encryptedLogsBlob, + kmsService + }) + : [] + } as TPamSanitizedSession; +}; diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts new file mode 100644 index 000000000..2bc1d5345 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +import { PamSessionsSchema } from "@app/db/schemas"; + +export const PamSessionCommandLogSchema = z.object({ + input: z.string(), + output: z.string(), + timestamp: z.coerce.date() +}); + +export const SanitizedSessionSchema = PamSessionsSchema.omit({ + encryptedLogsBlob: true +}).extend({ + commandLogs: PamSessionCommandLogSchema.array() +}); diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts new file mode 100644 index 000000000..26ff7daa6 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -0,0 +1,192 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType, OrganizationActionScope } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TUpdateSessionLogsDTO } from "./pam-session.types"; +import { TPamSessionDALFactory } from "./pam-session-dal"; +import { PamSessionStatus } from "./pam-session-enums"; +import { decryptSession } from "./pam-session-fns"; + +type TPamSessionServiceFactoryDep = { + pamSessionDAL: TPamSessionDALFactory; + projectDAL: TProjectDALFactory; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; +}; + +export type TPamSessionServiceFactory = ReturnType; + +export const pamSessionServiceFactory = ({ + pamSessionDAL, + projectDAL, + permissionService, + licenseService, + kmsService +}: TPamSessionServiceFactoryDep) => { + const getById = async (sessionId: string, actor: OrgServiceActor) => { + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: session.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamSessionActions.Read, + ProjectPermissionSub.PamSessions + ); + + return { + session: await decryptSession(session, session.projectId, kmsService) + }; + }; + + const list = async (projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamSessionActions.Read, + ProjectPermissionSub.PamSessions + ); + + const sessions = await pamSessionDAL.find({ projectId }); + + return { + sessions: await Promise.all(sessions.map((session) => decryptSession(session, projectId, kmsService))) + }; + }; + + const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => { + const orgLicensePlan = await licenseService.getPlan(actor.orgId); + if (!orgLicensePlan.pam) { + throw new BadRequestError({ + message: "PAM operation failed due to organization plan restrictions." + }); + } + + // To be hit by gateways only + if (actor.type !== ActorType.IDENTITY) { + throw new ForbiddenRequestError({ message: "Only gateways can perform this action" }); + } + + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + if (session.encryptedLogsBlob) { + throw new BadRequestError({ message: "Cannot update logs for sessions with existing logs" }); + } + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: project.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (session.gatewayIdentityId !== actor.id) { + throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" }); + } + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: session.projectId + }); + + const { cipherTextBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(logs)) + }); + + const updatedSession = await pamSessionDAL.updateById(sessionId, { + encryptedLogsBlob: cipherTextBlob + }); + + return { session: updatedSession, projectId: project.id }; + }; + + const endSessionById = async (sessionId: string, actor: OrgServiceActor) => { + const session = await pamSessionDAL.findById(sessionId); + if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const project = await projectDAL.findById(session.projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` }); + + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: project.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); + + if (actor.type === ActorType.IDENTITY) { + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionGatewayActions.CreateGateways, + OrgPermissionSubjects.Gateway + ); + + if (session.gatewayIdentityId !== actor.id) { + throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" }); + } + } else if (actor.type === ActorType.USER) { + if (session.userId !== actor.id) { + throw new ForbiddenRequestError({ message: "You are not authorized to end this session" }); + } + } else { + throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" }); + } + + if (session.status === PamSessionStatus.Ended) { + return { + session, + projectId: project.id + }; + } + + if (session.status !== PamSessionStatus.Active && session.status !== PamSessionStatus.Starting) { + throw new BadRequestError({ message: "Cannot end sessions that are not active or starting" }); + } + + const updatedSession = await pamSessionDAL.updateById(sessionId, { + endedAt: new Date(), + status: PamSessionStatus.Ended + }); + + return { session: updatedSession, projectId: project.id }; + }; + + return { getById, list, updateLogsById, endSessionById }; +}; diff --git a/backend/src/ee/services/pam-session/pam-session.types.ts b/backend/src/ee/services/pam-session/pam-session.types.ts new file mode 100644 index 000000000..0c87a9fa4 --- /dev/null +++ b/backend/src/ee/services/pam-session/pam-session.types.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas"; + +export type TPamSessionCommandLog = z.infer; +export type TPamSanitizedSession = z.infer; + +// DTOs +export type TUpdateSessionLogsDTO = { + sessionId: string; + logs: TPamSessionCommandLog[]; +}; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 83d1c44d9..34876f739 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -5,6 +5,7 @@ import { ProjectPermissionAppConnectionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, ProjectPermissionDynamicSecretActions, @@ -12,6 +13,8 @@ import { ProjectPermissionIdentityActions, ProjectPermissionKmipActions, ProjectPermissionMemberActions, + ProjectPermissionPamAccountActions, + ProjectPermissionPamSessionActions, ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, @@ -49,7 +52,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshCertificateTemplates, - ProjectPermissionSub.SshHostGroups + ProjectPermissionSub.SshHostGroups, + ProjectPermissionSub.PamFolders, + ProjectPermissionSub.PamResources ].forEach((el) => { can( [ @@ -68,8 +73,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionPkiTemplateActions.Edit, ProjectPermissionPkiTemplateActions.Create, ProjectPermissionPkiTemplateActions.Delete, - ProjectPermissionPkiTemplateActions.IssueCert, - ProjectPermissionPkiTemplateActions.ListCerts + ProjectPermissionPkiTemplateActions.IssueCert, // deprecated + ProjectPermissionPkiTemplateActions.ListCerts // deprecated ], ProjectPermissionSub.CertificateTemplates ); @@ -95,6 +100,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates ); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionCertificateProfileActions.IssueCert + ], + ProjectPermissionSub.CertificateProfiles + ); + can( [ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback], ProjectPermissionSub.Commits @@ -290,6 +306,19 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.AppConnections ); + can( + [ + ProjectPermissionPamAccountActions.Access, + ProjectPermissionPamAccountActions.Read, + ProjectPermissionPamAccountActions.Create, + ProjectPermissionPamAccountActions.Edit, + ProjectPermissionPamAccountActions.Delete + ], + ProjectPermissionSub.PamAccounts + ); + + can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions); + return rules; }; @@ -426,6 +455,7 @@ const buildMemberPermissionRules = () => { // double check if all CRUD are needed for CA and Certificates can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can( [ @@ -437,7 +467,15 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete + ], + ProjectPermissionSub.CertificateProfiles + ); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); @@ -518,6 +556,15 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources); + + can( + [ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read], + ProjectPermissionSub.PamAccounts + ); + return rules; }; @@ -579,6 +626,12 @@ const buildViewerPermissionRules = () => { ProjectPermissionSub.SecretEvents ); + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders); + + can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources); + + can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts); + return rules; }; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 0548911e8..743dcd63f 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -15,6 +15,11 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionSubOrgActions { + Create = "create", + DirectAccess = "direct-access" +} + export enum OrgPermissionAppConnectionActions { Read = "read", Create = "create", @@ -117,7 +122,8 @@ export enum OrgPermissionSubjects { Kmip = "kmip", Gateway = "gateway", Relay = "relay", - SecretShare = "secret-share" + SecretShare = "secret-share", + SubOrganization = "sub-organization" } export type AppConnectionSubjectFields = { @@ -128,6 +134,7 @@ export type OrgPermissionSet = | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] | [OrgPermissionActions.Create, OrgPermissionSubjects.Project] | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionSubOrgActions, OrgPermissionSubjects.SubOrganization] | [OrgPermissionActions, OrgPermissionSubjects.Member] | [OrgPermissionActions, OrgPermissionSubjects.Settings] | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] @@ -185,6 +192,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.SubOrganization).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionSubOrgActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(OrgPermissionSubjects.Member).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -219,7 +232,9 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ }), z.object({ subject: z.literal(OrgPermissionSubjects.Groups).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGroupActions).describe( + "Describe what action an entity can take." + ) }), z.object({ subject: z.literal(OrgPermissionSubjects.SecretScanning).describe("The entity this permission pertains to."), @@ -227,11 +242,15 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ }), z.object({ subject: z.literal(OrgPermissionSubjects.Billing).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionBillingActions).describe( + "Describe what action an entity can take." + ) }), z.object({ subject: z.literal(OrgPermissionSubjects.Identity).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionIdentityActions).describe( + "Describe what action an entity can take." + ) }), z.object({ subject: z.literal(OrgPermissionSubjects.Kms).describe("The entity this permission pertains to."), @@ -302,6 +321,10 @@ const buildAdminPermission = () => { // ws permissions can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Create, OrgPermissionSubjects.Project); + + can(OrgPermissionSubOrgActions.Create, OrgPermissionSubjects.SubOrganization); + can(OrgPermissionSubOrgActions.DirectAccess, OrgPermissionSubjects.SubOrganization); + // role permission can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index cdf55127f..95480a54a 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -1,182 +1,61 @@ +import { Knex } from "knex"; import { z } from "zod"; import { TDbClient } from "@app/db"; import { - IdentityProjectMembershipRoleSchema, - OrgMembershipRole, - OrgMembershipsSchema, + AccessScope, + AccessScopeData, + MembershipRolesSchema, + MembershipsSchema, TableName, - TIdentityOrgMemberships, - TProjectRoles, - TProjects + TMemberships, + TRoles } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { ActorType } from "@app/services/auth/auth-type"; + +interface TPermissionDataReturn extends TMemberships { + orgAuthEnforced?: boolean | null; + orgGoogleSsoAuthEnforced?: boolean | null; + shouldUseNewPrivilegeSystem?: boolean | null; + rootOrgId?: string | null; + bypassOrgAuthEnabled?: boolean | null; + roles: { + id: string; + createdAt: Date; + updatedAt: Date; + isTemporary: boolean; + role: string; + temporaryRange?: string | null | undefined; + permissions?: unknown; + customRoleId?: string | null | undefined; + temporaryMode?: string | null | undefined; + temporaryAccessStartTime?: Date | null | undefined; + temporaryAccessEndTime?: Date | null | undefined; + customRoleSlug?: string | null | undefined; + }[]; + additionalPrivileges: { + id: string; + permissions: unknown; + temporaryRange: string | null | undefined; + temporaryMode: string | null | undefined; + temporaryAccessEndTime: Date | null | undefined; + temporaryAccessStartTime: Date | null | undefined; + isTemporary: boolean; + }[]; + metadata: { + id: string; + key: string; + value: string; + }[]; +} export interface TPermissionDALFactory { - getOrgPermission: ( - userId: string, + getProjectUserPermissions: ( + projectId: string, orgId: string ) => Promise< - { - status: string; - orgId: string; - id: string; - createdAt: Date; - updatedAt: Date; - role: string; - isActive: boolean; - shouldUseNewPrivilegeSystem: boolean; - bypassOrgAuthEnabled: boolean; - permissions?: unknown; - userId?: string | null | undefined; - roleId?: string | null | undefined; - inviteEmail?: string | null | undefined; - projectFavorites?: string[] | null | undefined; - customRoleSlug?: string | null | undefined; - orgAuthEnforced?: boolean | null | undefined; - orgGoogleSsoAuthEnforced: boolean; - } & { - groups: { - id: string; - updatedAt: Date; - createdAt: Date; - role: string; - roleId: string | null | undefined; - customRolePermission: unknown; - name: string; - slug: string; - orgId: string; - }[]; - } - >; - getOrgIdentityPermission: ( - identityId: string, - orgId: string - ) => Promise< - | (TIdentityOrgMemberships & { - orgAuthEnforced: boolean | null | undefined; - shouldUseNewPrivilegeSystem: boolean; - permissions?: unknown; - }) - | undefined - >; - getProjectPermission: ( - userId: string, - projectId: string - ) => Promise< - | { - roles: { - id: string; - role: string; - customRoleSlug: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - isTemporary: boolean; - }[]; - additionalPrivileges: { - id: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - isTemporary: boolean; - }[]; - orgId: string; - orgAuthEnforced: boolean | null | undefined; - orgGoogleSsoAuthEnforced: boolean; - orgRole: OrgMembershipRole; - userId: string; - projectId: string; - username: string; - projectType?: string | null; - id: string; - createdAt: Date; - updatedAt: Date; - shouldUseNewPrivilegeSystem: boolean; - bypassOrgAuthEnabled: boolean; - metadata: { - id: string; - key: string; - value: string; - }[]; - userGroupRoles: { - id: string; - role: string; - customRoleSlug: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - isTemporary: boolean; - }[]; - projecMembershiptRoles: { - id: string; - role: string; - customRoleSlug: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - isTemporary: boolean; - }[]; - } - | undefined - >; - getProjectIdentityPermission: ( - identityId: string, - projectId: string - ) => Promise< - | { - roles: { - id: string; - createdAt: Date; - updatedAt: Date; - isTemporary: boolean; - role: string; - projectMembershipId: string; - temporaryRange?: string | null | undefined; - permissions?: unknown; - customRoleId?: string | null | undefined; - temporaryMode?: string | null | undefined; - temporaryAccessStartTime?: Date | null | undefined; - temporaryAccessEndTime?: Date | null | undefined; - customRoleSlug?: string | null | undefined; - }[]; - additionalPrivileges: { - id: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - isTemporary: boolean; - }[]; - id: string; - identityId: string; - username: string; - projectId: string; - createdAt: Date; - updatedAt: Date; - orgId: string; - projectType?: string | null; - shouldUseNewPrivilegeSystem: boolean; - orgAuthEnforced: boolean; - metadata: { - id: string; - key: string; - value: string; - }[]; - } - | undefined - >; - getProjectUserPermissions: (projectId: string) => Promise< { roles: { id: string; @@ -198,45 +77,19 @@ export interface TPermissionDALFactory { temporaryAccessEndTime: Date | null | undefined; isTemporary: boolean; }[]; - orgId: string; - orgAuthEnforced: boolean | null | undefined; userId: string; - projectId: string; username: string; - projectType?: string | null; - id: string; - createdAt: Date; - updatedAt: Date; metadata: { id: string; key: string; value: string; }[]; - userGroupRoles: { - id: string; - role: string; - customRoleSlug: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - isTemporary: boolean; - }[]; - projectMembershipRoles: { - id: string; - role: string; - customRoleSlug: string; - permissions: unknown; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessStartTime: Date | null | undefined; - temporaryAccessEndTime: Date | null | undefined; - isTemporary: boolean; - }[]; }[] >; - getProjectIdentityPermissions: (projectId: string) => Promise< + getProjectIdentityPermissions: ( + projectId: string, + orgId: string + ) => Promise< { roles: { id: string; @@ -244,7 +97,6 @@ export interface TPermissionDALFactory { updatedAt: Date; isTemporary: boolean; role: string; - projectMembershipId: string; temporaryRange?: string | null | undefined; permissions?: unknown; customRoleId?: string | null | undefined; @@ -269,8 +121,6 @@ export interface TPermissionDALFactory { createdAt: Date; updatedAt: Date; orgId: string; - projectType?: string | null; - orgAuthEnforced: boolean; metadata: { id: string; key: string; @@ -310,128 +160,205 @@ export interface TPermissionDALFactory { }[]; }[] >; + getPermission: (dto: { + scopeData: AccessScopeData; + actorId: string; + actorType: ActorType.IDENTITY | ActorType.USER; + tx?: Knex; + }) => Promise; } export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { - const getOrgPermission: TPermissionDALFactory["getOrgPermission"] = async (userId: string, orgId: string) => { + const getPermission: TPermissionDALFactory["getPermission"] = async ({ scopeData, tx, actorId, actorType }) => { try { - const groupSubQuery = db(TableName.Groups) - .where(`${TableName.Groups}.orgId`, orgId) - .join(TableName.UserGroupMembership, (queryBuilder) => { - queryBuilder - .on(`${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) - .andOn(`${TableName.UserGroupMembership}.userId`, db.raw("?", [userId])); - }) - .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) - .select( - db.ref("id").withSchema(TableName.Groups).as("groupId"), - db.ref("orgId").withSchema(TableName.Groups).as("groupOrgId"), - db.ref("name").withSchema(TableName.Groups).as("groupName"), - db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), - db.ref("role").withSchema(TableName.Groups).as("groupRole"), - db.ref("roleId").withSchema(TableName.Groups).as("groupRoleId"), - db.ref("createdAt").withSchema(TableName.Groups).as("groupCreatedAt"), - db.ref("updatedAt").withSchema(TableName.Groups).as("groupUpdatedAt"), - db.ref("permissions").withSchema(TableName.OrgRoles).as("groupCustomRolePermission") - ); + // akhilmhdh: when group has another group like sub group we would need recursively go down + const userGroupSubquery = (tx || db)(TableName.Groups) + .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) + .where(`${TableName.Groups}.orgId`, scopeData.orgId) + .where(`${TableName.UserGroupMembership}.userId`, actorId) + .select(db.ref("id").withSchema(TableName.Groups)); - const membership = await db - .replicaNode()(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) - .where(`${TableName.OrgMembership}.userId`, userId) - .leftJoin(TableName.OrgRoles, `${TableName.OrgRoles}.id`, `${TableName.OrgMembership}.roleId`) - .leftJoin[0]>( - groupSubQuery.as("userGroups"), - "userGroups.groupOrgId", - db.raw("?", [orgId]) - ) - .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + const docs = await (tx || db) + .replicaNode()(TableName.Membership) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .leftJoin(TableName.AdditionalPrivilege, (qb) => { + if (actorType === ActorType.IDENTITY) { + qb.on(`${TableName.Membership}.actorIdentityId`, `${TableName.AdditionalPrivilege}.actorIdentityId`); + } else { + qb.on(`${TableName.Membership}.actorUserId`, `${TableName.AdditionalPrivilege}.actorUserId`); + } + + if (scopeData.scope === AccessScope.Organization) { + qb.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.AdditionalPrivilege}.orgId`); + } else if (scopeData.scope === AccessScope.Project) { + qb.andOn(`${TableName.Membership}.scopeProjectId`, `${TableName.AdditionalPrivilege}.projectId`); + } else { + qb.andOn(`${TableName.Membership}.scopeNamespaceId`, `${TableName.AdditionalPrivilege}.namespaceId`); + } + }) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + if (actorType === ActorType.USER) { + void queryBuilder + .on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + } else if (actorType === ActorType.IDENTITY) { + void queryBuilder + .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + } + }) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where((qb) => { + if (actorType === ActorType.USER) { + void qb + .where(`${TableName.Membership}.actorUserId`, actorId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery); + } else if (actorType === ActorType.IDENTITY) { + void qb.where(`${TableName.Membership}.actorIdentityId`, actorId); + } + }) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) + .select(selectAllTableCols(TableName.Membership)) .select( - selectAllTableCols(TableName.OrgMembership), + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("permissions").withSchema(TableName.Role).as("customRolePermission"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt"), + db.ref("id").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeId"), + db.ref("name").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeName"), + db.ref("permissions").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegePermissions"), + db.ref("id").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeId"), + db.ref("temporaryMode").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.AdditionalPrivilege) + .as("additionalPrivilegeTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.AdditionalPrivilege) + .as("additionalPrivilegeTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeUpdatedAt"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization), - db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), - db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), - db.ref("groupId").withSchema("userGroups"), - db.ref("groupOrgId").withSchema("userGroups"), - db.ref("groupName").withSchema("userGroups"), - db.ref("groupSlug").withSchema("userGroups"), - db.ref("groupRole").withSchema("userGroups"), - db.ref("groupRoleId").withSchema("userGroups"), - db.ref("groupCreatedAt").withSchema("userGroups"), - db.ref("groupUpdatedAt").withSchema("userGroups"), - db.ref("groupCustomRolePermission").withSchema("userGroups") + db.ref("rootOrgId").withSchema(TableName.Organization).as("rootOrgId") ); - const [formatedDoc] = sqlNestRelationships({ - data: membership, + const data = sqlNestRelationships({ + data: docs, key: "id", parentMapper: (el) => - OrgMembershipsSchema.extend({ - permissions: z.unknown(), + MembershipsSchema.extend({ orgAuthEnforced: z.boolean().optional().nullable(), + shouldUseNewPrivilegeSystem: z.boolean().optional().nullable(), + rootOrgId: z.string().optional().nullable(), orgGoogleSsoAuthEnforced: z.boolean(), - bypassOrgAuthEnabled: z.boolean(), - customRoleSlug: z.string().optional().nullable(), - shouldUseNewPrivilegeSystem: z.boolean() + bypassOrgAuthEnabled: z.boolean() }).parse(el), childrenMapper: [ { - key: "groupId", - label: "groups" as const, + key: "additionalPrivilegeId", + label: "additionalPrivileges" as const, mapper: ({ - groupId, - groupUpdatedAt, - groupCreatedAt, - groupRole, - groupRoleId, - groupCustomRolePermission, - groupName, - groupSlug, - groupOrgId + additionalPrivilegeId, + additionalPrivilegePermissions, + additionalPrivilegeIsTemporary, + additionalPrivilegeTemporaryMode, + additionalPrivilegeTemporaryRange, + additionalPrivilegeTemporaryAccessEndTime, + additionalPrivilegeTemporaryAccessStartTime, + additionalPrivilegeCreatedAt, + additionalPrivilegeUpdatedAt }) => ({ - id: groupId, - updatedAt: groupUpdatedAt, - createdAt: groupCreatedAt, - role: groupRole, - roleId: groupRoleId, - customRolePermission: groupCustomRolePermission, - name: groupName, - slug: groupSlug, - orgId: groupOrgId + id: additionalPrivilegeId, + permissions: additionalPrivilegePermissions, + temporaryRange: additionalPrivilegeTemporaryRange, + temporaryMode: additionalPrivilegeTemporaryMode, + temporaryAccessStartTime: additionalPrivilegeTemporaryAccessStartTime, + temporaryAccessEndTime: additionalPrivilegeTemporaryAccessEndTime, + isTemporary: additionalPrivilegeIsTemporary, + createdAt: additionalPrivilegeCreatedAt, + updatedAt: additionalPrivilegeUpdatedAt + }) + }, + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + customRolePermission, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + permissions: customRolePermission, + customRoleSlug: roleSlug, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue }) } ] }); - return formatedDoc; + return data; } catch (error) { - throw new DatabaseError({ error, name: "GetOrgPermission" }); - } - }; - - const getOrgIdentityPermission: TPermissionDALFactory["getOrgIdentityPermission"] = async ( - identityId: string, - orgId: string - ) => { - try { - const membership = await db - .replicaNode()(TableName.IdentityOrgMembership) - .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) - .join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`) - .where("identityId", identityId) - .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) - .select(selectAllTableCols(TableName.IdentityOrgMembership)) - .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced")) - .select("permissions") - .select(db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization)) - .first(); - - return membership; - } catch (error) { - throw new DatabaseError({ error, name: "GetOrgIdentityPermission" }); + throw new DatabaseError({ error, name: "Get Permission" }); } }; @@ -441,55 +368,41 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { ) => { try { const docs = await db - .replicaNode()(TableName.GroupProjectMembership) - .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.GroupProjectMembership}.groupId`) - .join( - TableName.GroupProjectMembershipRole, - `${TableName.GroupProjectMembershipRole}.projectMembershipId`, - `${TableName.GroupProjectMembership}.id` - ) - .leftJoin( - { groupCustomRoles: TableName.ProjectRoles }, - `${TableName.GroupProjectMembershipRole}.customRoleId`, + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin( + { groupCustomRoles: TableName.Role }, + `${TableName.MembershipRole}.customRoleId`, `groupCustomRoles.id` ) - .where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId) + .where(`${TableName.Membership}.scopeProjectId`, "=", projectId) .where((bd) => { if (filterGroupId) { - void bd.where(`${TableName.GroupProjectMembership}.groupId`, "=", filterGroupId); + void bd.where(`${TableName.Membership}.actorGroupId`, "=", filterGroupId); } }) .select( - db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), + db.ref("id").withSchema(TableName.Membership).as("membershipId"), db.ref("id").withSchema(TableName.Groups).as("groupId"), db.ref("name").withSchema(TableName.Groups).as("groupName"), db.ref("slug").withSchema("groupCustomRoles").as("groupProjectMembershipRoleCustomRoleSlug"), db.ref("permissions").withSchema("groupCustomRoles").as("groupProjectMembershipRolePermission"), - db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("groupProjectMembershipRoleId"), - db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("groupProjectMembershipRole"), - db - .ref("customRoleId") - .withSchema(TableName.GroupProjectMembershipRole) - .as("groupProjectMembershipRoleCustomRoleId"), - db - .ref("isTemporary") - .withSchema(TableName.GroupProjectMembershipRole) - .as("groupProjectMembershipRoleIsTemporary"), - db - .ref("temporaryMode") - .withSchema(TableName.GroupProjectMembershipRole) - .as("groupProjectMembershipRoleTemporaryMode"), - db - .ref("temporaryRange") - .withSchema(TableName.GroupProjectMembershipRole) - .as("groupProjectMembershipRoleTemporaryRange"), + db.ref("id").withSchema(TableName.MembershipRole).as("groupProjectMembershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("groupProjectMembershipRole"), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("groupProjectMembershipRoleCustomRoleId"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("groupProjectMembershipRoleIsTemporary"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("groupProjectMembershipRoleTemporaryMode"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("groupProjectMembershipRoleTemporaryRange"), db .ref("temporaryAccessStartTime") - .withSchema(TableName.GroupProjectMembershipRole) + .withSchema(TableName.MembershipRole) .as("groupProjectMembershipRoleTemporaryAccessStartTime"), db .ref("temporaryAccessEndTime") - .withSchema(TableName.GroupProjectMembershipRole) + .withSchema(TableName.MembershipRole) .as("groupProjectMembershipRoleTemporaryAccessEndTime") ); @@ -551,246 +464,148 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { } }; - const getProjectUserPermissions: TPermissionDALFactory["getProjectUserPermissions"] = async (projectId: string) => { + const getProjectUserPermissions: TPermissionDALFactory["getProjectUserPermissions"] = async ( + projectId: string, + orgId: string + ) => { + const userGroupSubquery = db(TableName.Groups) + .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) + .where(`${TableName.Groups}.orgId`, orgId) + .select(db.ref("id").withSchema(TableName.Groups)); + try { const docs = await db .replicaNode()(TableName.Users) .where("isGhost", "=", false) - .leftJoin(TableName.GroupProjectMembership, (queryBuilder) => { - void queryBuilder.on(`${TableName.GroupProjectMembership}.projectId`, db.raw("?", [projectId])); + .join(TableName.Membership, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .leftJoin(TableName.AdditionalPrivilege, (qb) => { + qb.on(`${TableName.Membership}.actorUserId`, `${TableName.AdditionalPrivilege}.actorUserId`).andOn( + `${TableName.Membership}.scopeOrgId`, + `${TableName.AdditionalPrivilege}.orgId` + ); }) - .leftJoin( - TableName.GroupProjectMembershipRole, - `${TableName.GroupProjectMembershipRole}.projectMembershipId`, - `${TableName.GroupProjectMembership}.id` - ) - .leftJoin( - { groupCustomRoles: TableName.ProjectRoles }, - `${TableName.GroupProjectMembershipRole}.customRoleId`, - `groupCustomRoles.id` - ) - .join(TableName.ProjectMembership, (queryBuilder) => { - void queryBuilder - .on(`${TableName.ProjectMembership}.projectId`, db.raw("?", [projectId])) - .andOn(`${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`); - }) - .leftJoin( - TableName.ProjectUserMembershipRole, - `${TableName.ProjectUserMembershipRole}.projectMembershipId`, - `${TableName.ProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.ProjectUserMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin(TableName.ProjectUserAdditionalPrivilege, (queryBuilder) => { - void queryBuilder - .on(`${TableName.ProjectUserAdditionalPrivilege}.projectId`, db.raw("?", [projectId])) - .andOn(`${TableName.ProjectUserAdditionalPrivilege}.userId`, `${TableName.Users}.id`); - }) - .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) - .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder - .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) - .andOn(`${TableName.Organization}.id`, `${TableName.IdentityMetadata}.orgId`); + .on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where((qb) => { + void qb + .whereNotNull(`${TableName.Membership}.actorUserId`) + .orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery); + }) + .where((qb) => { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, projectId); }) .select( db.ref("id").withSchema(TableName.Users).as("userId"), db.ref("username").withSchema(TableName.Users).as("username"), - // groups specific - db.ref("id").withSchema(TableName.GroupProjectMembership).as("groupMembershipId"), - db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipUpdatedAt"), - db.ref("slug").withSchema("groupCustomRoles").as("userGroupProjectMembershipRoleCustomRoleSlug"), - db.ref("permissions").withSchema("groupCustomRoles").as("userGroupProjectMembershipRolePermission"), - db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRoleId"), - db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRole"), - db - .ref("customRoleId") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleCustomRoleId"), - db - .ref("isTemporary") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleIsTemporary"), - db - .ref("temporaryMode") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryMode"), - db - .ref("temporaryRange") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryRange"), + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("permissions").withSchema(TableName.Role).as("customRolePermission"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), db .ref("temporaryAccessStartTime") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryAccessStartTime"), + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), db .ref("temporaryAccessEndTime") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryAccessEndTime"), - // user specific - db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), - db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("userProjectMembershipRoleCustomRoleSlug"), - db.ref("permissions").withSchema(TableName.ProjectRoles).as("userProjectCustomRolePermission"), - db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRoleId"), - db.ref("role").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRole"), - db - .ref("temporaryMode") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryMode"), - db - .ref("isTemporary") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleIsTemporary"), - db - .ref("temporaryRange") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryRange"), + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt"), + db.ref("id").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeId"), + db.ref("name").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeName"), + db.ref("permissions").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegePermissions"), + db.ref("id").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeId"), + db.ref("temporaryMode").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeTemporaryRange"), db .ref("temporaryAccessStartTime") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryAccessStartTime"), + .withSchema(TableName.AdditionalPrivilege) + .as("additionalPrivilegeTemporaryAccessStartTime"), db .ref("temporaryAccessEndTime") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryAccessEndTime"), - db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesId"), - db - .ref("permissions") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesPermissions"), - db - .ref("temporaryMode") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryMode"), - db - .ref("isTemporary") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesIsTemporary"), - db - .ref("temporaryRange") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryRange"), - db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesUserId"), - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryAccessEndTime"), + .withSchema(TableName.AdditionalPrivilege) + .as("additionalPrivilegeTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AdditionalPrivilege).as("additionalPrivilegeUpdatedAt"), // general db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), - db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), - db.ref("orgId").withSchema(TableName.Project), - db.ref("type").withSchema(TableName.Project).as("projectType"), - db.ref("id").withSchema(TableName.Project).as("projectId") + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") ); const userPermissions = sqlNestRelationships({ data: docs, key: "userId", - parentMapper: ({ - orgId, - username, - orgAuthEnforced, - membershipId, - groupMembershipId, - membershipCreatedAt, - groupMembershipCreatedAt, - groupMembershipUpdatedAt, - membershipUpdatedAt, - projectType, - userId - }) => ({ - orgId, - orgAuthEnforced, + parentMapper: ({ username, userId }) => ({ userId, projectId, - username, - projectType, - id: membershipId || groupMembershipId, - createdAt: membershipCreatedAt || groupMembershipCreatedAt, - updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt + username }), childrenMapper: [ { - key: "userGroupProjectMembershipRoleId", - label: "userGroupRoles" as const, - mapper: ({ - userGroupProjectMembershipRoleId, - userGroupProjectMembershipRole, - userGroupProjectMembershipRolePermission, - userGroupProjectMembershipRoleCustomRoleSlug, - userGroupProjectMembershipRoleIsTemporary, - userGroupProjectMembershipRoleTemporaryMode, - userGroupProjectMembershipRoleTemporaryAccessEndTime, - userGroupProjectMembershipRoleTemporaryAccessStartTime, - userGroupProjectMembershipRoleTemporaryRange - }) => ({ - id: userGroupProjectMembershipRoleId, - role: userGroupProjectMembershipRole, - customRoleSlug: userGroupProjectMembershipRoleCustomRoleSlug, - permissions: userGroupProjectMembershipRolePermission, - temporaryRange: userGroupProjectMembershipRoleTemporaryRange, - temporaryMode: userGroupProjectMembershipRoleTemporaryMode, - temporaryAccessStartTime: userGroupProjectMembershipRoleTemporaryAccessStartTime, - temporaryAccessEndTime: userGroupProjectMembershipRoleTemporaryAccessEndTime, - isTemporary: userGroupProjectMembershipRoleIsTemporary - }) - }, - { - key: "userProjectMembershipRoleId", - label: "projectMembershipRoles" as const, - mapper: ({ - userProjectMembershipRoleId, - userProjectMembershipRole, - userProjectCustomRolePermission, - userProjectMembershipRoleIsTemporary, - userProjectMembershipRoleTemporaryMode, - userProjectMembershipRoleTemporaryRange, - userProjectMembershipRoleTemporaryAccessEndTime, - userProjectMembershipRoleTemporaryAccessStartTime, - userProjectMembershipRoleCustomRoleSlug - }) => ({ - id: userProjectMembershipRoleId, - role: userProjectMembershipRole, - customRoleSlug: userProjectMembershipRoleCustomRoleSlug, - permissions: userProjectCustomRolePermission, - temporaryRange: userProjectMembershipRoleTemporaryRange, - temporaryMode: userProjectMembershipRoleTemporaryMode, - temporaryAccessStartTime: userProjectMembershipRoleTemporaryAccessStartTime, - temporaryAccessEndTime: userProjectMembershipRoleTemporaryAccessEndTime, - isTemporary: userProjectMembershipRoleIsTemporary - }) - }, - { - key: "userAdditionalPrivilegesId", + key: "additionalPrivilegeId", label: "additionalPrivileges" as const, mapper: ({ - userAdditionalPrivilegesId, - userAdditionalPrivilegesPermissions, - userAdditionalPrivilegesIsTemporary, - userAdditionalPrivilegesTemporaryMode, - userAdditionalPrivilegesTemporaryRange, - userAdditionalPrivilegesTemporaryAccessEndTime, - userAdditionalPrivilegesTemporaryAccessStartTime + additionalPrivilegeId, + additionalPrivilegePermissions, + additionalPrivilegeIsTemporary, + additionalPrivilegeTemporaryMode, + additionalPrivilegeTemporaryRange, + additionalPrivilegeTemporaryAccessEndTime, + additionalPrivilegeTemporaryAccessStartTime, + additionalPrivilegeCreatedAt, + additionalPrivilegeUpdatedAt }) => ({ - id: userAdditionalPrivilegesId, - permissions: userAdditionalPrivilegesPermissions, - temporaryRange: userAdditionalPrivilegesTemporaryRange, - temporaryMode: userAdditionalPrivilegesTemporaryMode, - temporaryAccessStartTime: userAdditionalPrivilegesTemporaryAccessStartTime, - temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime, - isTemporary: userAdditionalPrivilegesIsTemporary + id: additionalPrivilegeId, + permissions: additionalPrivilegePermissions, + temporaryRange: additionalPrivilegeTemporaryRange, + temporaryMode: additionalPrivilegeTemporaryMode, + temporaryAccessStartTime: additionalPrivilegeTemporaryAccessStartTime, + temporaryAccessEndTime: additionalPrivilegeTemporaryAccessEndTime, + isTemporary: additionalPrivilegeIsTemporary, + createdAt: additionalPrivilegeCreatedAt, + updatedAt: additionalPrivilegeUpdatedAt + }) + }, + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + customRolePermission, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + permissions: customRolePermission, + customRoleSlug: roleSlug, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt }) }, { @@ -808,17 +623,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { return userPermissions .map((userPermission) => { if (!userPermission) return undefined; - if (!userPermission?.userGroupRoles?.[0] && !userPermission?.projectMembershipRoles?.[0]) return undefined; + if (!userPermission?.roles?.[0]) return undefined; // when introducting cron mode change it here const activeRoles = - userPermission?.projectMembershipRoles?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ) ?? []; - - const activeGroupRoles = - userPermission?.userGroupRoles?.filter( + userPermission?.roles?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ) ?? []; @@ -831,7 +640,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { return { ...userPermission, - roles: [...activeRoles, ...activeGroupRoles], + roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; }) @@ -841,378 +650,52 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { } }; - const getProjectPermission: TPermissionDALFactory["getProjectPermission"] = async ( - userId: string, - projectId: string - ) => { - try { - const subQueryUserGroups = db(TableName.UserGroupMembership).where("userId", userId).select("groupId"); - const docs = await db - .replicaNode()(TableName.Users) - .where(`${TableName.Users}.id`, userId) - .leftJoin(TableName.GroupProjectMembership, (queryBuilder) => { - void queryBuilder - .on(`${TableName.GroupProjectMembership}.projectId`, db.raw("?", [projectId])) - // @ts-expect-error akhilmhdh: this is valid knexjs query. Its just ts type argument is missing it - .andOnIn(`${TableName.GroupProjectMembership}.groupId`, subQueryUserGroups); - }) - .leftJoin( - TableName.GroupProjectMembershipRole, - `${TableName.GroupProjectMembershipRole}.projectMembershipId`, - `${TableName.GroupProjectMembership}.id` - ) - .leftJoin( - { groupCustomRoles: TableName.ProjectRoles }, - `${TableName.GroupProjectMembershipRole}.customRoleId`, - `groupCustomRoles.id` - ) - .leftJoin(TableName.ProjectMembership, (queryBuilder) => { - void queryBuilder - .on(`${TableName.ProjectMembership}.projectId`, db.raw("?", [projectId])) - .andOn(`${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`); - }) - .leftJoin( - TableName.ProjectUserMembershipRole, - `${TableName.ProjectUserMembershipRole}.projectMembershipId`, - `${TableName.ProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.ProjectUserMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin(TableName.ProjectUserAdditionalPrivilege, (queryBuilder) => { - void queryBuilder - .on(`${TableName.ProjectUserAdditionalPrivilege}.projectId`, db.raw("?", [projectId])) - .andOn(`${TableName.ProjectUserAdditionalPrivilege}.userId`, `${TableName.Users}.id`); - }) - .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) - .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) - .join(TableName.OrgMembership, (qb) => { - void qb - .on(`${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) - .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`); - }) - .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) - .andOn(`${TableName.Organization}.id`, `${TableName.IdentityMetadata}.orgId`); - }) - .select( - db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("username").withSchema(TableName.Users).as("username"), - // groups specific - db.ref("id").withSchema(TableName.GroupProjectMembership).as("groupMembershipId"), - db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipUpdatedAt"), - db.ref("slug").withSchema("groupCustomRoles").as("userGroupProjectMembershipRoleCustomRoleSlug"), - db.ref("permissions").withSchema("groupCustomRoles").as("userGroupProjectMembershipRolePermission"), - db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRoleId"), - db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRole"), - db - .ref("customRoleId") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleCustomRoleId"), - db - .ref("isTemporary") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleIsTemporary"), - db - .ref("temporaryMode") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryMode"), - db - .ref("temporaryRange") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryRange"), - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.GroupProjectMembershipRole) - .as("userGroupProjectMembershipRoleTemporaryAccessEndTime"), - // user specific - db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), - db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("userProjectMembershipRoleCustomRoleSlug"), - db.ref("permissions").withSchema(TableName.ProjectRoles).as("userProjectCustomRolePermission"), - db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRoleId"), - db.ref("role").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRole"), - db - .ref("temporaryMode") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryMode"), - db - .ref("isTemporary") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleIsTemporary"), - db - .ref("temporaryRange") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryRange"), - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.ProjectUserMembershipRole) - .as("userProjectMembershipRoleTemporaryAccessEndTime"), - db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesId"), - db - .ref("permissions") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesPermissions"), - db - .ref("temporaryMode") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryMode"), - db - .ref("isTemporary") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesIsTemporary"), - db - .ref("temporaryRange") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryRange"), - db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesUserId"), - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.ProjectUserAdditionalPrivilege) - .as("userAdditionalPrivilegesTemporaryAccessEndTime"), - // general - db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), - db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), - db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), - db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), - db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), - db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), - db.ref("orgId").withSchema(TableName.Project), - db.ref("type").withSchema(TableName.Project).as("projectType"), - db.ref("id").withSchema(TableName.Project).as("projectId"), - db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization) - ); - - const [userPermission] = sqlNestRelationships({ - data: docs, - key: "projectId", - parentMapper: ({ - orgId, - username, - orgAuthEnforced, - orgGoogleSsoAuthEnforced, - orgRole, - membershipId, - groupMembershipId, - membershipCreatedAt, - groupMembershipCreatedAt, - groupMembershipUpdatedAt, - membershipUpdatedAt, - projectType, - shouldUseNewPrivilegeSystem, - bypassOrgAuthEnabled - }) => ({ - orgId, - orgAuthEnforced, - orgGoogleSsoAuthEnforced, - orgRole: orgRole as OrgMembershipRole, - userId, - projectId, - username, - projectType, - id: membershipId || groupMembershipId, - createdAt: membershipCreatedAt || groupMembershipCreatedAt, - updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt, - shouldUseNewPrivilegeSystem, - bypassOrgAuthEnabled - }), - childrenMapper: [ - { - key: "userGroupProjectMembershipRoleId", - label: "userGroupRoles" as const, - mapper: ({ - userGroupProjectMembershipRoleId, - userGroupProjectMembershipRole, - userGroupProjectMembershipRolePermission, - userGroupProjectMembershipRoleCustomRoleSlug, - userGroupProjectMembershipRoleIsTemporary, - userGroupProjectMembershipRoleTemporaryMode, - userGroupProjectMembershipRoleTemporaryAccessEndTime, - userGroupProjectMembershipRoleTemporaryAccessStartTime, - userGroupProjectMembershipRoleTemporaryRange - }) => ({ - id: userGroupProjectMembershipRoleId, - role: userGroupProjectMembershipRole, - customRoleSlug: userGroupProjectMembershipRoleCustomRoleSlug, - permissions: userGroupProjectMembershipRolePermission, - temporaryRange: userGroupProjectMembershipRoleTemporaryRange, - temporaryMode: userGroupProjectMembershipRoleTemporaryMode, - temporaryAccessStartTime: userGroupProjectMembershipRoleTemporaryAccessStartTime, - temporaryAccessEndTime: userGroupProjectMembershipRoleTemporaryAccessEndTime, - isTemporary: userGroupProjectMembershipRoleIsTemporary - }) - }, - { - key: "userProjectMembershipRoleId", - label: "projecMembershiptRoles" as const, - mapper: ({ - userProjectMembershipRoleId, - userProjectMembershipRole, - userProjectCustomRolePermission, - userProjectMembershipRoleIsTemporary, - userProjectMembershipRoleTemporaryMode, - userProjectMembershipRoleTemporaryRange, - userProjectMembershipRoleTemporaryAccessEndTime, - userProjectMembershipRoleTemporaryAccessStartTime, - userProjectMembershipRoleCustomRoleSlug - }) => ({ - id: userProjectMembershipRoleId, - role: userProjectMembershipRole, - customRoleSlug: userProjectMembershipRoleCustomRoleSlug, - permissions: userProjectCustomRolePermission, - temporaryRange: userProjectMembershipRoleTemporaryRange, - temporaryMode: userProjectMembershipRoleTemporaryMode, - temporaryAccessStartTime: userProjectMembershipRoleTemporaryAccessStartTime, - temporaryAccessEndTime: userProjectMembershipRoleTemporaryAccessEndTime, - isTemporary: userProjectMembershipRoleIsTemporary - }) - }, - { - key: "userAdditionalPrivilegesId", - label: "additionalPrivileges" as const, - mapper: ({ - userAdditionalPrivilegesId, - userAdditionalPrivilegesPermissions, - userAdditionalPrivilegesIsTemporary, - userAdditionalPrivilegesTemporaryMode, - userAdditionalPrivilegesTemporaryRange, - userAdditionalPrivilegesTemporaryAccessEndTime, - userAdditionalPrivilegesTemporaryAccessStartTime - }) => ({ - id: userAdditionalPrivilegesId, - permissions: userAdditionalPrivilegesPermissions, - temporaryRange: userAdditionalPrivilegesTemporaryRange, - temporaryMode: userAdditionalPrivilegesTemporaryMode, - temporaryAccessStartTime: userAdditionalPrivilegesTemporaryAccessStartTime, - temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime, - isTemporary: userAdditionalPrivilegesIsTemporary - }) - }, - { - key: "metadataId", - label: "metadata" as const, - mapper: ({ metadataKey, metadataValue, metadataId }) => ({ - id: metadataId, - key: metadataKey, - value: metadataValue - }) - } - ] - }); - - if (!userPermission) return undefined; - if (!userPermission?.userGroupRoles?.[0] && !userPermission?.projecMembershiptRoles?.[0]) return undefined; - - // when introducting cron mode change it here - const activeRoles = - userPermission?.projecMembershiptRoles?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ) ?? []; - - const activeGroupRoles = - userPermission?.userGroupRoles?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ) ?? []; - - const activeAdditionalPrivileges = - userPermission?.additionalPrivileges?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ) ?? []; - - return { - ...userPermission, - roles: [...activeRoles, ...activeGroupRoles], - additionalPrivileges: activeAdditionalPrivileges - }; - } catch (error) { - throw new DatabaseError({ error, name: "GetProjectPermission" }); - } - }; - const getProjectIdentityPermissions: TPermissionDALFactory["getProjectIdentityPermissions"] = async ( - projectId: string + projectId: string, + orgId: string ) => { try { const docs = await db - .replicaNode()(TableName.IdentityProjectMembership) - .join( - TableName.IdentityProjectMembershipRole, - `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, - `${TableName.IdentityProjectMembership}.id` - ) - .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityProjectMembership}.identityId`) - .leftJoin( - TableName.ProjectRoles, - `${TableName.IdentityProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin( - TableName.IdentityProjectAdditionalPrivilege, - `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`, - `${TableName.IdentityProjectMembership}.id` - ) - .join( - // Join the Project table to later select orgId - TableName.Project, - `${TableName.IdentityProjectMembership}.projectId`, - `${TableName.Project}.id` - ) + .replicaNode()(TableName.Membership) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .leftJoin(TableName.AdditionalPrivilege, (qb) => { + qb.on(`${TableName.Membership}.actorIdentityId`, `${TableName.AdditionalPrivilege}.actorIdentityId`).andOn( + `${TableName.Membership}.scopeOrgId`, + `${TableName.AdditionalPrivilege}.orgId` + ); + }) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder - .on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`${TableName.Project}.orgId`, `${TableName.IdentityMetadata}.orgId`); + .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); }) - .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .select(selectAllTableCols(TableName.MembershipRole)) .select( - db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), + db.ref("id").withSchema(TableName.Membership).as("membershipId"), db.ref("id").withSchema(TableName.Identity).as("identityId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("orgId").withSchema(TableName.Project).as("orgId"), // Now you can select orgId from Project - db.ref("type").withSchema(TableName.Project).as("projectType"), - db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("permissions").withSchema(TableName.ProjectRoles), - db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), - db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), - db - .ref("temporaryMode") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryMode"), - db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), - db - .ref("temporaryRange") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryRange"), + db.ref("createdAt").withSchema(TableName.Membership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.Membership).as("membershipUpdatedAt"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.Role), + db.ref("id").withSchema(TableName.AdditionalPrivilege).as("identityApId"), + db.ref("permissions").withSchema(TableName.AdditionalPrivilege).as("identityApPermissions"), + db.ref("temporaryMode").withSchema(TableName.AdditionalPrivilege).as("identityApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.AdditionalPrivilege).as("identityApIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.AdditionalPrivilege).as("identityApTemporaryRange"), db .ref("temporaryAccessStartTime") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .withSchema(TableName.AdditionalPrivilege) .as("identityApTemporaryAccessStartTime"), db .ref("temporaryAccessEndTime") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .withSchema(TableName.AdditionalPrivilege) .as("identityApTemporaryAccessEndTime"), db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), @@ -1222,15 +705,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { const permissions = sqlNestRelationships({ data: docs, key: "identityId", - parentMapper: ({ - membershipId, - membershipCreatedAt, - membershipUpdatedAt, - orgId, - identityName, - projectType, - identityId - }) => ({ + parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, identityName, identityId }) => ({ id: membershipId, identityId, username: identityName, @@ -1238,7 +713,6 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { createdAt: membershipCreatedAt, updatedAt: membershipUpdatedAt, orgId, - projectType, // just a prefilled value orgAuthEnforced: false }), @@ -1247,7 +721,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { key: "id", label: "roles" as const, mapper: (data) => - IdentityProjectMembershipRoleSchema.extend({ + MembershipRolesSchema.extend({ permissions: z.unknown(), customRoleSlug: z.string().optional().nullable() }).parse(data) @@ -1309,170 +783,10 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { } }; - const getProjectIdentityPermission: TPermissionDALFactory["getProjectIdentityPermission"] = async ( - identityId, - projectId - ) => { - try { - const docs = await db - .replicaNode()(TableName.IdentityProjectMembership) - .join( - TableName.IdentityProjectMembershipRole, - `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, - `${TableName.IdentityProjectMembership}.id` - ) - .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityProjectMembership}.identityId`) - .leftJoin( - TableName.ProjectRoles, - `${TableName.IdentityProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin( - TableName.IdentityProjectAdditionalPrivilege, - `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`, - `${TableName.IdentityProjectMembership}.id` - ) - .join( - // Join the Project table to later select orgId - TableName.Project, - `${TableName.IdentityProjectMembership}.projectId`, - `${TableName.Project}.id` - ) - .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) - .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`${TableName.Project}.orgId`, `${TableName.IdentityMetadata}.orgId`); - }) - .where(`${TableName.IdentityProjectMembership}.identityId`, identityId) - .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) - .select( - db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), - db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("orgId").withSchema(TableName.Project).as("orgId"), // Now you can select orgId from Project - db.ref("type").withSchema(TableName.Project).as("projectType"), - db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), - db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("permissions").withSchema(TableName.ProjectRoles), - db.ref("shouldUseNewPrivilegeSystem").withSchema(TableName.Organization), - db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), - db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), - db - .ref("temporaryMode") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryMode"), - db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), - db - .ref("temporaryRange") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryRange"), - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryAccessEndTime"), - db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), - db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") - ); - - const permission = sqlNestRelationships({ - data: docs, - key: "membershipId", - parentMapper: ({ - membershipId, - membershipCreatedAt, - membershipUpdatedAt, - orgId, - identityName, - projectType, - shouldUseNewPrivilegeSystem - }) => ({ - id: membershipId, - identityId, - username: identityName, - projectId, - createdAt: membershipCreatedAt, - updatedAt: membershipUpdatedAt, - orgId, - projectType, - shouldUseNewPrivilegeSystem, - // just a prefilled value - orgAuthEnforced: false - }), - childrenMapper: [ - { - key: "id", - label: "roles" as const, - mapper: (data) => - IdentityProjectMembershipRoleSchema.extend({ - permissions: z.unknown(), - customRoleSlug: z.string().optional().nullable() - }).parse(data) - }, - { - key: "identityApId", - label: "additionalPrivileges" as const, - mapper: ({ - identityApId, - identityApPermissions, - identityApIsTemporary, - identityApTemporaryMode, - identityApTemporaryRange, - identityApTemporaryAccessEndTime, - identityApTemporaryAccessStartTime - }) => ({ - id: identityApId, - permissions: identityApPermissions, - temporaryRange: identityApTemporaryRange, - temporaryMode: identityApTemporaryMode, - temporaryAccessEndTime: identityApTemporaryAccessEndTime, - temporaryAccessStartTime: identityApTemporaryAccessStartTime, - isTemporary: identityApIsTemporary - }) - }, - { - key: "metadataId", - label: "metadata" as const, - mapper: ({ metadataKey, metadataValue, metadataId }) => ({ - id: metadataId, - key: metadataKey, - value: metadataValue - }) - } - ] - }); - - if (!permission?.[0]) return undefined; - - // when introducting cron mode change it here - const activeRoles = permission?.[0]?.roles.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ); - const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ); - - return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; - } catch (error) { - throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); - } - }; - return { - getOrgPermission, - getOrgIdentityPermission, - getProjectPermission, - getProjectIdentityPermission, getProjectUserPermissions, getProjectIdentityPermissions, - getProjectGroupPermissions + getProjectGroupPermissions, + getPermission }; }; diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index 7d61d7499..0da5ad3b2 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -2,7 +2,7 @@ import { ForbiddenError, MongoAbility, PureAbility, subject } from "@casl/ability"; import { z } from "zod"; -import { OrgMembershipRole, TOrganizations } from "@app/db/schemas"; +import { TOrganizations } from "@app/db/schemas"; import { validatePermissionBoundary } from "@app/lib/casl/boundary"; import { BadRequestError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorAuthMethod, AuthMethod } from "@app/services/auth/auth-type"; @@ -123,13 +123,13 @@ function validateOrgSSO( isOrgSsoEnforced: TOrganizations["authEnforced"], isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], - orgRole: OrgMembershipRole + isAdmin: boolean ) { if (actorAuthMethod === undefined) { throw new UnauthorizedError({ name: "No auth method defined" }); } - if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && isAdmin) { return; } diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index f25b84bd5..c69564b31 100644 --- a/backend/src/ee/services/permission/permission-service-types.ts +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -1,8 +1,8 @@ -import { MongoAbility, RawRuleOf } from "@casl/ability"; +import { MongoAbility } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; import { Knex } from "knex"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope, TMemberships } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionSet } from "./org-permission"; @@ -18,21 +18,6 @@ export type TBuildOrgPermissionDTO = { role: string; }[]; -export type TGetUserProjectPermissionArg = { - userId: string; - projectId: string; - authMethod: ActorAuthMethod; - actionProjectType: ActionProjectType; - userOrgId?: string; -}; - -export type TGetIdentityProjectPermissionArg = { - identityId: string; - projectId: string; - identityOrgId?: string; - actionProjectType: ActionProjectType; -}; - export type TGetServiceTokenProjectPermissionArg = { serviceTokenId: string; projectId: string; @@ -49,232 +34,85 @@ export type TGetProjectPermissionArg = { actionProjectType: ActionProjectType; }; +export type TGetOrgPermissionArg = { + actor: ActorType; + actorId: string; + orgId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + scope: OrganizationActionScope; +}; + export type TPermissionServiceFactory = { - getUserOrgPermission: ( - userId: string, - orgId: string, - authMethod: ActorAuthMethod, - userOrgId?: string - ) => Promise<{ + getOrgPermission: (arg: TGetOrgPermissionArg) => Promise<{ permission: MongoAbility; - membership: { - status: string; - orgId: string; - id: string; - createdAt: Date; - updatedAt: Date; - role: string; - isActive: boolean; - shouldUseNewPrivilegeSystem: boolean; - bypassOrgAuthEnabled: boolean; - permissions?: unknown; - userId?: string | null | undefined; - roleId?: string | null | undefined; - inviteEmail?: string | null | undefined; - projectFavorites?: string[] | null | undefined; - customRoleSlug?: string | null | undefined; - orgAuthEnforced?: boolean | null | undefined; - } & { - groups: { - id: string; - updatedAt: Date; - createdAt: Date; - role: string; - roleId: string | null | undefined; - customRolePermission: unknown; - name: string; - slug: string; - orgId: string; - }[]; - }; - }>; - getOrgPermission: ( - type: ActorType, - id: string, - orgId: string, - authMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => Promise< - | { - permission: MongoAbility; - membership: { - status: string; - orgId: string; - id: string; - createdAt: Date; - updatedAt: Date; - role: string; - isActive: boolean; - shouldUseNewPrivilegeSystem: boolean; - bypassOrgAuthEnabled: boolean; - permissions?: unknown; - userId?: string | null | undefined; - roleId?: string | null | undefined; - inviteEmail?: string | null | undefined; - projectFavorites?: string[] | null | undefined; - customRoleSlug?: string | null | undefined; - orgAuthEnforced?: boolean | null | undefined; - } & { - groups: { - id: string; - updatedAt: Date; - createdAt: Date; - role: string; - roleId: string | null | undefined; - customRolePermission: unknown; - name: string; - slug: string; - orgId: string; - }[]; - }; + memberships: Array< + TMemberships & { + roles: { role: string; customRoleSlug?: string | null }[]; + shouldUseNewPrivilegeSystem?: boolean | null; } - | { - permission: MongoAbility; - membership: { - id: string; - role: string; - createdAt: Date; - updatedAt: Date; - orgId: string; - roleId?: string | null | undefined; - permissions?: unknown; - identityId: string; - orgAuthEnforced: boolean | null | undefined; - shouldUseNewPrivilegeSystem: boolean; - }; - } - >; - getUserProjectPermission: ({ - userId, - projectId, - authMethod, - userOrgId, - actionProjectType - }: TGetUserProjectPermissionArg) => Promise<{ - permission: MongoAbility; - membership: { - id: string; - createdAt: Date; - updatedAt: Date; - userId: string; - projectId: string; - } & { - orgAuthEnforced: boolean | null | undefined; - orgId: string; - roles: Array<{ - role: string; - }>; - shouldUseNewPrivilegeSystem: boolean; - }; + >; hasRole: (role: string) => boolean; }>; - getProjectPermission: ( - arg: TGetProjectPermissionArg - ) => Promise< - T extends ActorType.SERVICE - ? { - permission: MongoAbility; - membership: { - shouldUseNewPrivilegeSystem: boolean; - }; - hasRole: (arg: string) => boolean; - } - : { - permission: MongoAbility; - membership: (T extends ActorType.USER - ? { - id: string; - createdAt: Date; - updatedAt: Date; - userId: string; - projectId: string; - } - : { - id: string; - createdAt: Date; - updatedAt: Date; - projectId: string; - identityId: string; - }) & { - orgAuthEnforced: boolean | null | undefined; - orgId: string; - roles: Array<{ - role: string; - }>; - shouldUseNewPrivilegeSystem: boolean; - }; - hasRole: (role: string) => boolean; - } - >; - getProjectPermissions: (projectId: string) => Promise<{ + getProjectPermission: (arg: TGetProjectPermissionArg) => Promise<{ + permission: MongoAbility; + memberships: Array; + hasRole: (role: string) => boolean; + }>; + getProjectPermissions: ( + projectId: string, + orgId: string + ) => Promise<{ userPermissions: { permission: MongoAbility; id: string; name: string; - membershipId: string; }[]; identityPermissions: { permission: MongoAbility; id: string; name: string; - membershipId: string; }[]; groupPermissions: { permission: MongoAbility; id: string; name: string; - membershipId: string; }[]; }>; - getOrgPermissionByRole: ( - role: string, + getOrgPermissionByRoles: ( + roles: string[], orgId: string ) => Promise< - | { - permission: MongoAbility; - role: { - name: string; - orgId: string; - id: string; - createdAt: Date; - updatedAt: Date; - slug: string; - permissions?: unknown; - description?: string | null | undefined; - }; - } - | { - permission: MongoAbility; - role?: undefined; - } + { + permission: MongoAbility; + role?: { + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + slug: string; + permissions?: unknown; + description?: string | null | undefined; + }; + }[] >; - getProjectPermissionByRole: ( - role: string, + getProjectPermissionByRoles: ( + roles: string[], projectId: string ) => Promise< - | { - permission: MongoAbility; - role: { - name: string; - version: number; - id: string; - createdAt: Date; - updatedAt: Date; - projectId: string; - slug: string; - permissions?: unknown; - description?: string | null | undefined; - }; - } - | { - permission: MongoAbility; - role?: undefined; - } + { + permission: MongoAbility; + role?: { + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + slug: string; + permissions?: unknown; + description?: string | null | undefined; + }; + }[] >; - buildOrgPermission: (orgUserRoles: TBuildOrgPermissionDTO) => MongoAbility; - buildProjectPermissionRules: ( - projectUserRoles: TBuildProjectPermissionDTO - ) => RawRuleOf>[]; checkGroupProjectPermission: ({ groupId, projectId, diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 9e94e1415..48b78d980 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -1,17 +1,16 @@ import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, unpackRules } from "@casl/ability/extra"; import { requestContext } from "@fastify/request-context"; -import { MongoQuery } from "@ucast/mongo2js"; import handlebars from "handlebars"; import { Knex } from "knex"; import { + AccessScope, ActionProjectType, + OrganizationActionScope, OrgMembershipRole, ProjectMembershipRole, - ServiceTokenScopes, - TIdentityProjectMemberships, - TProjectMemberships + ServiceTokenScopes } from "@app/db/schemas"; import { cryptographicOperatorPermissions, @@ -25,12 +24,12 @@ import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/ import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { objectify } from "@app/lib/fn"; -import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; -import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { TProjectRoleDALFactory } from "@app/services/project-role/project-role-dal"; +import { TRoleDALFactory } from "@app/services/role/role-dal"; import { TServiceTokenDALFactory } from "@app/services/service-token/service-token-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { TPermissionDALFactory } from "./permission-dal"; @@ -38,56 +37,86 @@ import { escapeHandlebarsMissingDict, validateOrgSSO } from "./permission-fns"; import { TBuildOrgPermissionDTO, TBuildProjectPermissionDTO, - TGetIdentityProjectPermissionArg, - TGetProjectPermissionArg, TGetServiceTokenProjectPermissionArg, - TGetUserProjectPermissionArg, TPermissionServiceFactory } from "./permission-service-types"; import { buildServiceTokenProjectPermission, ProjectPermissionSet } from "./project-permission"; +const buildOrgPermissionRules = (orgUserRoles: TBuildOrgPermissionDTO) => { + const rules = orgUserRoles + .map(({ role, permissions }) => { + switch (role) { + case OrgMembershipRole.Admin: + return orgAdminPermissions; + case OrgMembershipRole.Member: + return orgMemberPermissions; + case OrgMembershipRole.NoAccess: + return orgNoAccessPermissions; + case OrgMembershipRole.Custom: + return unpackRules>>( + permissions as PackRule>>[] + ); + default: + throw new NotFoundError({ name: "OrgRoleInvalid", message: `Organization role '${role}' not found` }); + } + }) + .reduce((prev, curr) => prev.concat(curr), []); + + return rules; +}; + +const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => { + const rules = projectUserRoles + .map(({ role, permissions }) => { + switch (role) { + case ProjectMembershipRole.Admin: + return projectAdminPermissions; + case ProjectMembershipRole.Member: + return projectMemberPermissions; + case ProjectMembershipRole.Viewer: + return projectViewerPermission; + case ProjectMembershipRole.NoAccess: + return projectNoAccessPermissions; + case ProjectMembershipRole.SshHostBootstrapper: + return sshHostBootstrapPermissions; + case ProjectMembershipRole.KmsCryptographicOperator: + return cryptographicOperatorPermissions; + case ProjectMembershipRole.Custom: { + return unpackRules>>( + permissions as PackRule>>[] + ); + } + default: + throw new NotFoundError({ + name: "ProjectRoleInvalid", + message: `Project role '${role}' not found` + }); + } + }) + .reduce((prev, curr) => prev.concat(curr), []); + + return rules; +}; + type TPermissionServiceFactoryDep = { - orgRoleDAL: Pick; - projectRoleDAL: Pick; serviceTokenDAL: Pick; projectDAL: Pick; permissionDAL: TPermissionDALFactory; keyStore: TKeyStoreFactory; + userDAL: Pick; + identityDAL: Pick; + roleDAL: Pick; }; export const permissionServiceFactory = ({ permissionDAL, - orgRoleDAL, - projectRoleDAL, serviceTokenDAL, projectDAL, - keyStore + userDAL, + identityDAL, + keyStore, + roleDAL }: TPermissionServiceFactoryDep): TPermissionServiceFactory => { - const buildOrgPermission = (orgUserRoles: TBuildOrgPermissionDTO) => { - const rules = orgUserRoles - .map(({ role, permissions }) => { - switch (role) { - case OrgMembershipRole.Admin: - return orgAdminPermissions; - case OrgMembershipRole.Member: - return orgMemberPermissions; - case OrgMembershipRole.NoAccess: - return orgNoAccessPermissions; - case OrgMembershipRole.Custom: - return unpackRules>>( - permissions as PackRule>>[] - ); - default: - throw new NotFoundError({ name: "OrgRoleInvalid", message: `Organization role '${role}' not found` }); - } - }) - .reduce((prev, curr) => prev.concat(curr), []); - - return createMongoAbility(rules, { - conditionsMatcher - }); - }; - const invalidateProjectPermissionCache = async (projectId: string, tx?: Knex) => { const projectPermissionDalVersionKey = KeyStorePrefixes.ProjectPermissionDalVersion(projectId); await keyStore.pgIncrementBy(projectPermissionDalVersionKey, { @@ -97,348 +126,132 @@ export const permissionServiceFactory = ({ }); }; - const calculateProjectPermissionTtl = (membership: unknown): number => { - const now = new Date(); - let minTtl = KeyStoreTtls.ProjectPermissionCacheInSeconds; + // akhilmdhh: will bring this up later + // const calculateProjectPermissionTtl = (membership: unknown): number => { + // const now = new Date(); + // let minTtl = KeyStoreTtls.ProjectPermissionCacheInSeconds; + // + // const getMinEndTime = (items: Array<{ temporaryAccessEndTime?: Date | null; isTemporary?: boolean }>) => { + // return items + // .filter((item) => item.isTemporary && item.temporaryAccessEndTime) + // .map((item) => item.temporaryAccessEndTime!) + // .filter((endTime) => endTime > now) + // .reduce((min, endTime) => (!min || endTime < min ? endTime : min), null as Date | null); + // }; + // + // const roleTimes: Date[] = []; + // const additionalPrivilegeTimes: Date[] = []; + // + // if ( + // membership && + // typeof membership === "object" && + // "roles" in membership && + // Array.isArray((membership as Record).roles) + // ) { + // const roles = (membership as Record).roles as Array<{ + // temporaryAccessEndTime?: Date | null; + // isTemporary?: boolean; + // }>; + // const minRoleEndTime = getMinEndTime(roles); + // if (minRoleEndTime) roleTimes.push(minRoleEndTime); + // } + // + // if ( + // membership && + // typeof membership === "object" && + // "additionalPrivileges" in membership && + // Array.isArray((membership as Record).additionalPrivileges) + // ) { + // const additionalPrivileges = (membership as Record).additionalPrivileges as Array<{ + // temporaryAccessEndTime?: Date | null; + // isTemporary?: boolean; + // }>; + // const minAdditionalEndTime = getMinEndTime(additionalPrivileges); + // if (minAdditionalEndTime) additionalPrivilegeTimes.push(minAdditionalEndTime); + // } + // + // const allEndTimes = [...roleTimes, ...additionalPrivilegeTimes]; + // if (allEndTimes.length > 0) { + // const nearestEndTime = allEndTimes.reduce((min, endTime) => (!min || endTime < min ? endTime : min)); + // const timeUntilExpiry = Math.floor((nearestEndTime.getTime() - now.getTime()) / 1000); + // minTtl = Math.min(minTtl, Math.max(1, timeUntilExpiry)); + // } + // + // return minTtl; + // }; - const getMinEndTime = (items: Array<{ temporaryAccessEndTime?: Date | null; isTemporary?: boolean }>) => { - return items - .filter((item) => item.isTemporary && item.temporaryAccessEndTime) - .map((item) => item.temporaryAccessEndTime!) - .filter((endTime) => endTime > now) - .reduce((min, endTime) => (!min || endTime < min ? endTime : min), null as Date | null); - }; - - const roleTimes: Date[] = []; - const additionalPrivilegeTimes: Date[] = []; - - if ( - membership && - typeof membership === "object" && - "roles" in membership && - Array.isArray((membership as Record).roles) - ) { - const roles = (membership as Record).roles as Array<{ - temporaryAccessEndTime?: Date | null; - isTemporary?: boolean; - }>; - const minRoleEndTime = getMinEndTime(roles); - if (minRoleEndTime) roleTimes.push(minRoleEndTime); - } - - if ( - membership && - typeof membership === "object" && - "additionalPrivileges" in membership && - Array.isArray((membership as Record).additionalPrivileges) - ) { - const additionalPrivileges = (membership as Record).additionalPrivileges as Array<{ - temporaryAccessEndTime?: Date | null; - isTemporary?: boolean; - }>; - const minAdditionalEndTime = getMinEndTime(additionalPrivileges); - if (minAdditionalEndTime) additionalPrivilegeTimes.push(minAdditionalEndTime); - } - - const allEndTimes = [...roleTimes, ...additionalPrivilegeTimes]; - if (allEndTimes.length > 0) { - const nearestEndTime = allEndTimes.reduce((min, endTime) => (!min || endTime < min ? endTime : min)); - const timeUntilExpiry = Math.floor((nearestEndTime.getTime() - now.getTime()) / 1000); - minTtl = Math.min(minTtl, Math.max(1, timeUntilExpiry)); - } - - return minTtl; - }; - - const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => { - const rules = projectUserRoles - .map(({ role, permissions }) => { - switch (role) { - case ProjectMembershipRole.Admin: - return projectAdminPermissions; - case ProjectMembershipRole.Member: - return projectMemberPermissions; - case ProjectMembershipRole.Viewer: - return projectViewerPermission; - case ProjectMembershipRole.NoAccess: - return projectNoAccessPermissions; - case ProjectMembershipRole.SshHostBootstrapper: - return sshHostBootstrapPermissions; - case ProjectMembershipRole.KmsCryptographicOperator: - return cryptographicOperatorPermissions; - case ProjectMembershipRole.Custom: { - return unpackRules>>( - permissions as PackRule>>[] - ); - } - default: - throw new NotFoundError({ - name: "ProjectRoleInvalid", - message: `Project role '${role}' not found` - }); - } - }) - .reduce((prev, curr) => prev.concat(curr), []); - - return rules; - }; - - /* - * Get user permission in an organization - */ - const getUserOrgPermission: TPermissionServiceFactory["getUserOrgPermission"] = async ( - userId, + const getOrgPermission: TPermissionServiceFactory["getOrgPermission"] = async ({ + actor, + actorId, orgId, - authMethod, - userOrgId - ) => { - // when token is scoped, ensure the passed org id is same as user org id - if (userOrgId && userOrgId !== orgId) - throw new ForbiddenRequestError({ message: "Invalid user token. Scoped to different organization." }); - const membership = await permissionDAL.getOrgPermission(userId, orgId); - if (!membership) throw new ForbiddenRequestError({ name: "You are not apart of this organization" }); - if (membership.role === OrgMembershipRole.Custom && !membership.permissions) { - throw new BadRequestError({ name: "Custom organization permission not found" }); + actorOrgId, + scope, + actorAuthMethod + }) => { + if (actor !== ActorType.USER && actor !== ActorType.IDENTITY) { + throw new BadRequestError({ + message: "Invalid actor provided", + name: "Get org permission" + }); } - // If the org ID is API_KEY, the request is being made with an API Key. - // Since we can't scope API keys to an organization, we'll need to do an arbitrary check to see if the user is a member of the organization. - - // Extra: This means that when users are using API keys to make requests, they can't use slug-based routes. - // Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization. - if (userOrgId !== "API_KEY" && membership.orgId !== userOrgId) { + if (orgId !== actorOrgId) { throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); } - validateOrgSSO( - authMethod, - membership.orgAuthEnforced, - membership.orgGoogleSsoAuthEnforced, - membership.bypassOrgAuthEnabled, - membership.role as OrgMembershipRole - ); - - const finalPolicyRoles = [{ role: membership.role, permissions: membership.permissions }].concat( - membership?.groups?.map(({ role, customRolePermission }) => ({ - role, - permissions: customRolePermission - })) || [] - ); - return { permission: buildOrgPermission(finalPolicyRoles), membership }; - }; - - const getIdentityOrgPermission = async (identityId: string, orgId: string) => { - const membership = await permissionDAL.getOrgIdentityPermission(identityId, orgId); - if (!membership) throw new ForbiddenRequestError({ name: "Identity is not apart of this organization" }); - if (membership.role === OrgMembershipRole.Custom && !membership.permissions) { - throw new NotFoundError({ name: `Custom organization permission not found for identity ${identityId}` }); - } - return { - permission: buildOrgPermission([{ role: membership.role, permissions: membership.permissions }]), - membership - }; - }; - - const getOrgPermission: TPermissionServiceFactory["getOrgPermission"] = async ( - type, - id, - orgId, - authMethod, - actorOrgId - ) => { - switch (type) { - case ActorType.USER: - return getUserOrgPermission(id, orgId, authMethod, actorOrgId); - case ActorType.IDENTITY: - return getIdentityOrgPermission(id, orgId); - default: - throw new BadRequestError({ - message: "Invalid actor provided", - name: "Get org permission" - }); - } - }; - - // instead of actor type this will fetch by role slug. meaning it can be the pre defined slugs like - // admin member or user defined ones like biller etc - const getOrgPermissionByRole: TPermissionServiceFactory["getOrgPermissionByRole"] = async (role, orgId) => { - const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); - if (isCustomRole) { - const orgRole = await orgRoleDAL.findOne({ slug: role, orgId }); - if (!orgRole) - throw new NotFoundError({ - message: `Specified role '${role}' was not found in the organization with ID '${orgId}'` - }); - return { - permission: buildOrgPermission([{ role: OrgMembershipRole.Custom, permissions: orgRole.permissions }]), - role: orgRole - }; - } - return { permission: buildOrgPermission([{ role, permissions: [] }]) }; - }; - - // user permission for a project in an organization - const getUserProjectPermission = async ({ - userId, - projectId, - authMethod, - userOrgId, - actionProjectType - }: TGetUserProjectPermissionArg): Promise> => { - const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); - if (!userProjectPermission) throw new ForbiddenRequestError({ name: "User not a part of the specified project" }); - - if ( - userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions) - ) { - throw new NotFoundError({ name: "The permission was not found" }); - } - - // If the org ID is API_KEY, the request is being made with an API Key. - // Since we can't scope API keys to an organization, we'll need to do an arbitrary check to see if the user is a member of the organization. - - // Extra: This means that when users are using API keys to make requests, they can't use slug-based routes. - // Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization. - if (userOrgId !== "API_KEY" && userProjectPermission.orgId !== userOrgId) { - throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); - } - - validateOrgSSO( - authMethod, - userProjectPermission.orgAuthEnforced, - userProjectPermission.orgGoogleSsoAuthEnforced, - userProjectPermission.bypassOrgAuthEnabled, - userProjectPermission.orgRole - ); - - if (actionProjectType !== ActionProjectType.Any && actionProjectType !== userProjectPermission.projectType) { - throw new BadRequestError({ - message: `The project is of type ${userProjectPermission.projectType}. Operations of type ${actionProjectType} are not allowed.` - }); - } - - // join two permissions and pass to build the final permission set - const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; - const additionalPrivileges = - userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ - role: ProjectMembershipRole.Custom, - permissions - })) || []; - - const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); - const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const unescapedMetadata = objectify( - userProjectPermission.metadata, - (i) => i.key, - (i) => i.value - ); - const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); - requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata }); - const interpolateRules = templatedRules( - { - identity: { - id: userProjectPermission.userId, - username: userProjectPermission.username, - metadata: metadataKeyValuePair - } + const permissionData = await permissionDAL.getPermission({ + scopeData: { + scope: AccessScope.Organization, + orgId }, - { data: false } - ); - const permission = createMongoAbility( - JSON.parse(interpolateRules) as RawRuleOf>[], - { - conditionsMatcher - } + actorId, + actorType: actor + }); + if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this organization" }); + + const rootOrgId = permissionData?.[0]?.rootOrgId; + const isChild = Boolean(rootOrgId); + if (scope === OrganizationActionScope.ParentOrganization && isChild) { + throw new ForbiddenRequestError({ message: `Child organization cannot do this operation` }); + } else if (scope === OrganizationActionScope.ChildOrganization && !isChild) { + throw new ForbiddenRequestError({ message: `Parent organization cannot do this operation` }); + } + + const permissionFromRoles = permissionData.flatMap((membership) => { + const activeRoles = membership?.roles + .filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) + .map(({ role, permissions }) => ({ role, permissions })); + const activeAdditionalPrivileges = membership?.additionalPrivileges + .filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) + .map(({ permissions }) => ({ role: OrgMembershipRole.Custom, permissions })); + return activeRoles.concat(activeAdditionalPrivileges); + }); + + const hasRole = (role: string) => + permissionData.some((memberships) => memberships.roles.some((el) => role === (el.customRoleSlug || el.role))); + + validateOrgSSO( + actorAuthMethod, + permissionData?.[0].orgAuthEnforced, + Boolean(permissionData?.[0].orgGoogleSsoAuthEnforced), + Boolean(permissionData?.[0].bypassOrgAuthEnabled), + hasRole(OrgMembershipRole.Admin) ); + const permission = createMongoAbility(buildOrgPermissionRules(permissionFromRoles), { + conditionsMatcher + }); + return { permission, - membership: userProjectPermission, - hasRole: (role: string) => - userProjectPermission.roles.findIndex( - ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug - ) !== -1 - }; - }; - - const getIdentityProjectPermission = async ({ - identityId, - projectId, - identityOrgId, - actionProjectType - }: TGetIdentityProjectPermissionArg): Promise> => { - const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId); - if (!identityProjectPermission) - throw new ForbiddenRequestError({ - name: `Identity is not a member of the specified project with ID '${projectId}'` - }); - - if ( - identityProjectPermission.roles.some( - ({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions - ) - ) { - throw new NotFoundError({ name: "Custom permission not found" }); - } - - if (identityProjectPermission.orgId !== identityOrgId) { - throw new ForbiddenRequestError({ name: "Identity is not a member of the specified organization" }); - } - - if (actionProjectType !== ActionProjectType.Any && actionProjectType !== identityProjectPermission.projectType) { - throw new BadRequestError({ - message: `The project is of type ${identityProjectPermission.projectType}. Operations of type ${actionProjectType} are not allowed.` - }); - } - - const rolePermissions = - identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; - const additionalPrivileges = - identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ - role: ProjectMembershipRole.Custom, - permissions - })) || []; - - const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); - const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); - const unescapedIdentityAuthInfo = requestContext.get("identityAuthInfo"); - const unescapedMetadata = objectify( - identityProjectPermission.metadata, - (i) => i.key, - (i) => i.value - ); - const identityAuthInfo = - unescapedIdentityAuthInfo?.identityId === identityId && unescapedIdentityAuthInfo - ? escapeHandlebarsMissingDict(unescapedIdentityAuthInfo as never, "identity.auth") - : {}; - const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); - - requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata, auth: unescapedIdentityAuthInfo }); - const interpolateRules = templatedRules( - { - identity: { - id: identityProjectPermission.identityId, - username: identityProjectPermission.username, - metadata: metadataKeyValuePair, - auth: identityAuthInfo - } - }, - { data: false } - ); - const permission = createMongoAbility( - JSON.parse(interpolateRules) as RawRuleOf>[], - { - conditionsMatcher - } - ); - - return { - permission, - membership: identityProjectPermission, - hasRole: (role: string) => - identityProjectPermission.roles.findIndex( - ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug - ) !== -1 + memberships: permissionData, + hasRole }; }; @@ -455,10 +268,6 @@ export const permissionServiceFactory = ({ if (!serviceTokenProject) throw new BadRequestError({ message: "Service token not linked to a project" }); - if (serviceTokenProject.orgId !== actorOrgId) { - throw new ForbiddenRequestError({ message: "Service token not a part of the specified organization" }); - } - if (serviceToken.projectId !== projectId) { throw new ForbiddenRequestError({ name: `Service token not a part of the specified project with ID ${projectId}` @@ -480,34 +289,156 @@ export const permissionServiceFactory = ({ const scopes = ServiceTokenScopes.parse(serviceToken.scopes || []); return { permission: buildServiceTokenProjectPermission(scopes, serviceToken.permissions), - membership: { - shouldUseNewPrivilegeSystem: true - } + memberships: [], + hasRole: () => false }; }; - type TProjectPermissionRT = T extends ActorType.SERVICE - ? { - permission: MongoAbility; - membership: { - shouldUseNewPrivilegeSystem: boolean; - }; - hasRole: (arg: string) => boolean; - } // service token doesn't have both membership and roles - : { - permission: MongoAbility; - membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { - orgAuthEnforced: boolean | null | undefined; - orgId: string; - roles: Array<{ role: string }>; - shouldUseNewPrivilegeSystem: boolean; - }; - hasRole: (role: string) => boolean; - }; + const getProjectPermission: TPermissionServiceFactory["getProjectPermission"] = async ({ + actor: inputActor, + actorId: inputActorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType + }) => { + let actor = inputActor; + let actorId = inputActorId; - const getProjectPermissions: TPermissionServiceFactory["getProjectPermissions"] = async (projectId) => { + if (actor === ActorType.SERVICE) { + return getServiceTokenProjectPermission({ + serviceTokenId: actorId, + projectId, + actorOrgId, + actionProjectType + }); + } + + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + if ( + assumedPrivilegeDetailsCtx && + actor === ActorType.USER && + actorId === assumedPrivilegeDetailsCtx.requesterId && + projectId === assumedPrivilegeDetailsCtx.projectId + ) { + actor = assumedPrivilegeDetailsCtx.actorType; + actorId = assumedPrivilegeDetailsCtx.actorId; + } + + if (ActorType.USER !== actor && ActorType.IDENTITY !== actor) { + throw new BadRequestError({ + message: "Invalid actor provided", + name: "Get org permission" + }); + } + + const projectDetails = await projectDAL.findById(projectId); + if (!projectDetails) { + throw new NotFoundError({ message: `Project with ${projectId} not found` }); + } + + if (projectDetails.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); + } + + if (actionProjectType !== ActionProjectType.Any && actionProjectType !== projectDetails.type) { + throw new BadRequestError({ + message: `The project is of type ${projectDetails.type}. Operations of type ${actionProjectType} are not allowed.` + }); + } + + const permissionData = await permissionDAL.getPermission({ + scopeData: { + scope: AccessScope.Project, + orgId: projectDetails.orgId, + projectId + }, + actorId, + actorType: actor + }); + if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this organization" }); + + const permissionFromRoles = permissionData.flatMap((membership) => { + const activeRoles = membership?.roles + .filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) + .map(({ role, permissions }) => ({ role, permissions })); + const activeAdditionalPrivileges = membership?.additionalPrivileges + .filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) + .map(({ permissions }) => ({ role: ProjectMembershipRole.Custom, permissions })); + return activeRoles.concat(activeAdditionalPrivileges); + }); + + const hasRole = (role: string) => + permissionData.some((memberships) => memberships.roles.some((el) => role === (el.customRoleSlug || el.role))); + + validateOrgSSO( + actorAuthMethod, + permissionData?.[0].orgAuthEnforced, + Boolean(permissionData?.[0].orgGoogleSsoAuthEnforced), + Boolean(permissionData?.[0].bypassOrgAuthEnabled), + hasRole(ProjectMembershipRole.Admin) + ); + + const rules = buildProjectPermissionRules(permissionFromRoles); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const unescapedMetadata = objectify( + permissionData?.[0]?.metadata, + (i) => i.key, + (i) => i.value + ); + const metadataKeyValuePair = escapeHandlebarsMissingDict(unescapedMetadata, "identity.metadata"); + requestContext.set("identityPermissionMetadata", { metadata: unescapedMetadata }); + + let username = ""; + if (actor === ActorType.USER) { + const userDetails = await userDAL.findById(actorId); + username = userDetails?.username; + } else { + const identityDetails = await identityDAL.findById(actorId); + username = identityDetails?.name; + } + + const unescapedIdentityAuthInfo = requestContext.get("identityAuthInfo"); + const identityAuthInfo = + unescapedIdentityAuthInfo?.identityId === actorId && unescapedIdentityAuthInfo + ? escapeHandlebarsMissingDict(unescapedIdentityAuthInfo as never, "identity.auth") + : {}; + + const interpolateRules = templatedRules( + { + identity: { + id: actorId, + username, + metadata: metadataKeyValuePair, + auth: identityAuthInfo + } + }, + { data: false } + ); + + const permission = createMongoAbility( + JSON.parse(interpolateRules) as RawRuleOf>[], + { + conditionsMatcher + } + ); + + return { + permission, + memberships: permissionData, + hasRole + }; + }; + + const getProjectPermissions: TPermissionServiceFactory["getProjectPermissions"] = async (projectId, orgId) => { // fetch user permissions - const rawUserProjectPermissions = await permissionDAL.getProjectUserPermissions(projectId); + const rawUserProjectPermissions = await permissionDAL.getProjectUserPermissions(projectId, orgId); const userPermissions = rawUserProjectPermissions.map((userProjectPermission) => { const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; @@ -547,13 +478,12 @@ export const permissionServiceFactory = ({ return { permission, id: userProjectPermission.userId, - name: userProjectPermission.username, - membershipId: userProjectPermission.id + name: userProjectPermission.username }; }); // fetch identity permissions - const rawIdentityProjectPermissions = await permissionDAL.getProjectIdentityPermissions(projectId); + const rawIdentityProjectPermissions = await permissionDAL.getProjectIdentityPermissions(projectId, orgId); const identityPermissions = rawIdentityProjectPermissions.map((identityProjectPermission) => { const rolePermissions = identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; @@ -593,8 +523,7 @@ export const permissionServiceFactory = ({ return { permission, id: identityProjectPermission.identityId, - name: identityProjectPermission.username, - membershipId: identityProjectPermission.id + name: identityProjectPermission.username }; }); @@ -611,8 +540,7 @@ export const permissionServiceFactory = ({ return { permission, id: groupProjectPermission.groupId, - name: groupProjectPermission.username, - membershipId: groupProjectPermission.id + name: groupProjectPermission.username }; }); @@ -623,141 +551,105 @@ export const permissionServiceFactory = ({ }; }; - const getProjectPermission = async ({ - actor: inputActor, - actorId: inputActorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType - }: TGetProjectPermissionArg): Promise> => { - let actor = inputActor; - let actorId = inputActorId; - const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); - if ( - assumedPrivilegeDetailsCtx && - actor === ActorType.USER && - actorId === assumedPrivilegeDetailsCtx.requesterId && - projectId === assumedPrivilegeDetailsCtx.projectId - ) { - actor = assumedPrivilegeDetailsCtx.actorType; - actorId = assumedPrivilegeDetailsCtx.actorId; + // instead of actor type this will fetch by role slug. meaning it can be the pre defined slugs like + // admin member or user defined ones like biller etc + const getOrgPermissionByRoles: TPermissionServiceFactory["getOrgPermissionByRoles"] = async (roles, orgId) => { + const formattedRoles = roles.map((role) => ({ + name: role, + isCustom: !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole) + })); + + const customRoles = formattedRoles.filter((el) => el.isCustom).map((el) => el.name); + const customRoleDetails = customRoles.length + ? await roleDAL.find({ + orgId, + $in: { + slug: customRoles + } + }) + : []; + if (customRoles.length !== customRoleDetails.length) { + const missingRoles = customRoles.filter((role) => !customRoleDetails.find((el) => el.slug === role)); + throw new NotFoundError({ + message: `Specified roles '${missingRoles.join(",")}' was not found in the organization with ID '${orgId}'` + }); } - if (actor === ActorType.SERVICE) { - return getServiceTokenProjectPermission({ - serviceTokenId: actorId, - projectId, - actorOrgId, - actionProjectType - }) as Promise>; - } - - const cachedProjectPermissionVersion = await keyStore.pgGetIntItem( - KeyStorePrefixes.ProjectPermissionDalVersion(projectId) - ); - const projectPermissionVersion = Number(cachedProjectPermissionVersion || 0); - const cacheKey = KeyStorePrefixes.ProjectPermission( - projectId, - projectPermissionVersion, - actor, - actorId, - actionProjectType || ActionProjectType.Any - ); - - try { - const cachedData = await keyStore.getItem(cacheKey); - if (cachedData) { - const parsed = JSON.parse(cachedData) as { - rules: RawRuleOf>[]; - membership: { - roles?: Array<{ role: string; customRoleSlug?: string }>; - [key: string]: unknown; - }; - }; - const permission = createMongoAbility(parsed.rules, { - conditionsMatcher - }); - + return formattedRoles.map((el) => { + if (el.isCustom) { + const roleDetails = customRoleDetails.find((role) => role.slug === el.name); return { - permission, - membership: parsed.membership, - hasRole: (role: string) => - parsed.membership.roles?.findIndex( - ({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug - ) !== -1 - } as TProjectPermissionRT; + permission: createMongoAbility( + buildOrgPermissionRules([{ role: OrgMembershipRole.Custom, permissions: roleDetails?.permissions || [] }]), + { + conditionsMatcher + } + ), + role: roleDetails! + }; } - } catch (error) { - logger.error(error, "Failed to get project permission"); - } - let result: TProjectPermissionRT; - - switch (actor) { - case ActorType.USER: - result = (await getUserProjectPermission({ - userId: actorId, - projectId, - authMethod: actorAuthMethod, - userOrgId: actorOrgId, - actionProjectType - })) as TProjectPermissionRT; - break; - case ActorType.IDENTITY: - result = (await getIdentityProjectPermission({ - identityId: actorId, - projectId, - identityOrgId: actorOrgId, - actionProjectType - })) as TProjectPermissionRT; - break; - default: - throw new BadRequestError({ - message: "Invalid actor provided", - name: "Get project permission" - }); - } - - try { - const cacheData = { - rules: result.permission.rules, - membership: result.membership + return { + permission: createMongoAbility( + buildOrgPermissionRules([{ role: el.name, permissions: [] }]), + { + conditionsMatcher + } + ) }; - - const ttl = calculateProjectPermissionTtl(result.membership); - await keyStore.setItemWithExpiry(cacheKey, ttl, JSON.stringify(cacheData)); - } catch (error) { - logger.error(error, "Failed to cache project permission"); - } - - return result; + }); }; - const getProjectPermissionByRole: TPermissionServiceFactory["getProjectPermissionByRole"] = async ( - role, + const getProjectPermissionByRoles: TPermissionServiceFactory["getProjectPermissionByRoles"] = async ( + roles, projectId ) => { - const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); - if (isCustomRole) { - const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); - if (!projectRole) throw new NotFoundError({ message: `Specified role was not found: ${role}` }); - const rules = buildProjectPermissionRules([ - { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } - ]); - return { - permission: createMongoAbility(rules, { - conditionsMatcher - }), - role: projectRole - }; + const formattedRoles = roles.map((role) => ({ + name: role, + isCustom: !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + })); + + const customRoles = formattedRoles.filter((el) => el.isCustom).map((el) => el.name); + const customRoleDetails = customRoles.length + ? await roleDAL.find({ + projectId, + $in: { + slug: customRoles + } + }) + : []; + if (customRoles.length !== customRoleDetails.length) { + const missingRoles = customRoles.filter((role) => !customRoleDetails.find((el) => el.slug === role)); + throw new NotFoundError({ + message: `Specified roles '${missingRoles.join(",")}' was not found in the project with ID '${projectId}'` + }); } - const rules = buildProjectPermissionRules([{ role, permissions: [] }]); - const permission = createMongoAbility(rules, { - conditionsMatcher + return formattedRoles.map((el) => { + if (el.isCustom) { + const roleDetails = customRoleDetails.find((role) => role.slug === el.name); + return { + permission: createMongoAbility( + buildProjectPermissionRules([ + { role: ProjectMembershipRole.Custom, permissions: roleDetails?.permissions || [] } + ]), + { + conditionsMatcher + } + ), + role: roleDetails! + }; + } + + return { + permission: createMongoAbility( + buildProjectPermissionRules([{ role: el.name, permissions: [] }]), + { + conditionsMatcher + } + ) + }; }); - return { permission }; }; const checkGroupProjectPermission: TPermissionServiceFactory["checkGroupProjectPermission"] = async ({ @@ -785,15 +677,11 @@ export const permissionServiceFactory = ({ }; return { - getUserOrgPermission, getOrgPermission, - getUserProjectPermission, getProjectPermission, getProjectPermissions, - getOrgPermissionByRole, - getProjectPermissionByRole, - buildOrgPermission, - buildProjectPermissionRules, + getOrgPermissionByRoles, + getProjectPermissionByRoles, checkGroupProjectPermission, invalidateProjectPermissionCache }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d79120256..bb62440c1 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -1,6 +1,7 @@ import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; import { z } from "zod"; +import { ProjectMembershipRole } from "@app/db/schemas"; import { CASL_ACTION_SCHEMA_ENUM, CASL_ACTION_SCHEMA_NATIVE_ENUM @@ -110,6 +111,14 @@ export enum ProjectPermissionPkiSubscriberActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -186,6 +195,22 @@ export enum ProjectPermissionAuditLogsActions { Read = "read" } +export enum ProjectPermissionPamAccountActions { + Access = "access", + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermissionPamSessionActions { + Read = "read" + // Terminate = "terminate" +} + +export const isCustomProjectRole = (slug: string) => + !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -228,7 +253,12 @@ export enum ProjectPermissionSub { SecretScanningFindings = "secret-scanning-findings", SecretScanningConfigs = "secret-scanning-configs", SecretEvents = "secret-events", - AppConnections = "app-connections" + AppConnections = "app-connections", + PamFolders = "pam-folders", + PamResources = "pam-resources", + PamAccounts = "pam-accounts", + PamSessions = "pam-sessions", + CertificateProfiles = "certificate-profiles" } export type SecretSubjectFields = { @@ -300,6 +330,12 @@ export type AppConnectionSubjectFields = { connectionId: string; }; +export type PamAccountSubjectFields = { + resourceName: string; + accountName: string; + accountPath: string; +}; + export type ProjectPermissionSet = | [ ProjectPermissionSecretActions, @@ -404,7 +440,15 @@ export type ProjectPermissionSet = | ProjectPermissionSub.AppConnections | (ForcedSubject & AppConnectionSubjectFields) ) - ]; + ] + | [ProjectPermissionActions, ProjectPermissionSub.PamFolders] + | [ProjectPermissionActions, ProjectPermissionSub.PamResources] + | [ + ProjectPermissionPamAccountActions, + ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) + ] + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -427,6 +471,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ }) .partial() ]); +const PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; +const PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ + z.string().refine((val) => val.startsWith("/"), SECRET_PATH_MISSING_SLASH_ERR_MSG), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ].refine( + (val) => val.startsWith("/"), + PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG + ), + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ].refine( + (val) => val.startsWith("/"), + PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG + ), + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN].refine( + (val) => val.every((el) => el.startsWith("/")), + PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG + ), + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() +]); // akhilmhdh: don't modify this for v2 // if you want to update create a new schema const SecretConditionV1Schema = z @@ -650,6 +715,34 @@ const AppConnectionConditionSchema = z }) .partial(); +const PamAccountConditionSchema = z + .object({ + resourceName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + accountName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + accountPath: PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -840,6 +933,34 @@ const GeneralPermissionSchema = [ conditions: AppConnectionConditionSchema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamFolders).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamResources).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamAccounts).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamAccountActions).describe( + "Describe what action an entity can take." + ), + conditions: PamAccountConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.PamSessions).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe( + "Describe what action an entity can take." + ) }) ]; @@ -998,6 +1119,13 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateProfiles).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/ee/services/project-template/project-template-service.ts b/backend/src/ee/services/project-template/project-template-service.ts index f3fe07aa8..5a9f04d8d 100644 --- a/backend/src/ee/services/project-template/project-template-service.ts +++ b/backend/src/ee/services/project-template/project-template-service.ts @@ -1,7 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { packRules } from "@casl/ability/extra"; -import { ProjectType, TProjectTemplates } from "@app/db/schemas"; +import { OrganizationActionScope, ProjectType, TProjectTemplates } 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-types"; @@ -59,13 +59,14 @@ export const projectTemplateServiceFactory = ({ message: "Failed to access project templates due to plan restriction. Upgrade plan to access project templates." }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); @@ -97,13 +98,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with Name "${name}"` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); @@ -125,13 +127,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); @@ -152,13 +155,14 @@ export const projectTemplateServiceFactory = ({ message: "Failed to create project template due to plan restriction. Upgrade plan to access project templates." }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); @@ -213,13 +217,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); if (projectTemplate.type !== ProjectType.SecretManager && environments) @@ -272,13 +277,14 @@ export const projectTemplateServiceFactory = ({ if (!projectTemplate) throw new NotFoundError({ message: `Could not find project template with ID ${id}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - projectTemplate.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: projectTemplate.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); diff --git a/backend/src/ee/services/project-template/project-template-types.ts b/backend/src/ee/services/project-template/project-template-types.ts index 8d9e952a7..1815344a7 100644 --- a/backend/src/ee/services/project-template/project-template-types.ts +++ b/backend/src/ee/services/project-template/project-template-types.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { ProjectMembershipRole, ProjectType, TProjectEnvironments } from "@app/db/schemas"; import { TProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; -import { OrgServiceActor } from "@app/lib/types"; +import { ProjectServiceActor } from "@app/lib/types"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; export type TProjectTemplateEnvironment = Pick; @@ -31,7 +31,7 @@ export enum InfisicalProjectTemplate { export type TProjectTemplateServiceFactory = { listProjectTemplatesByOrg: ( - actor: OrgServiceActor, + actor: ProjectServiceActor, type?: ProjectType ) => Promise< ( @@ -85,7 +85,7 @@ export type TProjectTemplateServiceFactory = { >; createProjectTemplate: ( arg: TCreateProjectTemplateDTO, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ environments: TProjectTemplateEnvironment[]; roles: { @@ -109,7 +109,7 @@ export type TProjectTemplateServiceFactory = { updateProjectTemplateById: ( id: string, { roles, environments, ...params }: TUpdateProjectTemplateDTO, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ environments: TProjectTemplateEnvironment[]; roles: { @@ -132,7 +132,7 @@ export type TProjectTemplateServiceFactory = { }>; deleteProjectTemplateById: ( id: string, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ environments: TProjectTemplateEnvironment[]; roles: { @@ -155,7 +155,7 @@ export type TProjectTemplateServiceFactory = { }>; findProjectTemplateById: ( id: string, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ packedRoles: TProjectTemplateRole[]; environments: TProjectTemplateEnvironment[]; @@ -179,7 +179,7 @@ export type TProjectTemplateServiceFactory = { }>; findProjectTemplateByName: ( name: string, - actor: OrgServiceActor + actor: ProjectServiceActor ) => Promise<{ packedRoles: TProjectTemplateRole[]; environments: TProjectTemplateEnvironment[]; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts deleted file mode 100644 index 6a3be2631..000000000 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify, TOrmify } from "@app/lib/knex"; - -export type TProjectUserAdditionalPrivilegeDALFactory = TOrmify; - -export const projectUserAdditionalPrivilegeDALFactory = (db: TDbClient): TProjectUserAdditionalPrivilegeDALFactory => { - const orm = ormify(db, TableName.ProjectUserAdditionalPrivilege); - return orm; -}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts deleted file mode 100644 index c2876572f..000000000 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; -import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; - -import { ActionProjectType, TableName } from "@app/db/schemas"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; -import { ms } from "@app/lib/ms"; -import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; -import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; -import { ActorType } from "@app/services/auth/auth-type"; -import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; - -import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; -import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; -import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { - ProjectPermissionMemberActions, - ProjectPermissionSet, - ProjectPermissionSub -} from "../permission/project-permission"; -import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; -import { - ProjectUserAdditionalPrivilegeTemporaryMode, - TProjectUserAdditionalPrivilegeServiceFactory -} from "./project-user-additional-privilege-types"; - -type TProjectUserAdditionalPrivilegeServiceFactoryDep = { - projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; - projectMembershipDAL: Pick; - permissionService: Pick; - accessApprovalRequestDAL: Pick; -}; - -const unpackPermissions = (permissions: unknown) => - UnpackedPermissionSchema.array().parse( - unpackRules((permissions || []) as PackRule>>[]) - ); - -export const projectUserAdditionalPrivilegeServiceFactory = ({ - projectUserAdditionalPrivilegeDAL, - projectMembershipDAL, - permissionService, - accessApprovalRequestDAL -}: TProjectUserAdditionalPrivilegeServiceFactoryDep): TProjectUserAdditionalPrivilegeServiceFactory => { - const create: TProjectUserAdditionalPrivilegeServiceFactory["create"] = async ({ - slug, - actor, - actorId, - permissions: customPermission, - actorOrgId, - actorAuthMethod, - projectMembershipId, - ...dto - }) => { - const projectMembership = await projectMembershipDAL.findById(projectMembershipId); - if (!projectMembership) - throw new NotFoundError({ message: `Project membership with ID ${projectMembershipId} found` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); - const { permission: targetUserPermission, membership } = await permissionService.getProjectPermission({ - actor: ActorType.USER, - actorId: projectMembership.userId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - // we need to validate that the privilege given is not higher than the assigning users permission - // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules - targetUserPermission.update(targetUserPermission.rules.concat(customPermission)); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member, - permission, - targetUserPermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged user", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ - slug, - projectId: projectMembership.projectId, - userId: projectMembership.userId - }); - if (existingSlug) - throw new BadRequestError({ message: `Additional privilege with provided slug ${slug} already exists` }); - - validateHandlebarTemplate("User Additional Privilege Create", JSON.stringify(customPermission || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - const packedPermission = JSON.stringify(packRules(customPermission)); - if (!dto.isTemporary) { - const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ - userId: projectMembership.userId, - projectId: projectMembership.projectId, - slug, - permissions: packedPermission - }); - - await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - } - - const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); - const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ - projectId: projectMembership.projectId, - userId: projectMembership.userId, - slug, - permissions: packedPermission, - isTemporary: true, - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, - temporaryRange: dto.temporaryRange, - temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) - }); - - await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - }; - - const updateById: TProjectUserAdditionalPrivilegeServiceFactory["updateById"] = async ({ - privilegeId, - actorOrgId, - actor, - actorId, - actorAuthMethod, - ...dto - }) => { - const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); - if (!userPrivilege) - throw new NotFoundError({ message: `User additional privilege with ID ${privilegeId} not found` }); - - const projectMembership = await projectMembershipDAL.findOne({ - userId: userPrivilege.userId, - projectId: userPrivilege.projectId - }); - - if (!projectMembership) - throw new NotFoundError({ - message: `Project membership for user with ID '${userPrivilege.userId}' not found in project with ID '${userPrivilege.projectId}'` - }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); - const { permission: targetUserPermission } = await permissionService.getProjectPermission({ - actor: ActorType.USER, - actorId: projectMembership.userId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - - // we need to validate that the privilege given is not higher than the assigning users permission - // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules - targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || [])); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member, - permission, - targetUserPermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update more privileged user", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - - if (dto?.slug) { - const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ - slug: dto.slug, - userId: projectMembership.id, - projectId: projectMembership.projectId - }); - if (existingSlug && existingSlug.id !== userPrivilege.id) - throw new BadRequestError({ message: `Additional privilege with provided slug ${dto.slug} already exists` }); - } - - validateHandlebarTemplate("User Additional Privilege Update", JSON.stringify(dto.permissions || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary; - - const packedPermission = dto.permissions && JSON.stringify(packRules(dto.permissions)); - if (isTemporary) { - const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime; - const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange; - const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { - slug: dto.slug, - permissions: packedPermission, - isTemporary: dto.isTemporary, - temporaryRange: dto.temporaryRange, - temporaryMode: dto.temporaryMode, - temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), - temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) - }); - - await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - } - - const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { - slug: dto.slug, - permissions: packedPermission, - isTemporary: false, - temporaryAccessStartTime: null, - temporaryAccessEndTime: null, - temporaryRange: null, - temporaryMode: null - }); - - await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); - - return { - ...additionalPrivilege, - permissions: unpackPermissions(additionalPrivilege.permissions) - }; - }; - - const deleteById: TProjectUserAdditionalPrivilegeServiceFactory["deleteById"] = async ({ - actorId, - actor, - actorOrgId, - actorAuthMethod, - privilegeId - }) => { - const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); - if (!userPrivilege) - throw new NotFoundError({ message: `User additional privilege with ID ${privilegeId} not found` }); - - const projectMembership = await projectMembershipDAL.findOne({ - userId: userPrivilege.userId, - projectId: userPrivilege.projectId - }); - if (!projectMembership) - throw new NotFoundError({ - message: `Project membership for user with ID '${userPrivilege.userId}' not found in project with ID '${userPrivilege.projectId}'` - }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); - - await accessApprovalRequestDAL.update( - { - privilegeId: userPrivilege.id - }, - { - privilegeDeletedAt: new Date(), - status: ApprovalStatus.REJECTED - } - ); - const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); - - await permissionService.invalidateProjectPermissionCache(projectMembership.projectId); - - return { - ...deletedPrivilege, - permissions: unpackPermissions(deletedPrivilege.permissions) - }; - }; - - const getPrivilegeDetailsById: TProjectUserAdditionalPrivilegeServiceFactory["getPrivilegeDetailsById"] = async ({ - privilegeId, - actorOrgId, - actor, - actorId, - actorAuthMethod - }) => { - const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); - if (!userPrivilege) - throw new NotFoundError({ message: `User additional privilege with ID ${privilegeId} not found` }); - - const projectMembership = await projectMembershipDAL.findOne({ - userId: userPrivilege.userId, - projectId: userPrivilege.projectId - }); - if (!projectMembership) - throw new NotFoundError({ - message: `Project membership for user with ID '${userPrivilege.userId}' not found in project with ID '${userPrivilege.projectId}'` - }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - - return { - ...userPrivilege, - permissions: unpackPermissions(userPrivilege.permissions) - }; - }; - - const listPrivileges: TProjectUserAdditionalPrivilegeServiceFactory["listPrivileges"] = async ({ - projectMembershipId, - actorOrgId, - actor, - actorId, - actorAuthMethod - }) => { - const projectMembership = await projectMembershipDAL.findById(projectMembershipId); - if (!projectMembership) - throw new NotFoundError({ message: `Project membership with ID ${projectMembershipId} not found` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectMembership.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - - const userPrivileges = await projectUserAdditionalPrivilegeDAL.find( - { - userId: projectMembership.userId, - projectId: projectMembership.projectId - }, - { sort: [[`${TableName.ProjectUserAdditionalPrivilege}.slug` as "slug", "asc"]] } - ); - return userPrivileges; - }; - - return { - create, - updateById, - deleteById, - getPrivilegeDetailsById, - listPrivileges - }; -}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts deleted file mode 100644 index a700d997d..000000000 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { TProjectUserAdditionalPrivilege } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; - -import { TProjectPermissionV2Schema } from "../permission/project-permission"; - -export enum ProjectUserAdditionalPrivilegeTemporaryMode { - Relative = "relative" -} - -export type TCreateUserPrivilegeDTO = ( - | { - permissions: TProjectPermissionV2Schema[]; - projectMembershipId: string; - slug: string; - isTemporary: false; - } - | { - permissions: TProjectPermissionV2Schema[]; - projectMembershipId: string; - slug: string; - isTemporary: true; - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - } -) & - Omit; - -export type TUpdateUserPrivilegeDTO = { privilegeId: string } & Omit & - Partial<{ - permissions: TProjectPermissionV2Schema[]; - slug: string; - isTemporary: boolean; - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - }>; - -export type TDeleteUserPrivilegeDTO = Omit & { privilegeId: string }; - -export type TGetUserPrivilegeDetailsDTO = Omit & { privilegeId: string }; - -export type TListUserPrivilegesDTO = Omit & { projectMembershipId: string }; - -interface TAdditionalPrivilege extends TProjectUserAdditionalPrivilege { - permissions: { - action: string[]; - subject?: string | undefined; - conditions?: unknown; - inverted?: boolean | undefined; - }[]; -} - -export type TProjectUserAdditionalPrivilegeServiceFactory = { - create: (arg: TCreateUserPrivilegeDTO) => Promise; - updateById: (arg: TUpdateUserPrivilegeDTO) => Promise; - deleteById: (arg: TDeleteUserPrivilegeDTO) => Promise; - getPrivilegeDetailsById: (arg: TGetUserPrivilegeDetailsDTO) => Promise; - listPrivileges: (arg: TListUserPrivilegesDTO) => Promise; -}; diff --git a/backend/src/ee/services/relay/relay-dal.ts b/backend/src/ee/services/relay/relay-dal.ts index 9107e0807..8e7eac8de 100644 --- a/backend/src/ee/services/relay/relay-dal.ts +++ b/backend/src/ee/services/relay/relay-dal.ts @@ -1,11 +1,47 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { TableName, TRelays } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, TFindFilter, TFindOpt } from "@app/lib/knex"; export type TRelayDALFactory = ReturnType; export const relayDalFactory = (db: TDbClient) => { const orm = ormify(db, TableName.Relay); - return orm; + const find = async ( + filter: TFindFilter & { isHeartbeatStale?: boolean }, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { + try { + const { isHeartbeatStale, ...regularFilter } = filter; + + const query = (tx || db.replicaNode())(TableName.Relay) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(regularFilter, TableName.Relay)); + + if (isHeartbeatStale) { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + void query.whereNotNull(`${TableName.Relay}.heartbeat`); + void query.where(`${TableName.Relay}.heartbeat`, "<", oneHourAgo); + void query.where((v) => { + void v + .whereNull(`${TableName.Relay}.healthAlertedAt`) + .orWhere(`${TableName.Relay}.healthAlertedAt`, "<", db.ref("heartbeat").withSchema(TableName.Relay)); + }); + } + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const docs = await query; + return docs; + } catch (error) { + throw new DatabaseError({ error, name: `${TableName.Relay}: Find` }); + } + }; + + return { ...orm, find }; }; diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index 2fef71259..b2eb932ed 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -3,10 +3,13 @@ import { isIP } from "node:net"; import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; -import { TRelays } from "@app/db/schemas"; +import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas"; import { PgSqlLock } from "@app/keystore/keystore"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { createRelayConnection } from "@app/lib/gateway-v2/gateway-v2"; +import { logger } from "@app/lib/logger"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns"; import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; @@ -16,6 +19,11 @@ import { } from "@app/services/certificate-authority/certificate-authority-fns"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { NotificationType } from "@app/services/notification/notification-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns"; import { TLicenseServiceFactory } from "../license/license-service"; @@ -39,7 +47,11 @@ export const relayServiceFactory = ({ relayDAL, kmsService, licenseService, - permissionService + permissionService, + orgDAL, + notificationService, + smtpService, + userDAL }: { instanceRelayConfigDAL: TInstanceRelayConfigDALFactory; orgRelayConfigDAL: TOrgRelayConfigDALFactory; @@ -47,6 +59,10 @@ export const relayServiceFactory = ({ kmsService: TKmsServiceFactory; licenseService: TLicenseServiceFactory; permissionService: TPermissionServiceFactory; + orgDAL: Pick; + notificationService: Pick; + smtpService: Pick; + userDAL: Pick; }) => { const $getInstanceCAs = async () => { const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => { @@ -110,8 +126,8 @@ export const relayServiceFactory = ({ // generate instance relay CA const instanceRelayCaSerialNumber = createSerialNumber(); - const instanceRelayCaIssuedAt = new Date(); const instanceRelayCaExpiration = new Date(new Date().setFullYear(2045)); + const instanceRelayCaIssuedAt = new Date(); const instanceRelayCaKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const instanceRelayCaSkObj = crypto.nativeCrypto.KeyObject.from(instanceRelayCaKeys.privateKey); const instanceRelayCaCert = await x509.X509CertificateGenerator.create({ @@ -708,7 +724,8 @@ export const relayServiceFactory = ({ relayPkiClientCaCertificate, relayPkiClientCaPrivateKey, relayPkiServerCaCertificate, - relayPkiServerCaCertificateChain + relayPkiServerCaCertificateChain, + duration }: { gatewayId: string; gatewayName: string; @@ -718,6 +735,7 @@ export const relayServiceFactory = ({ relayPkiClientCaPrivateKey: Buffer; relayPkiServerCaCertificate: Buffer; relayPkiServerCaCertificateChain: Buffer; + duration?: number; }) => { const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate); @@ -737,7 +755,7 @@ export const relayServiceFactory = ({ ); const clientCertIssuedAt = new Date(); - const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000); + const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000)); const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); const clientCertSerialNumber = createSerialNumber(); @@ -866,13 +884,15 @@ export const relayServiceFactory = ({ orgId, orgName, gatewayId, - gatewayName + gatewayName, + duration }: { relayId: string; orgId: string; orgName: string; gatewayId: string; gatewayName: string; + duration?: number; }) => { const relay = await relayDAL.findOne({ id: relayId @@ -896,7 +916,8 @@ export const relayServiceFactory = ({ relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey, relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, - relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain + relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain, + duration }); return { @@ -914,7 +935,8 @@ export const relayServiceFactory = ({ relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey, relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, - relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain + relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain, + duration }); return { @@ -950,13 +972,14 @@ export const relayServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityId, + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityId, orgId, - actorAuthMethod!, - orgId - ); + actorAuthMethod: actorAuthMethod!, + actorOrgId: orgId + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionRelayActions.CreateRelays, @@ -1050,6 +1073,79 @@ export const relayServiceFactory = ({ }); }; + const heartbeat = async ({ + name, + identityId, + actorAuthMethod, + orgId + }: { + name: string; + identityId?: string; + actorAuthMethod?: ActorAuthMethod; + orgId?: string; + }) => { + const relay = await relayDAL.findOne({ + name, + orgId: orgId ?? null + }); + + if (!relay) { + throw new NotFoundError({ message: `Relay with name ${name} not found.` }); + } + + let clientOrgId: string; + let clientOrgName: string; + + if (relay.orgId) { + if (!identityId || !orgId || relay.orgId !== orgId) { + throw new ForbiddenRequestError({ + message: "You do not have permission to perform this action on this relay." + }); + } + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityId, + orgId, + actorAuthMethod: actorAuthMethod!, + actorOrgId: orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionRelayActions.CreateRelays, + OrgPermissionSubjects.Relay + ); + clientOrgId = orgId; + clientOrgName = orgId; + } else { + clientOrgId = "00000000-0000-0000-0000-000000000000"; + clientOrgName = "heartbeat"; + } + + const relayClientCredentials = await getCredentialsForClient({ + relayId: relay.id, + orgId: clientOrgId, + orgName: clientOrgName, + gatewayId: "00000000-0000-0000-0000-000000000000", + gatewayName: "heartbeat", + duration: 60 * 1000 // 1 minute + }); + + try { + await createRelayConnection({ + relayHost: relayClientCredentials.relayHost, + clientCertificate: relayClientCredentials.clientCertificate, + clientPrivateKey: relayClientCredentials.clientPrivateKey, + serverCertificateChain: relayClientCredentials.serverCertificateChain + }); + + await relayDAL.updateById(relay.id, { heartbeat: new Date() }); + } catch (err) { + const error = err as Error; + throw new BadRequestError({ message: `Relay ${name} is not reachable: ${error.message}` }); + } + }; + const getRelays = async ({ actorId, actor, @@ -1061,13 +1157,14 @@ export const relayServiceFactory = ({ actorAuthMethod: ActorAuthMethod; actorOrgId: string; }) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, - actorAuthMethod, + orgId: actorOrgId, + actorAuthMethod: actorAuthMethod!, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay); @@ -1095,13 +1192,14 @@ export const relayServiceFactory = ({ actorAuthMethod: ActorAuthMethod; actorOrgId: string; }) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay); @@ -1114,11 +1212,87 @@ export const relayServiceFactory = ({ return deletedRelay; }; + const healthcheckNotify = async () => { + const unhealthyRelays = await relayDAL.find({ + isHeartbeatStale: true + }); + + if (unhealthyRelays.length === 0) return; + + logger.warn( + { relayIds: unhealthyRelays.map((g) => g.id) }, + "Found relays with last heartbeat over an hour ago. Sending notifications." + ); + + const relaysByOrg = groupBy(unhealthyRelays, (r) => r.orgId ?? "instance"); + + for await (const [orgId, relays] of Object.entries(relaysByOrg)) { + try { + if (orgId === "instance") { + const superAdmins = await userDAL.find({ + superAdmin: true + }); + + const recipients = superAdmins.map((admin) => admin.email).filter((v): v is string => !!v); + + if (recipients.length > 0) { + const relayNames = relays.map((r) => `"${r.name}"`).join(", "); + await smtpService.sendMail({ + recipients, + subjectLine: "Relay Health Alert", + substitutions: { + type: "instance-relay", + names: relayNames + }, + template: SmtpTemplates.HealthAlert + }); + } + } else { + const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin); + if (admins.length === 0) { + // eslint-disable-next-line no-continue + continue; + } + + const relayNames = relays.map((r) => `"${r.name}"`).join(", "); + const body = `The following relay(s) in your organization may be offline as they haven't reported a heartbeat in over an hour: ${relayNames}. Please check their status.`; + + await notificationService.createUserNotifications( + admins.map((admin) => ({ + userId: admin.user.id, + orgId, + type: NotificationType.RELAY_HEALTH_ALERT, + title: "Relay Health Alert", + body, + link: "/organization/networking" + })) + ); + + await smtpService.sendMail({ + recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), + subjectLine: "Relay Health Alert", + substitutions: { + type: "relay", + names: relayNames + }, + template: SmtpTemplates.HealthAlert + }); + } + + await Promise.all(relays.map((r) => relayDAL.updateById(r.id, { healthAlertedAt: new Date() }))); + } catch (error) { + logger.error(error, `Failed to send relay health notifications for organization [orgId=${orgId}]`); + } + } + }; + return { registerRelay, getCredentialsForGateway, getCredentialsForClient, getRelays, - deleteRelay + deleteRelay, + heartbeat, + healthcheckNotify }; }; 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 f1ee313e8..c99ae8b28 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -4,6 +4,8 @@ import { Knex } from "knex"; import RE2 from "re2"; import { + AccessScope, + OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TableName, @@ -19,13 +21,13 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; -import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; @@ -68,32 +70,30 @@ type TSamlConfigServiceFactoryDep = { "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; identityMetadataDAL: Pick; - orgMembershipDAL: Pick; - groupDAL: Pick; - userGroupMembershipDAL: Pick< - TUserGroupMembershipDALFactory, - "find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership" - >; - groupProjectDAL: Pick; - projectDAL: Pick; - projectBotDAL: Pick; - projectKeyDAL: Pick; + membershipRoleDAL: Pick; permissionService: Pick; licenseService: Pick; tokenService: Pick; smtpService: Pick; kmsService: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership" + >; + groupDAL: Pick; + projectDAL: Pick; + projectBotDAL: Pick; + projectKeyDAL: Pick; + membershipGroupDAL: Pick; }; export const samlConfigServiceFactory = ({ samlConfigDAL, orgDAL, - orgMembershipDAL, userDAL, userAliasDAL, groupDAL, userGroupMembershipDAL, - groupProjectDAL, projectDAL, projectBotDAL, projectKeyDAL, @@ -102,7 +102,9 @@ export const samlConfigServiceFactory = ({ tokenService, smtpService, identityMetadataDAL, - kmsService + kmsService, + membershipRoleDAL, + membershipGroupDAL }: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => { const parseSamlGroups = (groupsValue: string): string[] => { let samlGroups: string[] = []; @@ -181,6 +183,22 @@ export const samlConfigServiceFactory = ({ transaction ); orgGroupsMap.set(groupName, newGroup); + const orgMembership = await membershipGroupDAL.create( + { + actorGroupId: newGroup.id, + scope: AccessScope.Organization, + scopeOrgId: orgId + }, + transaction + ); + await membershipRoleDAL.create( + { + membershipId: orgMembership.id, + role: OrgMembershipRole.NoAccess, + customRoleId: null + }, + transaction + ); } } @@ -195,10 +213,10 @@ export const samlConfigServiceFactory = ({ userDAL, userGroupMembershipDAL, orgDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, + membershipGroupDAL, tx: transaction }); } catch (error) { @@ -218,7 +236,7 @@ export const samlConfigServiceFactory = ({ group, userDAL, userGroupMembershipDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, tx: transaction }); @@ -250,7 +268,14 @@ export const samlConfigServiceFactory = ({ authProvider, enableGroupSync }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); const plan = await licenseService.getPlan(orgId); @@ -316,7 +341,14 @@ export const samlConfigServiceFactory = ({ authProvider, enableGroupSync }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); const plan = await licenseService.getPlan(orgId); if (!plan.samlSSO) @@ -392,7 +424,7 @@ export const samlConfigServiceFactory = ({ }); } } else if (dto.type === "orgSlug") { - const org = await orgDAL.findOne({ slug: dto.orgSlug }); + const org = await orgDAL.findOne({ slug: dto.orgSlug, rootOrgId: null }); if (!org) { throw new NotFoundError({ message: `Organization with slug '${dto.orgSlug}' not found` @@ -423,13 +455,14 @@ export const samlConfigServiceFactory = ({ // when dto is type id means it's internally used if (dto.type === "org") { - const { permission } = await permissionService.getOrgPermission( - dto.actor, - dto.actorId, - samlConfig.orgId, - dto.actorAuthMethod, - dto.actorOrgId - ); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor: dto.actor, + actorId: dto.actorId, + orgId: samlConfig.orgId, + actorAuthMethod: dto.actorAuthMethod, + actorOrgId: dto.actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); } const { decryptor } = await kmsService.createCipherPairWithDataKey({ @@ -506,26 +539,35 @@ export const samlConfigServiceFactory = ({ const foundUser = await userDAL.findById(userAlias.userId, tx); const [orgMembership] = await orgDAL.findMembership( { - [`${TableName.OrgMembership}.userId` as "userId"]: foundUser.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: userAlias.userId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }, { tx } ); + if (!orgMembership) { const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole); - await orgMembershipDAL.create( + const membership = await orgDAL.createMembership( { - userId: userAlias.userId, + actorUserId: userAlias.userId, inviteEmail: email, - orgId, - role, - roleId, - status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, + scopeOrgId: orgId, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted, isActive: true }, tx ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role, + customRoleId: roleId + }, + tx + ); // Only update the membership to Accepted if the user account is already completed. } else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) { await orgDAL.updateMembershipById( @@ -606,8 +648,9 @@ export const samlConfigServiceFactory = ({ const [orgMembership] = await orgDAL.findMembership( { - [`${TableName.OrgMembership}.userId` as "userId"]: newUser.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: userAlias.userId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }, { tx } ); @@ -617,15 +660,22 @@ export const samlConfigServiceFactory = ({ const { role, roleId } = await getDefaultOrgMembershipRole(organization.defaultMembershipRole); - await orgMembershipDAL.create( + const membership = await orgDAL.createMembership( { - userId: newUser.id, - inviteEmail: email, - orgId, - role, - roleId, + actorUserId: newUser.id, + scopeOrgId: orgId, + scope: AccessScope.Organization, status: newUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later - isActive: true + isActive: true, + inviteEmail: email.toLowerCase() + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role, + customRoleId: roleId }, 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 bdf65b988..983ec4db7 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -37,7 +37,7 @@ export type TGetSamlCfgDTO = actor: ActorType; actorId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; } | { type: "orgSlug"; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 9cc6e134d..8b9256023 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -2,7 +2,16 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import { scimPatch } from "scim-patch"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups, TOrgMemberships, TUsers } from "@app/db/schemas"; +import { + AccessScope, + OrganizationActionScope, + OrgMembershipRole, + OrgMembershipStatus, + TableName, + TGroups, + TMemberships, + TUsers +} from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; @@ -11,18 +20,19 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; import { AuthTokenType } from "@app/services/auth/auth-type"; import { TExternalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; +import { TMembershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { deleteOrgMembershipFn } from "@app/services/org/org-fns"; +import { deleteOrgMembershipsFn } from "@app/services/org/org-fns"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; import { OrgAuthMethod } from "@app/services/org/org-types"; -import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; -import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -33,7 +43,6 @@ import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, parseScimFilter } from "./scim-fns"; import { TScimGroup, TScimServiceFactory } from "./scim-types"; @@ -48,6 +57,7 @@ type TScimServiceFactoryDep = { TOrgDALFactory, | "createMembership" | "findById" + | "find" | "findMembership" | "findMembershipWithScimFilter" | "deleteMembershipById" @@ -55,12 +65,8 @@ type TScimServiceFactoryDep = { | "updateMembershipById" | "findOrgById" >; - orgMembershipDAL: Pick< - TOrgMembershipDALFactory, - "find" | "findOne" | "create" | "updateById" | "findById" | "update" - >; + membershipUserDAL: TMembershipUserDALFactory; projectDAL: Pick; - projectMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, | "create" @@ -72,7 +78,8 @@ type TScimServiceFactoryDep = { | "updateById" | "update" >; - groupProjectDAL: Pick; + membershipGroupDAL: Pick; + membershipRoleDAL: TMembershipRoleDALFactory; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, | "find" @@ -88,8 +95,8 @@ type TScimServiceFactoryDep = { licenseService: Pick; permissionService: Pick; smtpService: Pick; - projectUserAdditionalPrivilegeDAL: Pick; externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory; + additionalPrivilegeDAL: TAdditionalPrivilegeDALFactory; }; export const scimServiceFactory = ({ @@ -98,18 +105,18 @@ export const scimServiceFactory = ({ userDAL, userAliasDAL, orgDAL, - orgMembershipDAL, projectDAL, - projectMembershipDAL, groupDAL, - groupProjectDAL, userGroupMembershipDAL, projectKeyDAL, projectBotDAL, permissionService, - projectUserAdditionalPrivilegeDAL, smtpService, - externalGroupOrgRoleMappingDAL + externalGroupOrgRoleMappingDAL, + membershipGroupDAL, + membershipUserDAL, + membershipRoleDAL, + additionalPrivilegeDAL }: TScimServiceFactoryDep): TScimServiceFactory => { const createScimToken: TScimServiceFactory["createScimToken"] = async ({ actor, @@ -120,7 +127,14 @@ export const scimServiceFactory = ({ description, ttlDays }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim); const plan = await licenseService.getPlan(orgId); @@ -155,7 +169,14 @@ export const scimServiceFactory = ({ actorAuthMethod, orgId }) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); const plan = await licenseService.getPlan(orgId); @@ -178,13 +199,14 @@ export const scimServiceFactory = ({ let scimToken = await scimDAL.findById(scimTokenId); if (!scimToken) throw new NotFoundError({ message: `SCIM token with ID '${scimTokenId}' not found` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.ParentOrganization, actor, actorId, - scimToken.orgId, + orgId: scimToken.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim); const plan = await licenseService.getPlan(scimToken.orgId); @@ -244,8 +266,9 @@ export const scimServiceFactory = ({ const getScimUser: TScimServiceFactory["getScimUser"] = async ({ orgMembershipId, orgId }) => { const [membership] = await orgDAL .findMembership({ - [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + [`${TableName.Membership}.id` as "id"]: orgMembershipId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }) .catch(() => { throw new ScimRequestError({ @@ -322,13 +345,14 @@ export const scimServiceFactory = ({ const { user: createdUser, orgMembership: createdOrgMembership } = await userDAL.transaction(async (tx) => { let user: TUsers | undefined; - let orgMembership: TOrgMemberships; + let orgMembership: TMemberships; if (userAlias) { user = await userDAL.findById(userAlias.userId, tx); - orgMembership = await orgMembershipDAL.findOne( + orgMembership = await membershipUserDAL.findOne( { - userId: user.id, - orgId + actorUserId: user.id, + scope: AccessScope.Organization, + scopeOrgId: orgId }, tx ); @@ -336,20 +360,27 @@ export const scimServiceFactory = ({ if (!orgMembership) { const { role, roleId } = await getDefaultOrgMembershipRole(org.defaultMembershipRole); - orgMembership = await orgMembershipDAL.create( + orgMembership = await membershipUserDAL.create( { - userId: userAlias.userId, + actorUserId: userAlias.userId, inviteEmail: email.toLowerCase(), - orgId, - role, - roleId, + scopeOrgId: orgId, + scope: AccessScope.Organization, status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, tx ); + await membershipRoleDAL.create( + { + membershipId: orgMembership.id, + role, + customRoleId: roleId + }, + tx + ); } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { - orgMembership = await orgMembershipDAL.updateById( + orgMembership = await membershipUserDAL.updateById( orgMembership.id, { status: OrgMembershipStatus.Accepted @@ -401,8 +432,9 @@ export const scimServiceFactory = ({ const [foundOrgMembership] = await orgDAL.findMembership( { - [`${TableName.OrgMembership}.userId` as "userId"]: user.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: user.id, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }, { tx } ); @@ -412,18 +444,25 @@ export const scimServiceFactory = ({ if (!orgMembership) { const { role, roleId } = await getDefaultOrgMembershipRole(org.defaultMembershipRole); - orgMembership = await orgMembershipDAL.create( + orgMembership = await membershipUserDAL.create( { - userId: user.id, + actorUserId: user.id, inviteEmail: email.toLowerCase(), - orgId, - role, - roleId, + scopeOrgId: orgId, + scope: AccessScope.Organization, status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, tx ); + await membershipRoleDAL.create( + { + membershipId: orgMembership.id, + role, + customRoleId: roleId + }, + tx + ); // Only update the membership to Accepted if the user account is already completed. } else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) { orgMembership = await orgDAL.updateMembershipById( @@ -475,8 +514,9 @@ export const scimServiceFactory = ({ const [membership] = await orgDAL .findMembership({ - [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + [`${TableName.Membership}.id` as "id"]: orgMembershipId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }) .catch(() => { throw new ScimRequestError({ @@ -485,7 +525,7 @@ export const scimServiceFactory = ({ }); }); - if (!membership) + if (!membership || !membership.actorUserId) throw new ScimRequestError({ detail: "User not found", status: 404 @@ -514,7 +554,7 @@ export const scimServiceFactory = ({ org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails; await userDAL.transaction(async (tx) => { - await orgMembershipDAL.updateById( + await membershipUserDAL.updateById( membership.id, { isActive: scimUser.active @@ -523,7 +563,7 @@ export const scimServiceFactory = ({ ); const hasEmailChanged = scimUser.emails[0].value !== membership.email; await userDAL.updateById( - membership.userId, + membership.actorUserId as string, { firstName: scimUser.name.givenName, email: scimUser.emails[0].value.toLowerCase(), @@ -556,8 +596,9 @@ export const scimServiceFactory = ({ const [membership] = await orgDAL .findMembership({ - [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + [`${TableName.Membership}.id` as "id"]: orgMembershipId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }) .catch(() => { throw new ScimRequestError({ @@ -566,7 +607,7 @@ export const scimServiceFactory = ({ }); }); - if (!membership) + if (!membership || !membership.actorUserId) throw new ScimRequestError({ detail: "User not found", status: 404 @@ -587,7 +628,7 @@ export const scimServiceFactory = ({ { orgId, aliasType: org.orgAuthMethod === OrgAuthMethod.OIDC ? UserAliasType.OIDC : UserAliasType.SAML, - userId: membership.userId + userId: membership.actorUserId as string }, { externalId @@ -595,7 +636,7 @@ export const scimServiceFactory = ({ tx ); - await orgMembershipDAL.updateById( + await membershipUserDAL.updateById( membership.id, { isActive: active @@ -603,7 +644,7 @@ export const scimServiceFactory = ({ tx ); await userDAL.updateById( - membership.userId, + membership.actorUserId!, { firstName, email: email?.toLowerCase(), @@ -628,8 +669,9 @@ export const scimServiceFactory = ({ const deleteScimUser: TScimServiceFactory["deleteScimUser"] = async ({ orgMembershipId, orgId }) => { const [membership] = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + [`${TableName.Membership}.id` as "id"]: orgMembershipId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }); if (!membership) @@ -645,15 +687,17 @@ export const scimServiceFactory = ({ }); } - await deleteOrgMembershipFn({ - orgMembershipId: membership.id, - orgId: membership.orgId, + await deleteOrgMembershipsFn({ + orgMembershipIds: [membership.id], + orgId: membership.scopeOrgId, orgDAL, - projectMembershipDAL, - projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, - licenseService + licenseService, + membershipUserDAL, + membershipRoleDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL }); return {}; // intentionally return empty object upon success @@ -750,8 +794,9 @@ export const scimServiceFactory = ({ if (!externalGroupMapping) return; // only get org memberships that are new (invites) - const newOrgMemberships = await orgMembershipDAL.find({ + const newOrgMemberships = await membershipUserDAL.find({ status: "invited", + scope: AccessScope.Organization, $in: { id: members.map((member) => member.value) } @@ -760,15 +805,15 @@ export const scimServiceFactory = ({ if (!newOrgMemberships.length) return; // set new membership roles to group mapping value - await orgMembershipDAL.update( + await membershipRoleDAL.update( { $in: { - id: newOrgMemberships.map((membership) => membership.id) + membershipId: newOrgMemberships.map((membership) => membership.id) } }, { role: externalGroupMapping.role, - roleId: externalGroupMapping.roleId + customRoleId: externalGroupMapping.roleId } ); }; @@ -821,8 +866,26 @@ export const scimServiceFactory = ({ tx ); + const groupMembership = await membershipGroupDAL.create( + { + scope: AccessScope.Organization, + actorGroupId: group.id, + scopeOrgId: orgId + }, + tx + ); + + await membershipRoleDAL.create( + { + membershipId: groupMembership.id, + role: OrgMembershipRole.NoAccess + }, + tx + ); + if (members && members.length) { - const orgMemberships = await orgMembershipDAL.find({ + const orgMemberships = await membershipUserDAL.find({ + scope: AccessScope.Organization, $in: { id: members.map((member) => member.value) } @@ -830,14 +893,14 @@ export const scimServiceFactory = ({ const newMembers = await addUsersToGroupByUserIds({ group, - userIds: orgMemberships.map((membership) => membership.userId as string), + userIds: orgMemberships.map((membership) => membership.actorUserId as string), userDAL, userGroupMembershipDAL, orgDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, + membershipGroupDAL, tx }); @@ -850,9 +913,10 @@ export const scimServiceFactory = ({ }); const orgMemberships = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization, $in: { - [`${TableName.OrgMembership}.userId` as "userId"]: newGroup.newMembers.map((member) => member.id) + [`${TableName.Membership}.actorUserId` as "actorUserId"]: newGroup.newMembers.map((member) => member.id) } }); @@ -895,9 +959,10 @@ export const scimServiceFactory = ({ .then((g) => g.members); const orgMemberships = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization, $in: { - [`${TableName.OrgMembership}.userId` as "userId"]: users + [`${TableName.Membership}.actorUserId` as "actorUserId"]: users .filter((user) => user.isPartOfGroup) .map((user) => user.id) } @@ -933,10 +998,10 @@ export const scimServiceFactory = ({ } const updatedGroup = await groupDAL.transaction(async (tx) => { - if (group.name !== displayName) { + if (group?.name !== displayName) { await externalGroupOrgRoleMappingDAL.update( { - groupName: group.name, + groupName: group?.name, orgId }, { @@ -958,14 +1023,16 @@ export const scimServiceFactory = ({ } const orgMemberships = members.length - ? await orgMembershipDAL.find({ + ? await membershipUserDAL.find({ + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization, $in: { id: members.map((member) => member.value) } }) : []; - const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.actorUserId as string)); const userGroupMembers = await userGroupMembershipDAL.find({ groupId: group.id }); @@ -978,20 +1045,20 @@ export const scimServiceFactory = ({ const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); const allMembersUserIdsSet = new Set(allMembersUserIds); - const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.actorUserId as string)); const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); if (toAddUserIds.length) { await addUsersToGroupByUserIds({ group, - userIds: toAddUserIds.map((member) => member.userId as string), + userIds: toAddUserIds.map((member) => member.actorUserId as string), userDAL, userGroupMembershipDAL, orgDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, + membershipGroupDAL, tx }); } @@ -1002,7 +1069,7 @@ export const scimServiceFactory = ({ userIds: toRemoveUserIds, userDAL, userGroupMembershipDAL, - groupProjectDAL, + membershipGroupDAL, projectKeyDAL, tx }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 01caef223..7597dcfd4 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -2,9 +2,10 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + AccessScope, SecretApprovalRequestsSchema, TableName, - TOrgMemberships, + TMemberships, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, TUserGroupMembership, @@ -36,6 +37,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where(filter) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) .join( TableName.SecretApprovalPolicy, `${TableName.SecretApprovalRequest}.policyId`, @@ -109,24 +111,22 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `secretApprovalReviewerUser.id` ) - .leftJoin( - db(TableName.OrgMembership).as("approverOrgMembership"), - `${TableName.SecretApprovalPolicyApprover}.approverUserId`, - `approverOrgMembership.userId` - ) - - .leftJoin( - db(TableName.OrgMembership).as("approverGroupOrgMembership"), - `secretApprovalPolicyGroupApproverUser.id`, - `approverGroupOrgMembership.userId` - ) - - .leftJoin( - db(TableName.OrgMembership).as("reviewerOrgMembership"), - `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, - `reviewerOrgMembership.userId` - ) + .leftJoin(db(TableName.Membership).as("approverOrgMembership"), (qb) => { + qb.on(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, `approverOrgMembership.actorUserId`) + .andOn(`approverOrgMembership.scopeOrgId`, `${TableName.Project}.orgId`) + .andOn(`approverOrgMembership.scope`, db.raw("?", [AccessScope.Organization])); + }) + .leftJoin(db(TableName.Membership).as("approverGroupOrgMembership"), (qb) => { + qb.on(`secretApprovalPolicyGroupApproverUser.id`, `approverGroupOrgMembership.actorUserId`) + .andOn(`approverGroupOrgMembership.scopeOrgId`, `${TableName.Project}.orgId`) + .andOn(`approverGroupOrgMembership.scope`, db.raw("?", [AccessScope.Organization])); + }) + .leftJoin(db(TableName.Membership).as("reviewerOrgMembership"), (qb) => { + qb.on(`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `reviewerOrgMembership.actorUserId`) + .andOn(`reviewerOrgMembership.scopeOrgId`, `${TableName.Project}.orgId`) + .andOn(`reviewerOrgMembership.scope`, db.raw("?", [AccessScope.Organization])); + }) .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts index 85a3cd5f2..a5fbe37a7 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { WebhookEventMap } from "@octokit/webhooks-types"; import { ProbotOctokit } from "probot"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; @@ -49,7 +50,14 @@ export const secretScanningServiceFactory = ({ }: TInstallAppSessionDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const sessionId = crypto.randomBytes(16).toString("hex"); @@ -68,13 +76,14 @@ export const secretScanningServiceFactory = ({ const session = await gitAppInstallSessionDAL.findOne({ sessionId }); if (!session) throw new NotFoundError({ message: "Session was not found" }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - session.orgId, + orgId: session.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const installatedApp = await gitAppOrgDAL.transaction(async (tx) => { await gitAppInstallSessionDAL.deleteById(session.id, tx); @@ -117,7 +126,14 @@ export const secretScanningServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetOrgInstallStatusDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const appInstallation = await gitAppOrgDAL.findOne({ orgId }); @@ -125,7 +141,14 @@ export const secretScanningServiceFactory = ({ }; const getRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId, filter }: TGetOrgRisksDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const results = await secretScanningDAL.findByOrgId(orgId, filter); @@ -134,7 +157,14 @@ export const secretScanningServiceFactory = ({ }; const getAllRisksByOrg = async ({ actor, orgId, actorId, actorAuthMethod, actorOrgId }: TGetAllOrgRisksDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); const risks = await secretScanningDAL.find({ orgId }, { sort: [["createdAt", "desc"]] }); @@ -150,7 +180,14 @@ export const secretScanningServiceFactory = ({ riskId, status }: TUpdateRiskStatusDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); const isRiskResolved = Boolean( diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts index aa6d4f66a..0846532f9 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -44,10 +44,7 @@ type TSshHostGroupServiceFactoryDep = { sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; - permissionService: Pick< - TPermissionServiceFactory, - "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" - >; + permissionService: Pick; licenseService: Pick; groupDAL: Pick; }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-fns.ts b/backend/src/ee/services/ssh-host/ssh-host-fns.ts index dec15e093..7e188a5fe 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-fns.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-fns.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { ActionProjectType } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "../permission/project-permission"; import { TCreateSshLoginMappingsDTO } from "./ssh-host-types"; @@ -59,11 +60,12 @@ export const createSshLoginMappings = async ({ for await (const user of users) { // check that each user has access to the SSH project - await permissionService.getUserProjectPermission({ - userId: user.id, + await permissionService.getProjectPermission({ + actor: ActorType.USER, + actorId: user.id, projectId, - authMethod: actorAuthMethod, - userOrgId: actorOrgId, + actorAuthMethod, + actorOrgId, actionProjectType: ActionProjectType.SSH }); } diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index d1082b4df..37d843c47 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -64,10 +64,7 @@ type TSshHostServiceFactoryDep = { >; sshHostLoginUserDAL: TSshHostLoginUserDALFactory; sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; - permissionService: Pick< - TPermissionServiceFactory, - "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" - >; + permissionService: Pick; kmsService: Pick; }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts index a8269ac37..698f067f2 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -66,7 +66,7 @@ type BaseCreateSshLoginMappingsDTO = { sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; - permissionService: Pick; + permissionService: Pick; groupDAL: Pick; projectId: string; actorAuthMethod: ActorAuthMethod; diff --git a/backend/src/ee/services/sub-org/sub-org-service.ts b/backend/src/ee/services/sub-org/sub-org-service.ts new file mode 100644 index 000000000..74bf784d5 --- /dev/null +++ b/backend/src/ee/services/sub-org/sub-org-service.ts @@ -0,0 +1,160 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects, OrgPermissionSubOrgActions } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service-types"; +import { TCreateSubOrgDTO, TListSubOrgDTO, TUpdateSubOrgDTO } from "./sub-org-types"; + +type TSubOrgServiceFactoryDep = { + orgDAL: Pick< + TOrgDALFactory, + "findOne" | "create" | "transaction" | "listSubOrganizations" | "updateById" | "findById" + >; + permissionService: Pick; + licenseService: Pick; + membershipDAL: Pick; + membershipRoleDAL: Pick; +}; + +export type TSubOrgServiceFactory = ReturnType; + +export const subOrgServiceFactory = ({ + orgDAL, + permissionService, + licenseService, + membershipDAL, + membershipRoleDAL +}: TSubOrgServiceFactoryDep) => { + const createSubOrg = async ({ name, permissionActor }: TCreateSubOrgDTO) => { + const { permission } = await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: permissionActor.orgId, + actorOrgId: permissionActor.orgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.ParentOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionSubOrgActions.Create, + OrgPermissionSubjects.SubOrganization + ); + + const orgLicensePlan = await licenseService.getPlan(permissionActor.rootOrgId); + if (!orgLicensePlan.subOrganization) { + throw new BadRequestError({ + message: "Sub-organization creation failed. Please upgrade your instance to Infisical's Enterprise plan." + }); + } + + const existingSubOrg = await orgDAL.findOne({ + parentOrgId: permissionActor.orgId, + name + }); + if (existingSubOrg) { + throw new BadRequestError({ message: `Sub-organization with name ${name} already exists` }); + } + + const organization = await orgDAL.transaction(async (tx) => { + const org = await orgDAL.create( + { name, slug: name, rootOrgId: permissionActor.rootOrgId, parentOrgId: permissionActor.orgId }, + tx + ); + const membership = await membershipDAL.create( + { + scope: AccessScope.Organization, + [permissionActor.type === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"]: permissionActor.id, + scopeOrgId: org.id, + status: OrgMembershipStatus.Accepted, + isActive: true + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); + return org; + }); + + return { + organization + }; + }; + + const listSubOrgs = async ({ permissionActor, data }: TListSubOrgDTO) => { + await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: permissionActor.rootOrgId, + actorOrgId: permissionActor.rootOrgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.Any + }); + + const organizations = await orgDAL.listSubOrganizations({ + actorId: permissionActor.id, + actorType: permissionActor.type, + orgId: permissionActor.rootOrgId, + isAccessible: data?.isAccessible, + limit: data?.limit, + offset: data?.offset + }); + + return { + organizations + }; + }; + + const updateSubOrg = async ({ subOrgId, name, permissionActor }: TUpdateSubOrgDTO) => { + const subOrg = await orgDAL.findOne({ + rootOrgId: permissionActor.rootOrgId, + id: subOrgId + }); + if (!subOrg) { + throw new BadRequestError({ message: "Sub-organization not found" }); + } + + const { permission } = await permissionService.getOrgPermission({ + actorId: permissionActor.id, + actor: permissionActor.type, + orgId: subOrgId, + actorOrgId: subOrgId, + actorAuthMethod: permissionActor.authMethod, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + const existingSubOrg = await orgDAL.findOne({ + parentOrgId: subOrg.parentOrgId, + slug: name + }); + + if (existingSubOrg && existingSubOrg.id !== subOrgId) { + throw new BadRequestError({ message: `Sub-organization with name ${name} already exists` }); + } + + const organization = await orgDAL.updateById(subOrgId, { name, slug: name }); + + return { + organization + }; + }; + + return { + createSubOrg, + listSubOrgs, + updateSubOrg + }; +}; diff --git a/backend/src/ee/services/sub-org/sub-org-types.ts b/backend/src/ee/services/sub-org/sub-org-types.ts new file mode 100644 index 000000000..a1af9878e --- /dev/null +++ b/backend/src/ee/services/sub-org/sub-org-types.ts @@ -0,0 +1,22 @@ +import { OrgServiceActor } from "@app/lib/types"; + +export type TCreateSubOrgDTO = { + name: string; + permissionActor: OrgServiceActor; +}; + +export type TListSubOrgDTO = { + permissionActor: OrgServiceActor; + data: Partial<{ + limit?: number; + offset?: number; + search?: string; + isAccessible?: boolean; + }>; +}; + +export type TUpdateSubOrgDTO = { + subOrgId: string; + name: string; + permissionActor: OrgServiceActor; +}; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 17f4aa493..3155fe05c 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -23,6 +23,7 @@ export const PgSqlLock = { InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"), OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`), OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-init:${orgId}`), + GatewayPamSessionKey: (gatewayId: string) => pgAdvisoryLockHashText(`gateway-pam-session-key:${gatewayId}`), IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`) } as const; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2d0de59a2..dde9b4e35 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -33,6 +33,7 @@ export enum ApiDocsTags { LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", + SubOrganizations = "Sub Organizations", Projects = "Projects", ProjectUsers = "Project Users", ProjectGroups = "Project Groups", @@ -57,6 +58,7 @@ export enum ApiDocsTags { PkiCertificateAuthorities = "PKI Certificate Authorities", PkiCertificates = "PKI Certificates", PkiCertificateTemplates = "PKI Certificate Templates", + PkiCertificateProfiles = "PKI Certificate Profiles", PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", PkiSubscribers = "PKI Subscribers", @@ -122,16 +124,16 @@ export const IDENTITIES = { hasDeleteProtection: "Prevents deletion of the identity when enabled." }, UPDATE: { - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", name: "The new name of the identity.", role: "The new role of the identity.", hasDeleteProtection: "Prevents deletion of the identity when enabled." }, DELETE: { - identityId: "The ID of the identity to delete." + identityId: "The ID of the machine identity to delete." }, GET_BY_ID: { - identityId: "The ID of the identity to get details.", + identityId: "The ID of the machine identity to get details.", orgId: "The ID of the org of the identity" }, LIST: { @@ -156,7 +158,7 @@ export const UNIVERSAL_AUTH = { clientSecret: "Your Machine Identity Client Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", clientSecretTrustedIps: "A list of IPs or CIDR ranges that the Client Secret can be used from together with the Client ID to get back an access token. You can use 0.0.0.0/0, to allow usage from any network address.", accessTokenTrustedIps: @@ -175,13 +177,13 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", clientSecretTrustedIps: "The new list of IPs or CIDR ranges that the Client Secret can be used from.", accessTokenTrustedIps: "The new list of IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", @@ -195,25 +197,25 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, CREATE_CLIENT_SECRET: { - identityId: "The ID of the identity to create a client secret for.", + identityId: "The ID of the machine identity to create a client secret for.", description: "The description of the client secret.", numUsesLimit: "The maximum number of times that the client secret can be used; a value of 0 implies infinite number of uses.", ttl: "The lifetime for the client secret in seconds." }, LIST_CLIENT_SECRETS: { - identityId: "The ID of the identity to list client secrets for." + identityId: "The ID of the machine identity to list client secrets for." }, GET_CLIENT_SECRET: { - identityId: "The ID of the identity to get the client secret from.", + identityId: "The ID of the machine identity to get the client secret from.", clientSecretId: "The ID of the client secret to get details." }, REVOKE_CLIENT_SECRET: { - identityId: "The ID of the identity to revoke the client secret from.", + identityId: "The ID of the machine identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." @@ -225,13 +227,13 @@ export const UNIVERSAL_AUTH = { export const LDAP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", username: "The username of the LDAP user to login.", password: "The password of the LDAP user to login." }, ATTACH: { templateId: "The ID of the identity auth template to attach the configuration onto.", - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: "The comma-separated array of key/value pairs of required fields that the LDAP entry must have in order to authenticate.", @@ -251,7 +253,7 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { - identityId: "The ID of the identity to update the configuration for.", + identityId: "The ID of the machine identity to update the configuration for.", url: "The new URL of the LDAP server.", allowedFields: "The comma-separated list of allowed fields to return from the LDAP user.", searchBase: "The new base DN to search for the LDAP user.", @@ -271,19 +273,19 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the configuration for." + identityId: "The ID of the machine identity to retrieve the configuration for." }, REVOKE: { - identityId: "The ID of the identity to revoke the configuration for." + identityId: "The ID of the machine identity to revoke the configuration for." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." } } as const; export const ALICLOUD_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", Action: "The Alibaba Cloud API action. For STS GetCallerIdentity, this should be 'GetCallerIdentity'.", Format: "The response format. For STS GetCallerIdentity, this should be 'JSON'.", Version: "The API version. This should be in 'YYYY-MM-DD' format (e.g., '2015-04-01').", @@ -295,7 +297,7 @@ export const ALICLOUD_AUTH = { Signature: "The signature string calculated based on the request parameters and AccessKey Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", @@ -303,7 +305,7 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", @@ -311,19 +313,19 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TLS_CERT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -333,7 +335,7 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -343,16 +345,16 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AWS_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", iamHttpRequestMethod: "The HTTP request method used in the signed request.", iamRequestUrl: "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/.", @@ -361,7 +363,7 @@ export const AWS_AUTH = { iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedPrincipalArns: "The comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -373,7 +375,7 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedPrincipalArns: "The new comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -385,21 +387,21 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const OCI_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", userOcid: "The OCID of the user attempting login.", headers: "The headers of the signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -409,7 +411,7 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -419,19 +421,19 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AZURE_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenantId: "The tenant ID for the Azure AD organization.", resource: "The resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -442,7 +444,7 @@ export const AZURE_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenantId: "The new tenant ID for the Azure AD organization.", resource: "The new resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -453,19 +455,19 @@ export const AZURE_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const GCP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedServiceAccounts: "The comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -478,7 +480,7 @@ export const GCP_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedServiceAccounts: "The new comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -491,19 +493,19 @@ export const GCP_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const KUBERNETES_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", kubernetesHost: "The host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -522,7 +524,7 @@ export const KUBERNETES_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", kubernetesHost: "The new host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -541,41 +543,41 @@ export const KUBERNETES_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TOKEN_AUTH = { ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, GET_TOKENS: { - identityId: "The ID of the identity to list token metadata for.", + identityId: "The ID of the machine identity to list token metadata for.", offset: "The offset to start from. If you enter 10, it will start from the 10th token.", limit: "The number of tokens to return." }, CREATE_TOKEN: { - identityId: "The ID of the identity to create the token for.", + identityId: "The ID of the machine identity to create the token for.", name: "The name of the token to create." }, UPDATE_TOKEN: { @@ -589,10 +591,10 @@ export const TOKEN_AUTH = { export const OIDC_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", oidcDiscoveryUrl: "The URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The unique identifier of the identity provider issuing the JWT.", @@ -606,7 +608,7 @@ export const OIDC_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", oidcDiscoveryUrl: "The new URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The new PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The new unique identifier of the identity provider issuing the JWT.", @@ -620,19 +622,19 @@ export const OIDC_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const JWT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", @@ -649,7 +651,7 @@ export const JWT_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", @@ -666,10 +668,10 @@ export const JWT_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; @@ -716,6 +718,21 @@ export const ORGANIZATIONS = { } } as const; +export const SUB_ORGANIZATIONS = { + CREATE: { + name: "The name of the sub organization to create." + }, + UPDATE: { + name: "The name of the sub organization to update.", + subOrgId: "The id of the sub organization to update." + }, + LIST: { + limit: "The number of sub organizations to return.", + offset: "The offset to start from. If you enter 10, it will start from the 10th sub organization.", + isAccessible: "Filter to only return sub organizations that the actor has access to." + } +} as const; + export const PROJECTS = { CREATE: { organizationSlug: "The slug of the organization to create the project in.", @@ -853,12 +870,12 @@ export const PROJECT_IDENTITIES = { search: "The text string that identity membership names will be filtered by." }, GET_IDENTITY_MEMBERSHIP_BY_ID: { - identityId: "The ID of the identity to get the membership for.", + identityId: "The ID of the machine identity to get the membership for.", projectId: "The ID of the project to get the identity membership for." }, UPDATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to update the identity membership for.", - identityId: "The ID of the identity to update the membership for.", + identityId: "The ID of the machine identity to update the membership for.", roles: { description: "A list of role slugs to assign to the identity project membership.", role: "The role slug to assign to the newly created identity project membership.", @@ -871,11 +888,11 @@ export const PROJECT_IDENTITIES = { }, DELETE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to delete the identity membership from.", - identityId: "The ID of the identity to delete the membership from." + identityId: "The ID of the machine identity to delete the membership from." }, CREATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to create the identity membership from.", - identityId: "The ID of the identity to create the membership from.", + identityId: "The ID of the machine identity to create the membership from.", role: "The role slug to assign to the newly created identity project membership.", roles: { description: "A list of role slugs to assign to the newly created identity project membership.", @@ -949,7 +966,8 @@ export const FOLDERS = { projectId: "The ID of the project to delete the folder from.", environment: "The slug of the environment where the folder is located.", directory: "The directory of the folder to delete. (Deprecated in favor of path)", - path: "The path of the folder to delete." + path: "The path of the folder to delete.", + forceDelete: "Whether to force delete the folder even if it contains resources." } } as const; @@ -1270,7 +1288,7 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to create.", + identityId: "The ID of the machine identity to create.", slug: "The slug of the privilege to create.", permissions: `@deprecated - use privilegePermission The permission object for the privilege. @@ -1296,7 +1314,7 @@ The permission object for the privilege. }, UPDATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", permissions: `@deprecated - use privilegePermission @@ -1322,17 +1340,17 @@ The permission object for the privilege. }, DELETE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, LIST: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", unpacked: "Whether the system should send the permissions as unpacked." } }; @@ -1374,7 +1392,7 @@ export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { CREATE: { - identityId: "The ID of the identity to create the privilege for.", + identityId: "The ID of the machine identity to create the privilege for.", projectId: "The ID of the project of the identity in.", slug: "The slug of the privilege to create.", permission: "The permission for the privilege.", @@ -1385,7 +1403,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, UPDATE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", privilegePermission: "The permission for the privilege.", isTemporary: "Whether the privilege is temporary.", @@ -1395,12 +1413,12 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, DELETE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, GET_BY_ID: { @@ -1408,7 +1426,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, LIST: { projectId: "The ID of the project that the identity is in.", - identityId: "The ID of the identity to list." + identityId: "The ID of the machine identity to list." } }; @@ -2355,6 +2373,9 @@ export const AppConnections = { sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", sslCertificate: "The SSL certificate (PEM format) to use for secure connection." + }, + LARAVEL_FORGE: { + apiToken: "The API token used to authenticate with Laravel Forge." } } }; @@ -2507,6 +2528,14 @@ export const SecretSyncs = { branch: "The branch to sync preview secrets to.", teamId: "The ID of the Vercel team to sync secrets to." }, + LARAVEL_FORGE: { + orgSlug: "The slug of the Laravel Forge org to sync secrets to.", + orgName: "The name of the Laravel Forge org to sync secrets to.", + serverId: "The ID of the Laravel Forge server to sync secrets to.", + serverName: "The name of the Laravel Forge server to sync secrets to.", + siteId: "The ID of the Laravel Forge site to sync secrets to.", + siteName: "The name of the Laravel Forge site to sync secrets to." + }, WINDMILL: { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path to sync secrets to." diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 15f878323..9fc4cff92 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,7 +1,9 @@ import { z } from "zod"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { crypto } from "@app/lib/crypto/cryptography"; import { QueueWorkerProfile } from "@app/lib/types"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { BadRequestError } from "../errors"; @@ -363,11 +365,6 @@ const envSchema = z /* INTERNAL ----------------------------------------------------------------------------- */ INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()) }) - // To ensure that basic encryption is always possible. - .refine( - (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), - "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." - ) .refine( (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS), "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined." @@ -453,7 +450,12 @@ export const getConfig = () => envCfg; export const getOriginalConfig = () => originalEnvConfig; // cannot import singleton logger directly as it needs config to load various transport -export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logger?: CustomLogger) => { +export const initEnvConfig = async ( + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + superAdminDAL?: TSuperAdminDALFactory, + logger?: CustomLogger +) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { (logger ?? console).error("Invalid environment variables. Check the error below"); @@ -469,7 +471,7 @@ export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logge } if (superAdminDAL) { - const fipsEnabled = await crypto.initialize(superAdminDAL); + const fipsEnabled = await crypto.initialize(superAdminDAL, hsmService, kmsRootConfigDAL); if (fipsEnabled) { const newEnvCfg = { @@ -532,6 +534,22 @@ export const getDatabaseCredentials = (logger?: CustomLogger) => { }; }; +export const getHsmConfig = (logger?: CustomLogger) => { + const parsedEnv = envSchema.safeParse(process.env); + if (!parsedEnv.success) { + (logger ?? console).error("Invalid environment variables. Check the error below"); + (logger ?? console).error(parsedEnv.error.issues); + process.exit(-1); + } + return { + isHsmConfigured: parsedEnv.data.isHsmConfigured, + HSM_PIN: parsedEnv.data.HSM_PIN, + HSM_SLOT: parsedEnv.data.HSM_SLOT, + HSM_LIB_PATH: parsedEnv.data.HSM_LIB_PATH, + HSM_KEY_LABEL: parsedEnv.data.HSM_KEY_LABEL + }; +}; + // A list of environment variables that can be overwritten export const overwriteSchema: { [key: string]: { diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index 45c7a1986..6e2a15740 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -9,7 +9,11 @@ import nacl from "tweetnacl"; import naclUtils from "tweetnacl-util"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { ADMIN_CONFIG_DB_UUID } from "@app/services/super-admin/super-admin-service"; @@ -106,49 +110,73 @@ const cryptographyFactory = () => { } }; - const $setFipsModeEnabled = (enabled: boolean, envCfg?: Pick) => { + const $setFipsModeEnabled = async ( + enabled: boolean, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + envCfg?: Pick + ) => { // If FIPS is enabled, we need to validate that the ENCRYPTION_KEY is in a base64 format, and is a 256-bit key. if (enabled) { crypto.setFips(true); const appCfg = envCfg || getConfig(); - if (appCfg.ENCRYPTION_KEY) { - // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL + }); - // note(daniel): for some reason this resolves as true for some hex-encoded strings. - if (!isBase64(appCfg.ENCRYPTION_KEY)) { + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + // only perform encryption key validation if it's actually required. + if (needsEncryptionKey) { + if (appCfg.ENCRYPTION_KEY) { + // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key + + // note(daniel): for some reason this resolves as true for some hex-encoded strings. + if (!isBase64(appCfg.ENCRYPTION_KEY)) { + throw new CryptographyError({ + message: + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + }); + } + + if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) { + throw new CryptographyError({ + message: + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + }); + } + } else { throw new CryptographyError({ message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" }); } - - if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) { - throw new CryptographyError({ - message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" - }); - } - } else { - throw new CryptographyError({ - message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" - }); } } $fipsEnabled = enabled; $isInitialized = true; }; - const initialize = async (superAdminDAL: TSuperAdminDALFactory, envCfg?: Pick) => { + const initialize = async ( + superAdminDAL: TSuperAdminDALFactory, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + envCfg?: Pick + ) => { if ($isInitialized) { return isFipsModeEnabled(); } if (process.env.FIPS_ENABLED !== "true") { logger.info("Cryptography module initialized in normal operation mode."); - $setFipsModeEnabled(false, envCfg); + await $setFipsModeEnabled(false, hsmService, kmsRootConfigDAL, envCfg); return false; } @@ -158,11 +186,11 @@ const cryptographyFactory = () => { if (serverCfg) { if (serverCfg.fipsEnabled) { logger.info("[FIPS]: Instance is configured for FIPS mode of operation. Continuing startup with FIPS enabled."); - $setFipsModeEnabled(true, envCfg); + await $setFipsModeEnabled(true, hsmService, kmsRootConfigDAL, envCfg); return true; } logger.info("[FIPS]: Instance age predates FIPS mode inception date. Continuing without FIPS."); - $setFipsModeEnabled(false, envCfg); + await $setFipsModeEnabled(false, hsmService, kmsRootConfigDAL, envCfg); return false; } @@ -171,7 +199,7 @@ const cryptographyFactory = () => { // TODO(daniel): check if it's an enterprise deployment // if there is no server cfg, and FIPS_MODE is `true`, its a fresh FIPS deployment. We need to set the fipsEnabled to true. - $setFipsModeEnabled(true, envCfg); + await $setFipsModeEnabled(true, hsmService, kmsRootConfigDAL, envCfg); return true; }; @@ -258,6 +286,13 @@ const cryptographyFactory = () => { const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY; + // Sanity check + if (!rootEncryptionKey && !encryptionKey) { + throw new CryptographyError({ + message: "Tried to encrypt with instance root encryption key, but no root encryption key is set." + }); + } + if (rootEncryptionKey) { const { iv, tag, ciphertext } = encrypt({ plaintext: data, @@ -303,6 +338,14 @@ const cryptographyFactory = () => { // the or gate is used used in migration const rootEncryptionKey = appCfg?.ROOT_ENCRYPTION_KEY || process.env.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg?.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY; + + // Sanity check + if (!rootEncryptionKey && !encryptionKey) { + throw new CryptographyError({ + message: "Tried to decrypt with instance root encryption key, but no root encryption key is set." + }); + } + if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) { const data = symmetric().decrypt({ key: rootEncryptionKey, diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 65d0b7859..6ff7278aa 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -53,3 +53,53 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => { return result; }; + +export const deepEqual = (obj1: unknown, obj2: unknown): boolean => { + if (obj1 === obj2) return true; + + if (obj1 === null || obj2 === null || obj1 === undefined || obj2 === undefined) { + return obj1 === obj2; + } + + if (typeof obj1 !== typeof obj2) return false; + + if (typeof obj1 !== "object") return obj1 === obj2; + + if (Array.isArray(obj1) !== Array.isArray(obj2)) return false; + + if (Array.isArray(obj1)) { + const arr1 = obj1 as unknown[]; + const arr2 = obj2 as unknown[]; + if (arr1.length !== arr2.length) return false; + return arr1.every((val, idx) => deepEqual(val, arr2[idx])); + } + + const keys1 = Object.keys(obj1 as Record).sort(); + const keys2 = Object.keys(obj2 as Record).sort(); + + if (keys1.length !== keys2.length) return false; + if (keys1.some((key, idx) => key !== keys2[idx])) return false; + + return keys1.every((key) => + deepEqual((obj1 as Record)[key], (obj2 as Record)[key]) + ); +}; + +export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: string[] = []): boolean => { + if (skipFields.length === 0) { + return deepEqual(obj1, obj2); + } + + if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 === null || obj2 === null) { + return deepEqual(obj1, obj2); + } + + const filtered1 = Object.fromEntries( + Object.entries(obj1 as Record).filter(([key]) => !skipFields.includes(key)) + ); + const filtered2 = Object.fromEntries( + Object.entries(obj2 as Record).filter(([key]) => !skipFields.includes(key)) + ); + + return deepEqual(filtered1, filtered2); +}; diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index e6e873f11..5ae0e5b1d 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -18,7 +18,7 @@ interface IGatewayRelayServer { getRelayError: () => string; } -const createRelayConnection = async ({ +export const createRelayConnection = async ({ relayHost, clientCertificate, clientPrivateKey, diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index a7a60349f..5fbe8ca79 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -5,7 +5,7 @@ export type TGenericPermission = { actor: ActorType; actorId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }; /** @@ -78,6 +78,15 @@ export type OrgServiceActor = { id: string; authMethod: ActorAuthMethod; orgId: string; + rootOrgId: string; + parentOrgId: string; +}; + +export type ProjectServiceActor = { + type: ActorType; + id: string; + authMethod: ActorAuthMethod; + orgId: string; }; export enum QueueWorkerProfile { diff --git a/backend/src/main.ts b/backend/src/main.ts index 7be9f43ec..400804804 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,14 +9,16 @@ import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; +import { hsmServiceFactory } from "./ee/services/hsm/hsm-service"; import { keyStoreFactory } from "./keystore/keystore"; -import { formatSmtpConfig, getDatabaseCredentials, initEnvConfig } from "./lib/config/env"; +import { formatSmtpConfig, getDatabaseCredentials, getHsmConfig, initEnvConfig } from "./lib/config/env"; import { buildRedisFromConfig } from "./lib/config/redis"; import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; import { main } from "./server/app"; import { bootstrapCheck } from "./server/boot-strap-check"; +import { kmsRootConfigDALFactory } from "./services/kms/kms-root-config-dal"; import { smtpServiceFactory } from "./services/smtp/smtp-service"; import { superAdminDALFactory } from "./services/super-admin/super-admin-dal"; @@ -26,6 +28,18 @@ const run = async () => { const logger = initLogger(); await removeTemporaryBaseDirectory(); + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + const databaseCredentials = getDatabaseCredentials(logger); const db = initDbConnection({ @@ -35,7 +49,8 @@ const run = async () => { }); const superAdminDAL = superAdminDALFactory(db); - const envConfig = await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const envConfig = await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const auditLogDb = envConfig.AUDIT_LOGS_DB_CONNECTION_URI ? initAuditLogDbConnection({ @@ -59,14 +74,12 @@ const run = async () => { const keyStore = keyStoreFactory(envConfig, keyValueStoreDAL); const redis = buildRedisFromConfig(envConfig); - const hsmModule = initializeHsmModule(envConfig); - hsmModule.initialize(); - const server = await main({ db, auditLogDb, superAdminDAL, - hsmModule: hsmModule.getModule(), + kmsRootConfigDAL, + hsmService, smtp, logger, queue, diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 335f25d5f..9d8c472f4 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -76,7 +76,9 @@ export enum QueueName { TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", - UserNotification = "user-notification" + UserNotification = "user-notification", + HealthAlert = "health-alert", + PamAccountRotation = "pam-account-rotation" } export enum QueueJobs { @@ -124,7 +126,9 @@ export enum QueueJobs { TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", - UserNotification = "user-notification-job" + UserNotification = "user-notification-job", + HealthAlert = "health-alert", + PamAccountRotation = "pam-account-rotation" } export type TQueueJobTypes = { @@ -351,6 +355,14 @@ export type TQueueJobTypes = { name: QueueJobs.UserNotification; payload: { notifications: TCreateUserNotificationDTO[] }; }; + [QueueName.HealthAlert]: { + name: QueueJobs.HealthAlert; + payload: undefined; + }; + [QueueName.PamAccountRotation]: { + name: QueueJobs.PamAccountRotation; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 8cf23f703..f1176b932 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -15,12 +15,13 @@ import fastify from "fastify"; import { Cluster, Redis } from "ioredis"; import { Knex } from "knex"; -import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, IS_PACKAGED, TEnvConfig } from "@app/lib/config/env"; import { CustomLogger } from "@app/lib/logger/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TQueueServiceFactory } from "@app/queue"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; @@ -42,16 +43,16 @@ type TMain = { logger?: CustomLogger; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; - hsmModule: HsmModule; redis: Redis | Cluster; envConfig: TEnvConfig; superAdminDAL: TSuperAdminDALFactory; + hsmService: THsmServiceFactory; + kmsRootConfigDAL: TKmsRootConfigDALFactory; }; // Run the server! export const main = async ({ db, - hsmModule, auditLogDb, smtp, logger, @@ -59,7 +60,9 @@ export const main = async ({ keyStore, redis, envConfig, - superAdminDAL + superAdminDAL, + hsmService, + kmsRootConfigDAL }: TMain) => { const appCfg = getConfig(); @@ -148,9 +151,10 @@ export const main = async ({ db, auditLogDb, keyStore, - hsmModule, + hsmService, envConfig, - superAdminDAL + superAdminDAL, + kmsRootConfigDAL }); await server.register(registerServeUI, { diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index d0750926d..035e645f4 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -43,6 +43,6 @@ export const GenericResourceNameSchema = z export const BaseSecretNameSchema = z.string().trim().min(1); export const SecretNameSchema = BaseSecretNameSchema.refine( - (el) => !el.includes(":"), - "Secret name cannot contain colon." -).refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); + (el) => !el.includes(":") && !el.includes("/"), + "Secret name cannot contain colon or forward slash." +); diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 1bff11879..b33f2fbe6 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -8,6 +8,7 @@ import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; +import { slugSchema } from "@app/server/lib/schemas"; import { ActorType, AuthMethod, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -20,6 +21,8 @@ export type TAuthMode = tokenVersionId: string; // the session id of token used user: TUsers; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: AuthMethod; isMfaVerified?: boolean; token: AuthModeJwtTokenPayload; @@ -31,6 +34,8 @@ export type TAuthMode = userId: string; user: TUsers; orgId: string; + rootOrgId: string; + parentOrgId: string; token: string; } | { @@ -39,6 +44,8 @@ export type TAuthMode = actor: ActorType.SERVICE; serviceTokenId: string; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: null; token: string; } @@ -48,6 +55,8 @@ export type TAuthMode = identityId: string; identityName: string; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: null; isInstanceAdmin?: boolean; token: TIdentityAccessTokenJwtPayload; @@ -57,6 +66,8 @@ export type TAuthMode = actor: ActorType.SCIM_CLIENT; scimTokenId: string; orgId: string; + rootOrgId: string; + parentOrgId: string; authMethod: null; }; @@ -136,17 +147,26 @@ export const injectIdentity = fp( if (!authMode) return; + const subOrganizationSelector = req.headers?.["x-infisical-org"] as string | undefined; + if (subOrganizationSelector) { + await slugSchema().parseAsync(subOrganizationSelector); + } + switch (authMode) { case AuthMode.JWT: { - const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token); + const { user, tokenVersionId, orgId, rootOrgId, parentOrgId } = + await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); requestContext.set("orgId", orgId); + req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId, actor, - orgId: orgId as string, + orgId, + rootOrgId, + parentOrgId, authMethod: token.authMethod, isMfaVerified: token.isMfaVerified, token @@ -154,13 +174,19 @@ export const injectIdentity = fp( break; } case AuthMode.IDENTITY_ACCESS_TOKEN: { - const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); + const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( + token, + subOrganizationSelector, + req.realIp + ); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, orgId: identity.orgId, + rootOrgId: identity.rootOrgId, + parentOrgId: identity.parentOrgId, identityId: identity.identityId, identityName: identity.name, authMethod: null, @@ -190,8 +216,14 @@ export const injectIdentity = fp( case AuthMode.SERVICE_TOKEN: { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); requestContext.set("orgId", serviceToken.orgId); + + if (subOrganizationSelector) + throw new BadRequestError({ message: `Service token doesn't support sub organization selector` }); + req.auth = { orgId: serviceToken.orgId, + rootOrgId: serviceToken.rootOrgId, + parentOrgId: serviceToken.parentOrgId, authMode: AuthMode.SERVICE_TOKEN as const, serviceToken, serviceTokenId: serviceToken.id, @@ -202,22 +234,27 @@ export const injectIdentity = fp( break; } case AuthMode.API_KEY: { - const user = await server.services.apiKey.fnValidateApiKey(token as string); - req.auth = { - authMode: AuthMode.API_KEY as const, - userId: user.id, - actor, - user, - orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon! - authMethod: null, - token: token as string - }; - break; + throw new BadRequestError({ + message: "API key authentication is not supported anymore. Please switch to identity authentication." + }); } case AuthMode.SCIM_TOKEN: { const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); requestContext.set("orgId", orgId); - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null }; + + if (subOrganizationSelector) + throw new BadRequestError({ message: `SCIM token doesn't support sub organization selector` }); + + req.auth = { + authMode: AuthMode.SCIM_TOKEN, + actor, + scimTokenId, + orgId, + authMethod: null, + // scim cannot be done for sub organization + rootOrgId: orgId, + parentOrgId: orgId + }; break; } default: diff --git a/backend/src/server/plugins/auth/inject-permission.ts b/backend/src/server/plugins/auth/inject-permission.ts index 11a94657b..827a055d3 100644 --- a/backend/src/server/plugins/auth/inject-permission.ts +++ b/backend/src/server/plugins/auth/inject-permission.ts @@ -14,7 +14,9 @@ export const injectPermission = fp(async (server) => { type: ActorType.USER, id: req.auth.userId, orgId: req.auth.orgId, // if the req.auth.authMode is AuthMode.API_KEY, the orgId will be "API_KEY" - authMethod: req.auth.authMethod // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + authMethod: req.auth.authMethod, // if the req.auth.authMode is AuthMode.API_KEY, the authMethod will be null + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId }; logger.info( @@ -25,7 +27,9 @@ export const injectPermission = fp(async (server) => { type: ActorType.IDENTITY, id: req.auth.identityId, orgId: req.auth.orgId, - authMethod: null + authMethod: null, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId }; logger.info( @@ -36,6 +40,8 @@ export const injectPermission = fp(async (server) => { type: ActorType.SERVICE, id: req.auth.serviceTokenId, orgId: req.auth.orgId, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId, authMethod: null }; @@ -47,6 +53,8 @@ export const injectPermission = fp(async (server) => { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId, + rootOrgId: req.auth.rootOrgId, + parentOrgId: req.auth.parentOrgId, authMethod: null }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d86cd45ab..bf2a3c10a 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -46,13 +46,10 @@ import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/gi import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; -import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; -import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { identityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-dal"; import { identityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-service"; -import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; -import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; -import { identityProjectAdditionalPrivilegeV2ServiceFactory } from "@app/ee/services/identity-project-additional-privilege-v2/identity-project-additional-privilege-v2-service"; import { kmipClientCertificateDALFactory } from "@app/ee/services/kmip/kmip-client-certificate-dal"; import { kmipClientDALFactory } from "@app/ee/services/kmip/kmip-client-dal"; import { kmipOperationServiceFactory } from "@app/ee/services/kmip/kmip-operation-service"; @@ -66,13 +63,19 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; +import { pamAccountDALFactory } from "@app/ee/services/pam-account/pam-account-dal"; +import { pamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal"; +import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service"; +import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal"; +import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service"; +import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal"; +import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; -import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; -import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { instanceRelayConfigDalFactory } from "@app/ee/services/relay/instance-relay-config-dal"; @@ -128,17 +131,21 @@ import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login- import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; +import { subOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; import { registerSecretScanningV2Webhooks } from "@app/server/plugins/secret-scanner-v2"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; +import { additionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; +import { additionalPrivilegeServiceFactory } from "@app/services/additional-privilege/additional-privilege-service"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; @@ -162,14 +169,24 @@ import { externalCertificateAuthorityDALFactory } from "@app/services/certificat import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; +import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; +import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; +import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; +import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; +import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; +import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal"; import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; @@ -179,8 +196,8 @@ import { folderCommitChangesDALFactory } from "@app/services/folder-commit-chang import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; -import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; +import { healthAlertServiceFactory } from "@app/services/health-alert/health-alert-queue"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; @@ -206,7 +223,6 @@ import { identityOciAuthServiceFactory } from "@app/services/identity-oci-auth/i import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; -import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal"; import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service"; @@ -221,8 +237,17 @@ import { integrationAuthDALFactory } from "@app/services/integration-auth/integr import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; -import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; +import { membershipDALFactory } from "@app/services/membership/membership-dal"; +import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; +import { membershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; +import { membershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service"; +import { membershipIdentityDALFactory } from "@app/services/membership-identity/membership-identity-dal"; +import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; +import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; +import { membershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; @@ -232,13 +257,11 @@ import { userNotificationDALFactory } from "@app/services/notification/user-noti import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; -import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; -import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; -import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -266,15 +289,14 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal" import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; -import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; -import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; -import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { reminderDALFactory } from "@app/services/reminder/reminder-dal"; import { dailyReminderQueueServiceFactory } from "@app/services/reminder/reminder-queue"; import { reminderServiceFactory } from "@app/services/reminder/reminder-service"; import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal"; import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; +import { roleDALFactory } from "@app/services/role/role-dal"; +import { roleServiceFactory } from "@app/services/role/role-service"; import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretQueueFactory } from "@app/services/secret/secret-queue"; import { secretServiceFactory } from "@app/services/secret/secret-service"; @@ -344,20 +366,22 @@ export const registerRoutes = async ( auditLogDb, superAdminDAL, db, - hsmModule, smtp: smtpService, queue: queueService, keyStore, - envConfig + envConfig, + hsmService, + kmsRootConfigDAL }: { auditLogDb?: Knex; superAdminDAL: TSuperAdminDALFactory; db: Knex; - hsmModule: HsmModule; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; envConfig: TEnvConfig; + hsmService: THsmServiceFactory; + kmsRootConfigDAL: TKmsRootConfigDALFactory; } ) => { const appCfg = getConfig(); @@ -372,18 +396,13 @@ export const registerRoutes = async ( const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); const orgMembershipDAL = orgMembershipDALFactory(db); - const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); - const orgRoleDAL = orgRoleDALFactory(db); const rateLimitDAL = rateLimitDALFactory(db); const apiKeyDAL = apiKeyDALFactory(db); const projectDAL = projectDALFactory(db); const projectSshConfigDAL = projectSshConfigDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); - const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db); - const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); - const projectRoleDAL = projectRoleDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db); const projectKeyDAL = projectKeyDALFactory(db); const projectBotDAL = projectBotDALFactory(db); @@ -415,8 +434,6 @@ export const registerRoutes = async ( const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); - const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); - const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db); const identityTokenAuthDAL = identityTokenAuthDALFactory(db); @@ -474,7 +491,6 @@ export const registerRoutes = async ( const gitAppOrgDAL = gitAppDALFactory(db); const groupDAL = groupDALFactory(db); const groupProjectDAL = groupProjectDALFactory(db); - const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); const secretSharingDAL = secretSharingDALFactory(db); @@ -496,7 +512,6 @@ export const registerRoutes = async ( const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); - const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const slackIntegrationDAL = slackIntegrationDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db); @@ -523,17 +538,29 @@ export const registerRoutes = async ( const secretScanningV2DAL = secretScanningV2DALFactory(db); const keyValueStoreDAL = keyValueStoreDALFactory(db); + const membershipDAL = membershipDALFactory(db); + const membershipUserDAL = membershipUserDALFactory(db); + const membershipIdentityDAL = membershipIdentityDALFactory(db); + const membershipGroupDAL = membershipGroupDALFactory(db); + const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db); + const membershipRoleDAL = membershipRoleDALFactory(db); + const roleDAL = roleDALFactory(db); + + const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); + const eventBusService = eventBusFactory(server.redis); const sseService = sseServiceFactory(eventBusService, server.redis); const permissionService = permissionServiceFactory({ permissionDAL, - orgRoleDAL, - projectRoleDAL, serviceTokenDAL, projectDAL, - keyStore + keyStore, + roleDAL, + userDAL, + identityDAL }); + const assumePrivilegeService = assumePrivilegeServiceFactory({ projectDAL, permissionService @@ -544,13 +571,60 @@ export const registerRoutes = async ( orgDAL, licenseDAL, keyStore, - identityOrgMembershipDAL, - projectDAL + projectDAL, + envConfig }); - const hsmService = hsmServiceFactory({ - hsmModule, - envConfig + const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, membershipUserDAL, orgDAL }); + + const membershipUserService = membershipUserServiceFactory({ + licenseService, + membershipRoleDAL, + membershipUserDAL, + orgDAL, + permissionService, + roleDAL, + userDAL, + projectDAL, + projectKeyDAL, + smtpService, + tokenService, + userAliasDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL + }); + + const membershipIdentityService = membershipIdentityServiceFactory({ + identityDAL, + membershipIdentityDAL, + membershipRoleDAL, + orgDAL, + permissionService, + roleDAL, + additionalPrivilegeDAL + }); + + const membershipGroupService = membershipGroupServiceFactory({ + membershipGroupDAL, + membershipRoleDAL, + roleDAL, + permissionService, + orgDAL + }); + + const roleService = roleServiceFactory({ + permissionService, + roleDAL, + projectDAL, + identityDAL, + userDAL, + externalGroupOrgRoleMappingDAL + }); + const additionalPrivilegeService = additionalPrivilegeServiceFactory({ + additionalPrivilegeDAL, + membershipDAL, + orgDAL, + permissionService }); const kmsService = kmsServiceFactory({ @@ -614,31 +688,29 @@ export const registerRoutes = async ( projectMembershipDAL, secretApprovalRequestDAL }); - const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); const samlService = samlConfigServiceFactory({ identityMetadataDAL, permissionService, orgDAL, - orgMembershipDAL, userDAL, userAliasDAL, samlConfigDAL, groupDAL, userGroupMembershipDAL, - groupProjectDAL, projectDAL, projectBotDAL, projectKeyDAL, licenseService, tokenService, smtpService, - kmsService + kmsService, + membershipRoleDAL, + membershipGroupDAL }); const groupService = groupServiceFactory({ userDAL, groupDAL, - groupProjectDAL, orgDAL, userGroupMembershipDAL, projectDAL, @@ -646,17 +718,13 @@ export const registerRoutes = async ( projectKeyDAL, permissionService, licenseService, - oidcConfigDAL + oidcConfigDAL, + membershipGroupDAL, + membershipRoleDAL }); const groupProjectService = groupProjectServiceFactory({ groupDAL, - groupProjectDAL, - groupProjectMembershipRoleDAL, - userGroupMembershipDAL, projectDAL, - projectKeyDAL, - projectBotDAL, - projectRoleDAL, permissionService }); @@ -701,18 +769,18 @@ export const registerRoutes = async ( userDAL, userAliasDAL, orgDAL, - orgMembershipDAL, projectDAL, - projectUserAdditionalPrivilegeDAL, - projectMembershipDAL, - groupDAL, - groupProjectDAL, userGroupMembershipDAL, projectKeyDAL, projectBotDAL, permissionService, smtpService, - externalGroupOrgRoleMappingDAL + externalGroupOrgRoleMappingDAL, + groupDAL, + membershipGroupDAL, + membershipRoleDAL, + membershipUserDAL, + additionalPrivilegeDAL }); const githubOrgSyncConfigService = githubOrgSyncServiceFactory({ @@ -722,16 +790,16 @@ export const registerRoutes = async ( permissionService, groupDAL, userGroupMembershipDAL, - orgMembershipDAL + orgMembershipDAL, + membershipRoleDAL, + membershipGroupDAL }); const ldapService = ldapConfigServiceFactory({ ldapConfigDAL, ldapGroupMapDAL, orgDAL, - orgMembershipDAL, groupDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, @@ -742,7 +810,9 @@ export const registerRoutes = async ( licenseService, tokenService, smtpService, - kmsService + kmsService, + membershipGroupDAL, + membershipRoleDAL }); const telemetryService = telemetryServiceFactory({ @@ -764,13 +834,12 @@ export const registerRoutes = async ( const userService = userServiceFactory({ userDAL, orgDAL, - orgMembershipDAL, tokenService, permissionService, groupProjectDAL, smtpService, - projectMembershipDAL, - userAliasDAL + userAliasDAL, + membershipUserDAL }); const upgradePathService = upgradePathServiceFactory({ keyStore }); @@ -787,17 +856,18 @@ export const registerRoutes = async ( tokenService, orgDAL, totpService, - orgMembershipDAL, auditLogService, - notificationService + notificationService, + membershipRoleDAL, + membershipUserDAL }); const passwordService = authPaswordServiceFactory({ tokenService, smtpService, authDAL, userDAL, - orgMembershipDAL, - totpConfigDAL + totpConfigDAL, + membershipUserDAL }); const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL }); @@ -819,14 +889,10 @@ export const registerRoutes = async ( folderDAL, licenseService, samlConfigDAL, - orgRoleDAL, permissionService, orgDAL, incidentContactDAL, tokenService, - projectUserAdditionalPrivilegeDAL, - projectUserMembershipRoleDAL, - projectRoleDAL, projectDAL, projectMembershipDAL, orgMembershipDAL, @@ -834,13 +900,26 @@ export const registerRoutes = async ( smtpService, userDAL, groupDAL, - orgBotDAL, oidcConfigDAL, ldapConfigDAL, loginService, projectBotService, - reminderService + reminderService, + membershipRoleDAL, + membershipUserDAL, + roleDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL }); + + const subOrgService = subOrgServiceFactory({ + licenseService, + membershipDAL, + membershipRoleDAL, + orgDAL, + permissionService + }); + const signupService = authSignupServiceFactory({ tokenService, smtpService, @@ -850,18 +929,10 @@ export const registerRoutes = async ( projectKeyDAL, projectDAL, projectBotDAL, - groupProjectDAL, - projectMembershipDAL, - projectUserMembershipRoleDAL, orgDAL, orgService, - licenseService - }); - const orgRoleService = orgRoleServiceFactory({ - permissionService, - orgRoleDAL, - orgDAL, - externalGroupOrgRoleMappingDAL + licenseService, + membershipGroupDAL }); const microsoftTeamsService = microsoftTeamsServiceFactory({ @@ -878,8 +949,6 @@ export const registerRoutes = async ( userAliasDAL, identityTokenAuthDAL, identityAccessTokenDAL, - orgMembershipDAL, - identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, @@ -891,7 +960,10 @@ export const registerRoutes = async ( microsoftTeamsService, invalidateCacheQueue, smtpService, - tokenService + tokenService, + membershipIdentityDAL, + membershipRoleDAL, + membershipUserDAL }); const offlineUsageReportService = offlineUsageReportServiceFactory({ @@ -903,9 +975,10 @@ export const registerRoutes = async ( smtpService, projectDAL, permissionService, - projectUserMembershipRoleDAL, - projectMembershipDAL, - notificationService + notificationService, + membershipRoleDAL, + membershipUserDAL, + projectMembershipDAL }); const rateLimitService = rateLimitServiceFactory({ @@ -931,32 +1004,25 @@ export const registerRoutes = async ( const projectMembershipService = projectMembershipServiceFactory({ projectMembershipDAL, - projectUserMembershipRoleDAL, projectDAL, permissionService, - projectBotDAL, - orgDAL, userDAL, - projectUserAdditionalPrivilegeDAL, userGroupMembershipDAL, smtpService, projectKeyDAL, - projectRoleDAL, groupProjectDAL, secretReminderRecipientsDAL, licenseService, - notificationService - }); - const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ - permissionService, - projectMembershipDAL, - projectUserAdditionalPrivilegeDAL, - accessApprovalRequestDAL + notificationService, + membershipUserDAL, + additionalPrivilegeDAL, + membershipRoleDAL }); + const projectKeyService = projectKeyServiceFactory({ permissionService, projectKeyDAL, - projectMembershipDAL + membershipUserDAL }); const projectQueueService = projectQueueFactory({ @@ -972,10 +1038,10 @@ export const registerRoutes = async ( secretVersionDAL, projectKeyDAL, projectBotDAL, - projectMembershipDAL, secretApprovalRequestDAL, secretApprovalSecretDAL: secretApprovalRequestSecretDAL, - projectUserMembershipRoleDAL + membershipRoleDAL, + membershipUserDAL }); const certificateAuthorityDAL = certificateAuthorityDALFactory(db); @@ -986,6 +1052,10 @@ export const registerRoutes = async ( const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateTemplateDAL = certificateTemplateDALFactory(db); const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db); + const certificateTemplateV2DAL = certificateTemplateV2DALFactory(db); + const certificateProfileDAL = certificateProfileDALFactory(db); + const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); + const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -1070,6 +1140,21 @@ export const registerRoutes = async ( licenseService }); + const certificateTemplateV2Service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL, + permissionService + }); + + const certificateProfileService = certificateProfileServiceFactory({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -1114,7 +1199,11 @@ export const registerRoutes = async ( relayDAL, kmsService, licenseService, - permissionService + permissionService, + orgDAL, + notificationService, + smtpService, + userDAL }); const gatewayV2Service = gatewayV2ServiceFactory({ @@ -1124,7 +1213,10 @@ export const registerRoutes = async ( orgGatewayConfigV2DAL, gatewayV2DAL, relayDAL, - permissionService + permissionService, + orgDAL, + notificationService, + smtpService }); const secretSyncQueue = secretSyncQueueFactory({ @@ -1187,14 +1279,15 @@ export const registerRoutes = async ( snapshotSecretV2BridgeDAL, secretApprovalRequestDAL, projectKeyDAL, - projectUserMembershipRoleDAL, orgService, resourceMetadataDAL, folderCommitService, secretSyncQueue, reminderService, eventBusService, - licenseService + licenseService, + membershipRoleDAL, + membershipUserDAL }); const projectService = projectServiceFactory({ @@ -1205,13 +1298,10 @@ export const registerRoutes = async ( secretV2BridgeDAL, projectQueue: projectQueueService, projectBotService, - identityProjectDAL, - identityOrgMembershipDAL, userDAL, projectEnvDAL, orgDAL, projectMembershipDAL, - projectRoleDAL, folderDAL, licenseService, pkiSubscriberDAL, @@ -1225,8 +1315,6 @@ export const registerRoutes = async ( sshCertificateTemplateDAL, sshHostDAL, sshHostGroupDAL, - projectUserMembershipRoleDAL, - identityProjectMembershipRoleDAL, keyStore, kmsService, certificateTemplateDAL, @@ -1235,10 +1323,14 @@ export const registerRoutes = async ( projectMicrosoftTeamsConfigDAL, microsoftTeamsIntegrationDAL, projectTemplateService, - groupProjectDAL, smtpService, reminderService, - notificationService + notificationService, + membershipGroupDAL, + membershipIdentityDAL, + membershipRoleDAL, + membershipUserDAL, + roleDAL }); const projectEnvService = projectEnvServiceFactory({ @@ -1252,16 +1344,6 @@ export const registerRoutes = async ( secretApprovalPolicyEnvironmentDAL: sapEnvironmentDAL }); - const projectRoleService = projectRoleServiceFactory({ - permissionService, - projectRoleDAL, - projectUserMembershipRoleDAL, - identityProjectMembershipRoleDAL, - projectDAL, - identityDAL, - userDAL - }); - const snapshotService = secretSnapshotServiceFactory({ permissionService, licenseService, @@ -1300,7 +1382,8 @@ export const registerRoutes = async ( projectDAL, folderCommitService, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }); const secretImportService = secretImportServiceFactory({ @@ -1416,20 +1499,18 @@ export const registerRoutes = async ( groupDAL, permissionService, projectEnvDAL, - projectMembershipDAL, projectDAL, userDAL, accessApprovalRequestDAL, - additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, - accessApprovalRequestReviewerDAL + accessApprovalRequestReviewerDAL, + additionalPrivilegeDAL, + membershipUserDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ projectDAL, permissionService, accessApprovalRequestReviewerDAL, - additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, - projectMembershipDAL, accessApprovalPolicyDAL, accessApprovalRequestDAL, projectEnvDAL, @@ -1441,7 +1522,8 @@ export const registerRoutes = async ( groupDAL, microsoftTeamsService, projectMicrosoftTeamsConfigDAL, - notificationService + notificationService, + additionalPrivilegeDAL }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1520,17 +1602,27 @@ export const registerRoutes = async ( permissionService, projectDAL, accessTokenQueue, - smtpService + smtpService, + orgDAL }); const identityService = identityServiceFactory({ + additionalPrivilegeDAL, permissionService, identityDAL, identityOrgMembershipDAL, identityProjectDAL, licenseService, identityMetadataDAL, - keyStore + keyStore, + orgDAL, + membershipIdentityDAL, + membershipRoleDAL + }); + const identityProjectService = identityProjectServiceFactory({ + identityProjectDAL, + membershipIdentityDAL, + permissionService }); const identityAuthTemplateService = identityAuthTemplateServiceFactory({ @@ -1544,110 +1636,106 @@ export const registerRoutes = async ( const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL, - identityOrgMembershipDAL, accessTokenQueue, - identityDAL - }); - - const identityProjectService = identityProjectServiceFactory({ - permissionService, - projectDAL, - identityProjectDAL, - identityOrgMembershipDAL, - identityProjectMembershipRoleDAL, - projectRoleDAL - }); - const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({ - projectDAL, - identityProjectAdditionalPrivilegeDAL, - permissionService, - identityProjectDAL - }); - - const identityProjectAdditionalPrivilegeV2Service = identityProjectAdditionalPrivilegeV2ServiceFactory({ - projectDAL, - identityProjectAdditionalPrivilegeDAL, - permissionService, - identityProjectDAL + identityDAL, + membershipIdentityDAL, + orgDAL }); const identityTokenAuthService = identityTokenAuthServiceFactory({ + identityDAL, identityTokenAuthDAL, - identityOrgMembershipDAL, identityAccessTokenDAL, permissionService, - licenseService + licenseService, + orgDAL, + membershipIdentityDAL }); const identityUaService = identityUaServiceFactory({ - identityOrgMembershipDAL, + identityDAL, permissionService, identityAccessTokenDAL, identityUaClientSecretDAL, identityUaDAL, licenseService, - keyStore + keyStore, + orgDAL, + membershipIdentityDAL }); const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ + identityDAL, identityKubernetesAuthDAL, - identityOrgMembershipDAL, identityAccessTokenDAL, permissionService, licenseService, gatewayService, + orgDAL, gatewayV2Service, gatewayV2DAL, gatewayDAL, - kmsService + kmsService, + membershipIdentityDAL }); const identityGcpAuthService = identityGcpAuthServiceFactory({ + identityDAL, identityGcpAuthDAL, - identityOrgMembershipDAL, + orgDAL, identityAccessTokenDAL, permissionService, - licenseService + licenseService, + membershipIdentityDAL }); const identityAliCloudAuthService = identityAliCloudAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, + orgDAL, identityAliCloudAuthDAL, - identityOrgMembershipDAL, licenseService, - permissionService + permissionService, + membershipIdentityDAL }); const identityTlsCertAuthService = identityTlsCertAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, identityTlsCertAuthDAL, - identityOrgMembershipDAL, licenseService, permissionService, - kmsService + kmsService, + membershipIdentityDAL }); const identityAwsAuthService = identityAwsAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, + orgDAL, identityAwsAuthDAL, - identityOrgMembershipDAL, licenseService, - permissionService + permissionService, + membershipIdentityDAL }); const identityAzureAuthService = identityAzureAuthServiceFactory({ + identityDAL, identityAzureAuthDAL, - identityOrgMembershipDAL, + orgDAL, identityAccessTokenDAL, permissionService, - licenseService + licenseService, + membershipIdentityDAL }); const identityOciAuthService = identityOciAuthServiceFactory({ + identityDAL, identityAccessTokenDAL, + orgDAL, identityOciAuthDAL, - identityOrgMembershipDAL, licenseService, - permissionService + permissionService, + membershipIdentityDAL }); const pitService = pitServiceFactory({ @@ -1665,33 +1753,45 @@ export const registerRoutes = async ( }); const identityOidcAuthService = identityOidcAuthServiceFactory({ + identityDAL, identityOidcAuthDAL, - identityOrgMembershipDAL, + orgDAL, identityAccessTokenDAL, permissionService, licenseService, - kmsService + kmsService, + membershipIdentityDAL }); const identityJwtAuthService = identityJwtAuthServiceFactory({ + identityDAL, identityJwtAuthDAL, + orgDAL, permissionService, identityAccessTokenDAL, - identityOrgMembershipDAL, licenseService, - kmsService + kmsService, + membershipIdentityDAL }); const identityLdapAuthService = identityLdapAuthServiceFactory({ identityLdapAuthDAL, + orgDAL, permissionService, kmsService, identityAccessTokenDAL, - identityOrgMembershipDAL, licenseService, identityDAL, identityAuthTemplateDAL, - keyStore + keyStore, + membershipIdentityDAL + }); + + const convertorService = convertorServiceFactory({ + additionalPrivilegeDAL, + membershipDAL, + projectDAL, + groupDAL }); const dynamicSecretProviders = buildDynamicSecretProviders({ @@ -1736,6 +1836,7 @@ export const registerRoutes = async ( identityDAL }); + // DAILY const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, queueService, @@ -1752,6 +1853,12 @@ export const registerRoutes = async ( keyValueStoreDAL }); + const healthAlert = healthAlertServiceFactory({ + gatewayV2Service, + queueService, + relayService + }); + const dailyReminderQueueService = dailyReminderQueueServiceFactory({ reminderService, queueService, @@ -1766,7 +1873,6 @@ export const registerRoutes = async ( const oidcService = oidcConfigServiceFactory({ orgDAL, - orgMembershipDAL, userDAL, userAliasDAL, licenseService, @@ -1779,9 +1885,10 @@ export const registerRoutes = async ( projectKeyDAL, projectDAL, userGroupMembershipDAL, - groupProjectDAL, groupDAL, - auditLogService + auditLogService, + membershipGroupDAL, + membershipRoleDAL }); const userEngagementService = userEngagementServiceFactory({ @@ -1827,18 +1934,11 @@ export const registerRoutes = async ( notificationService }); - const migrationService = externalMigrationServiceFactory({ - externalMigrationQueue, - userDAL, - permissionService, - gatewayService - }); - const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ permissionService, licenseService, - orgRoleDAL, - externalGroupOrgRoleMappingDAL + externalGroupOrgRoleMappingDAL, + roleDAL }); const appConnectionService = appConnectionServiceFactory({ @@ -2035,6 +2135,27 @@ export const registerRoutes = async ( pkiSyncQueue }); + const certificateV3Service = certificateV3ServiceFactory({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService: internalCertificateAuthorityService, + permissionService + }); + + const certificateEstV3Service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -2110,6 +2231,64 @@ export const registerRoutes = async ( appConnectionDAL }); + const pamFolderDAL = pamFolderDALFactory(db); + const pamResourceDAL = pamResourceDALFactory(db); + const pamAccountDAL = pamAccountDALFactory(db); + const pamSessionDAL = pamSessionDALFactory(db); + + const pamFolderService = pamFolderServiceFactory({ + pamFolderDAL, + permissionService, + licenseService + }); + + const pamResourceService = pamResourceServiceFactory({ + pamResourceDAL, + permissionService, + licenseService, + kmsService, + gatewayV2Service + }); + + const pamAccountService = pamAccountServiceFactory({ + pamAccountDAL, + gatewayV2Service, + kmsService, + licenseService, + pamFolderDAL, + pamResourceDAL, + pamSessionDAL, + permissionService, + projectDAL, + userDAL, + auditLogService + }); + + const pamAccountRotation = pamAccountRotationServiceFactory({ + queueService, + pamAccountService + }); + + const pamSessionService = pamSessionServiceFactory({ + pamSessionDAL, + projectDAL, + permissionService, + licenseService, + kmsService + }); + + const migrationService = externalMigrationServiceFactory({ + externalMigrationQueue, + userDAL, + permissionService, + gatewayService, + kmsService, + appConnectionService, + vaultExternalMigrationConfigDAL, + secretService, + auditLogService + }); + // setup the communication with license key server await licenseService.init(); @@ -2121,15 +2300,38 @@ export const registerRoutes = async ( // Start HSM service if it's configured/enabled. await hsmService.startService(); + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL, + licenseService + }); + + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + if (needsEncryptionKey) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment.\nYou can enable HSM encryption in the Server Console." + }); + } + } + await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); + await healthAlert.init(); await pkiSyncCleanup.init(); + await pamAccountRotation.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); - await kmsService.startService(); + await kmsService.startService(hsmStatus); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); await eventBusService.init(); @@ -2144,7 +2346,7 @@ export const registerRoutes = async ( groupProject: groupProjectService, permission: permissionService, org: orgService, - orgRole: orgRoleService, + subOrganization: subOrgService, oidc: oidcService, apiKey: apiKeyService, authToken: tokenService, @@ -2154,7 +2356,6 @@ export const registerRoutes = async ( projectMembership: projectMembershipService, projectKey: projectKeyService, projectEnv: projectEnvService, - projectRole: projectRoleService, secret: secretService, secretReplication: secretReplicationService, secretTag: secretTagService, @@ -2169,7 +2370,6 @@ export const registerRoutes = async ( identity: identityService, identityAuthTemplate: identityAuthTemplateService, identityAccessToken: identityAccessTokenService, - identityProject: identityProjectService, identityTokenAuth: identityTokenAuthService, identityUa: identityUaService, identityKubernetesAuth: identityKubernetesAuthService, @@ -2195,6 +2395,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + certificateV3: certificateV3Service, + certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, sshHost: sshHostService, @@ -2202,6 +2404,8 @@ export const registerRoutes = async ( certificateAuthority: certificateAuthorityService, internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, + certificateTemplateV2: certificateTemplateV2Service, + certificateProfile: certificateProfileService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, pit: pitService, @@ -2216,9 +2420,6 @@ export const registerRoutes = async ( scim: scimService, secretBlindIndex: secretBlindIndexService, telemetry: telemetryService, - projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, - identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, - identityProjectAdditionalPrivilegeV2: identityProjectAdditionalPrivilegeV2Service, secretSharing: secretSharingService, userEngagement: userEngagementService, externalKms: externalKmsService, @@ -2248,7 +2449,19 @@ export const registerRoutes = async ( bus: eventBusService, sse: sseService, notification: notificationService, - upgradePath: upgradePathService + pamFolder: pamFolderService, + pamResource: pamResourceService, + pamAccount: pamAccountService, + pamSession: pamSessionService, + upgradePath: upgradePathService, + + membershipUser: membershipUserService, + membershipIdentity: membershipIdentityService, + membershipGroup: membershipGroupService, + role: roleService, + additionalPrivilege: additionalPrivilegeService, + identityProject: identityProjectService, + convertor: convertorService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 344224008..47fbb0e07 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -209,11 +209,11 @@ export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivile ) }); -export const SanitizedRoleSchema = ProjectRolesSchema.extend({ +export const SanitizedRoleSchema = ProjectRolesSchema.omit({ version: true }).extend({ permissions: UnpackedPermissionSchema.array() }); -export const SanitizedRoleSchemaV1 = ProjectRolesSchema.extend({ +export const SanitizedRoleSchemaV1 = ProjectRolesSchema.omit({ version: true }).extend({ permissions: UnpackedPermissionSchema.array().transform((caslPermission) => // first map and remove other actions of folder permission caslPermission diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 7f5a2f374..ddb3f2326 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -5,6 +5,7 @@ import { IdentitiesSchema, OrganizationsSchema, OrgMembershipsSchema, + OrgMembershipStatus, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; @@ -172,7 +173,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { email: true, id: true, superAdmin: true - }).array() + }).array(), + total: z.number() }) } }, @@ -182,13 +184,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); }, handler: async (req) => { - const users = await server.services.superAdmin.getUsers({ + const result = await server.services.superAdmin.getUsers({ ...req.query }); - return { - users - }; + return result; } }); @@ -230,7 +230,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { createdAt: z.date() }) .array() - }).array() + }).array(), + total: z.number() }) } }, @@ -240,13 +241,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); }, handler: async (req) => { - const organizations = await server.services.superAdmin.getOrganizations({ + const result = await server.services.superAdmin.getOrganizations({ ...req.query }); - return { - organizations - }; + return result; } }); @@ -281,7 +280,10 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { ); return { - organizationMembership + organizationMembership: { + ...organizationMembership, + status: organizationMembership?.status || OrgMembershipStatus.Accepted + } }; } }); @@ -337,7 +339,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { .extend({ isInstanceAdmin: z.boolean() }) - .array() + .array(), + total: z.number() }) } }, @@ -347,13 +350,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); }, handler: async (req) => { - const identities = await server.services.superAdmin.getIdentities({ + const result = await server.services.superAdmin.getIdentities({ ...req.query }); - return { - identities - }; + return result; } }); @@ -895,7 +896,13 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const organizationMembership = await server.services.superAdmin.resendOrgInvite(req.params, req.permission); - return { organizationMembership }; + + return { + organizationMembership: { + ...organizationMembership, + status: organizationMembership?.status || OrgMembershipStatus.Accepted + } + }; } }); @@ -925,7 +932,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { req.params.organizationId, req.permission ); - return { organizationMembership }; + return { + organizationMembership: { + ...organizationMembership, + status: organizationMembership?.status || OrgMembershipStatus.Accepted + } + }; } }); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index e5549f9fe..c799ef0f0 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -77,6 +77,10 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { + LaravelForgeConnectionListItemSchema, + SanitizedLaravelForgeConnectionSchema +} from "@app/services/app-connection/laravel-forge"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; @@ -158,7 +162,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, - ...SanitizedRedisConnectionSchema.options + ...SanitizedRedisConnectionSchema.options, + ...SanitizedLaravelForgeConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -200,7 +205,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ NetlifyConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, - RedisConnectionListItemSchema + RedisConnectionListItemSchema, + LaravelForgeConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 11d9ce5e6..2e3da4420 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -24,6 +24,7 @@ import { registerGitLabConnectionRouter } from "./gitlab-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerLaravelForgeConnectionRouter } from "./laravel-forge-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; @@ -71,6 +72,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.LaravelForge, + server, + sanitizedResponseSchema: SanitizedLaravelForgeConnectionSchema, + createSchema: CreateLaravelForgeConnectionSchema, + updateSchema: UpdateLaravelForgeConnectionSchema + }); + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const organizations = await server.services.appConnection.laravelForge.listOrganizations( + connectionId, + req.permission + ); + + return organizations; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/servers`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug } = req.query; + const servers = await server.services.appConnection.laravelForge.listServers( + connectionId, + req.permission, + organizationSlug + ); + + return servers; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string(), + serverId: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug, serverId } = req.query; + const sites = await server.services.appConnection.laravelForge.listSites( + connectionId, + req.permission, + organizationSlug, + serverId + ); + + return sites; + } + }); +}; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 911979b60..48939844e 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -94,6 +94,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { decodedToken.userId, decodedToken.organizationId, decodedToken.authMethod, + decodedToken.organizationId, decodedToken.organizationId ); if (org && org.userTokenExpiration) { diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts new file mode 100644 index 000000000..2292c3ba8 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -0,0 +1,506 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { PkiCertificateProfilesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertStatus } from "@app/services/certificate/certificate-types"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; + +export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + body: z + .object({ + projectId: z.string().min(1), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.createProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug, + projectId: certificateProfile.projectId, + enrollmentType: certificateProfile.enrollmentType + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional(), + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfiles: PkiCertificateProfilesSchema.extend({ + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional() + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { profiles, totalCount } = await server.services.certificateProfile.listProfiles({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_PROFILES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateProfiles: profiles, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema.extend({ + certificateAuthority: z + .object({ + id: z.string(), + projectId: z.string(), + status: z.string(), + name: z.string() + }) + .optional(), + certificateTemplate: z + .object({ + id: z.string(), + projectId: z.string(), + name: z.string(), + description: z.string().optional() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional(), + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileByIdWithConfigs({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + let result = certificateProfile; + + if (req.query.includeMetrics) { + const metrics = await server.services.certificateProfile.getProfileMetrics({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + expiringDays: req.query.expiringDays + }); + result = { ...certificateProfile, metrics }; + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.GET_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile: result }; + } + }); + + server.route({ + method: "GET", + url: "/slug/:slug", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + slug: z.string().min(1) + }), + querystring: z.object({ + projectId: z.string().min(1) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId, + slug: req.params.slug + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + body: z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1).optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.updateProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.deleteProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/:id/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.nativeEnum(CertStatus).optional(), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string(), + serialNumber: z.string(), + cn: z.string(), + status: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + createdAt: z.date() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificates = await server.services.certificateProfile.getProfileCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + ...req.query + }); + + return { certificates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 17f564be4..5ff0e39c0 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.GET_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); @@ -121,7 +122,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, - ttl: certificateTemplate.ttl + ttl: certificateTemplate.ttl, + projectId: certificateTemplate.projectId } } }); @@ -184,9 +186,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, caId: certificateTemplate.caId, pkiCollectionId: certificateTemplate.pkiCollectionId as string, - name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, ttl: certificateTemplate.ttl @@ -230,7 +232,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.DELETE_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); diff --git a/backend/src/server/routes/v1/deprecated-project-membership-router.ts b/backend/src/server/routes/v1/deprecated-project-membership-router.ts index ab225929f..25e07a466 100644 --- a/backend/src/server/routes/v1/deprecated-project-membership-router.ts +++ b/backend/src/server/routes/v1/deprecated-project-membership-router.ts @@ -1,9 +1,13 @@ import { z } from "zod"; import { + AccessScope, + OrgMembershipRole, OrgMembershipsSchema, + OrgMembershipStatus, ProjectMembershipsSchema, ProjectUserMembershipRolesSchema, + TemporaryPermissionMode, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; @@ -13,7 +17,6 @@ import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ @@ -66,14 +69,23 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const memberships = await server.services.projectMembership.getProjectMemberships({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId + const { data: memberships } = await server.services.membershipUser.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.workspaceId + }, + data: {} }); - return { memberships }; + + return { + memberships: memberships.map((el) => ({ + ...el, + userId: el.actorUserId as string, + projectId: req.params.workspaceId + })) + }; } }); @@ -124,15 +136,30 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.getProjectMembershipById({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - id: req.params.membershipId + const { userId } = await server.services.convertor.userMembershipIdToUserId( + req.params.membershipId, + AccessScope.Project, + req.permission.orgId + ); + const membership = await server.services.membershipUser.getMembershipByUserId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.workspaceId + }, + selector: { + userId + } }); - return { membership }; + + return { + membership: { + ...membership, + userId, + projectId: req.params.workspaceId + } + }; } }); @@ -241,14 +268,22 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ ...req.auditLogInfo, event: { type: EventType.ADD_BATCH_PROJECT_MEMBER, - metadata: data.map(({ userId }) => ({ - userId: userId || "", + metadata: data.map(({ actorUserId }) => ({ + userId: actorUserId || "", email: "" })) } }); - return { data, success: true }; + return { + data: data.map((el) => ({ + ...el, + orgId: req.permission.orgId, + role: OrgMembershipRole.Member, + status: el.status || OrgMembershipStatus.Accepted + })), + success: true + }; } }); @@ -282,7 +317,7 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ z.object({ role: z.string(), isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryMode: z.nativeEnum(TemporaryPermissionMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() }) @@ -300,30 +335,28 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectMembership.updateProjectMembership({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - membershipId: req.params.membershipId, - roles: req.body.roles + const { userId } = await server.services.convertor.userMembershipIdToUserId( + req.params.membershipId, + AccessScope.Project, + req.permission.orgId + ); + + const { membership } = await server.services.membershipUser.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.workspaceId + }, + selector: { + userId + }, + data: { + roles: req.body.roles + } }); - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: req.params.workspaceId, - // event: { - // type: EventType.UPDATE_USER_WORKSPACE_ROLE, - // metadata: { - // userId: membership.userId, - // newRole: req.body.role, - // oldRole: membership.role, - // email: "" - // } - // } - // }); - return { roles }; + return { roles: membership.roles.map((el) => ({ ...el, projectMembershipId: req.params.membershipId })) }; } }); @@ -352,13 +385,22 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.deleteProjectMembership({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId, - membershipId: req.params.membershipId + const { userId } = await server.services.convertor.userMembershipIdToUserId( + req.params.membershipId, + AccessScope.Project, + req.permission.orgId + ); + + const { membership } = await server.services.membershipUser.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.workspaceId + }, + selector: { + userId + } }); await server.services.auditLog.createAuditLog({ @@ -367,12 +409,19 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ event: { type: EventType.REMOVE_PROJECT_MEMBER, metadata: { - userId: membership.userId, + userId: membership.actorUserId as string, email: "" } } }); - return { membership }; + + return { + membership: { + ...membership, + userId, + projectId: req.params.workspaceId + } + }; } }); }; diff --git a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts index ecb955025..955ec06ca 100644 --- a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts +++ b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts @@ -318,7 +318,8 @@ export const registerDeprecatedSecretFolderRouter = async (server: FastifyZodPro ...req.body, projectId: req.body.workspaceId, idOrName: req.params.folderIdOrName, - path + path, + forceDelete: true }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/group-project-router.ts b/backend/src/server/routes/v1/group-project-router.ts index d07e3bd8b..93caf5035 100644 --- a/backend/src/server/routes/v1/group-project-router.ts +++ b/backend/src/server/routes/v1/group-project-router.ts @@ -1,19 +1,21 @@ import { z } from "zod"; import { + AccessScope, GroupProjectMembershipsSchema, GroupsSchema, ProjectMembershipRole, ProjectUserMembershipRolesSchema, + TemporaryPermissionMode, UsersSchema } from "@app/db/schemas"; import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; +import { isUuidV4 } from "@app/lib/validator"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerGroupProjectRouter = async (server: FastifyZodProvider) => { server.route({ @@ -54,7 +56,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => z.object({ role: z.string(), isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryMode: z.nativeEnum(TemporaryPermissionMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() }) @@ -73,17 +75,32 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const groupMembership = await server.services.groupProject.addGroupToProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - roles: req.body.roles || [{ role: req.body.role }], - projectId: req.params.projectId, - groupIdOrName: req.params.groupIdOrName + let groupId = req.params.groupIdOrName; + if (!isUuidV4(req.params.groupIdOrName)) { + const groupDetails = await server.services.convertor.getGroupIdFromName(groupId, req.permission.orgId); + groupId = groupDetails.groupId; + } + + const { membership: groupMembership } = await server.services.membershipGroup.createMembership({ + permission: req.permission, + data: { + groupId, + roles: req.body.roles || [{ role: req.body.role, isTemporary: false }] + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMembership }; + return { + groupMembership: { + ...groupMembership, + projectId: req.params.projectId, + groupId: groupMembership.actorGroupId as string + } + }; } }); @@ -115,7 +132,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => z.object({ role: z.string(), isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryMode: z.nativeEnum(TemporaryPermissionMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() }) @@ -131,17 +148,22 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const roles = await server.services.groupProject.updateGroupInProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId, - groupId: req.params.groupId, - roles: req.body.roles + const { membership: groupMembership } = await server.services.membershipGroup.updateMembership({ + permission: req.permission, + selector: { + groupId: req.params.groupId + }, + data: { + roles: req.body.roles + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { roles }; + return { roles: groupMembership.roles.map((el) => ({ ...el, projectMembershipId: groupMembership.id })) }; } }); @@ -172,16 +194,25 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const groupMembership = await server.services.groupProject.removeGroupFromProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - groupId: req.params.groupId, - projectId: req.params.projectId + const { membership: groupMembership } = await server.services.membershipGroup.deleteMembership({ + permission: req.permission, + selector: { + groupId: req.params.groupId + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMembership }; + return { + groupMembership: { + ...groupMembership, + projectId: req.params.projectId, + groupId: groupMembership.actorGroupId as string + } + }; } }); @@ -233,15 +264,17 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const groupMemberships = await server.services.groupProject.listGroupsInProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId + const { memberships: groupMemberships } = await server.services.membershipGroup.listMemberships({ + permission: req.permission, + data: {}, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMemberships }; + return { groupMemberships: groupMemberships.map((el) => ({ ...el, groupId: el.actorGroupId as string })) }; } }); @@ -292,15 +325,25 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => } }, handler: async (req) => { - const groupMembership = await server.services.groupProject.getGroupInProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.params + const { membership: groupMembership } = await server.services.membershipGroup.getMembershipByGroupId({ + permission: req.permission, + selector: { + groupId: req.params.groupId + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMembership }; + return { + groupMembership: { + ...groupMembership, + projectId: req.params.projectId, + groupId: groupMembership.actorGroupId as string + } + }; } }); diff --git a/backend/src/server/routes/v1/identity-access-token-router.ts b/backend/src/server/routes/v1/identity-access-token-router.ts index 20ed8d150..0a9cd8dd6 100644 --- a/backend/src/server/routes/v1/identity-access-token-router.ts +++ b/backend/src/server/routes/v1/identity-access-token-router.ts @@ -13,7 +13,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Renew access token", + description: "Renew machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.RENEW_ACCESS_TOKEN.accessToken) }), @@ -48,7 +48,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke access token", + description: "Revoke machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.REVOKE_ACCESS_TOKEN.accessToken) }), diff --git a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts index a9b2d9b03..8f64d3b23 100644 --- a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts +++ b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts @@ -21,7 +21,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Login with Alibaba Cloud Auth", + description: "Login with Alibaba Cloud Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(ALICLOUD_AUTH.LOGIN.identityId), Action: z.enum(["GetCallerIdentity"]).describe(ALICLOUD_AUTH.LOGIN.Action), @@ -73,12 +73,12 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi } }, handler: async (req) => { - const { identityAliCloudAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityAliCloudAuth, accessToken, identityAccessToken, identity } = await server.services.identityAliCloudAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_ALICLOUD_AUTH, metadata: { @@ -108,7 +108,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Attach Alibaba Cloud Auth configuration onto identity", + description: "Attach Alibaba Cloud Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -200,7 +200,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Update Alibaba Cloud Auth configuration on identity", + description: "Update Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -292,7 +292,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Retrieve Alibaba Cloud Auth configuration on identity", + description: "Retrieve Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -340,7 +340,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Delete Alibaba Cloud Auth configuration on identity", + description: "Delete Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index effd66a68..3cfb19895 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -23,7 +23,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Login with AWS Auth", + description: "Login with AWS Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AWS_AUTH.LOGIN.identityId), iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), @@ -40,12 +40,12 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityAwsAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityAwsAuth, accessToken, identityAccessToken, identity } = await server.services.identityAwsAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_AWS_AUTH, metadata: { @@ -75,7 +75,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Attach AWS Auth configuration onto identity", + description: "Attach AWS Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -171,7 +171,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Update AWS Auth configuration on identity", + description: "Update AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Retrieve AWS Auth configuration on identity", + description: "Retrieve AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -303,7 +303,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Delete AWS Auth configuration on identity", + description: "Delete AWS Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index 9053bc2e3..cdab7af02 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Login with Azure Auth", + description: "Login with Azure Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AZURE_AUTH.LOGIN.identityId), jwt: z.string() @@ -35,12 +35,12 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider } }, handler: async (req) => { - const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityAzureAuth, accessToken, identityAccessToken, identity } = await server.services.identityAzureAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_AZURE_AUTH, metadata: { @@ -70,7 +70,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Attach Azure Auth configuration onto identity", + description: "Attach Azure Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -165,7 +165,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Update Azure Auth configuration on identity", + description: "Update Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Retrieve Azure Auth configuration on identity", + description: "Retrieve Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Delete Azure Auth configuration on identity", + description: "Delete Azure Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index b83faf9d9..474999b2b 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Login with GCP Auth", + description: "Login with GCP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(GCP_AUTH.LOGIN.identityId), jwt: z.string() @@ -35,12 +35,12 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityGcpAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityGcpAuth, accessToken, identityAccessToken, identity } = await server.services.identityGcpAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_GCP_AUTH, metadata: { @@ -70,7 +70,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Attach GCP Auth configuration onto identity", + description: "Attach GCP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -163,7 +163,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Update GCP Auth configuration on identity", + description: "Update GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -249,7 +249,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Retrieve GCP Auth configuration on identity", + description: "Retrieve GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -298,7 +298,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Delete GCP Auth configuration on identity", + description: "Delete GCP Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index 373a8b927..5d71b3781 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -96,7 +96,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Login with JWT Auth", + description: "Login with JWT Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -111,7 +111,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityJwtAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityJwtAuth, accessToken, identityAccessToken, identity } = await server.services.identityJwtAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -119,7 +119,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_JWT_AUTH, metadata: { @@ -148,7 +148,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Attach JWT Auth configuration onto identity", + description: "Attach JWT Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -217,7 +217,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Update JWT Auth configuration on identity", + description: "Update JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -283,7 +283,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Retrieve JWT Auth configuration on identity", + description: "Retrieve JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -332,7 +332,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Delete JWT Auth configuration on identity", + description: "Delete JWT Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 879310790..28f611aba 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -41,7 +41,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Login with Kubernetes Auth", + description: "Login with Kubernetes Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(KUBERNETES_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -56,7 +56,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide } }, handler: async (req) => { - const { identityKubernetesAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityKubernetesAuth, accessToken, identityAccessToken, identity } = await server.services.identityKubernetesAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -64,7 +64,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_KUBERNETES_AUTH, metadata: { @@ -93,7 +93,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Attach Kubernetes Auth configuration onto identity", + description: "Attach Kubernetes Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -240,7 +240,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Update Kubernetes Auth configuration on identity", + description: "Update Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -383,7 +383,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Retrieve Kubernetes Auth configuration on identity", + description: "Retrieve Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -432,7 +432,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Delete Kubernetes Auth configuration on identity", + description: "Delete Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index 512f253e0..dade20ea3 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -120,7 +120,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Login with LDAP Auth", + description: "Login with LDAP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId), username: z.string().describe(LDAP_AUTH.LOGIN.username), @@ -162,13 +162,13 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) const { identityId, user } = req.passportMachineIdentity; - const { accessToken, identityLdapAuth, identityMembershipOrg } = await server.services.identityLdapAuth.login({ + const { accessToken, identityLdapAuth, identity } = await server.services.identityLdapAuth.login({ identityId }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_LDAP_AUTH, metadata: { @@ -198,7 +198,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Attach LDAP Auth configuration onto identity", + description: "Attach LDAP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -389,7 +389,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Update LDAP Auth configuration on identity", + description: "Update LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -510,7 +510,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Retrieve LDAP Auth configuration on identity", + description: "Retrieve LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -568,7 +568,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Delete LDAP Auth configuration on identity", + description: "Delete LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -621,7 +621,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Clear LDAP Auth Lockouts for identity", + description: "Clear LDAP Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index e529f300b..003d9810b 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Login with OCI Auth", + description: "Login with OCI Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OCI_AUTH.LOGIN.identityId), userOcid: z.string().trim().describe(OCI_AUTH.LOGIN.userOcid), @@ -52,12 +52,12 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityOciAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityOciAuth, accessToken, identityAccessToken, identity } = await server.services.identityOciAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_OCI_AUTH, metadata: { @@ -87,7 +87,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Attach OCI Auth configuration onto identity", + description: "Attach OCI Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -176,7 +176,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Update OCI Auth configuration on identity", + description: "Update OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -259,7 +259,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Retrieve OCI Auth configuration on identity", + description: "Retrieve OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -307,7 +307,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Delete OCI Auth configuration on identity", + description: "Delete OCI Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 74cd94eb5..6fad1f400 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Login with OIDC Auth", + description: "Login with OIDC Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OIDC_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -59,7 +59,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const { identityOidcAuth, accessToken, identityAccessToken, identityMembershipOrg, oidcTokenData } = + const { identityOidcAuth, accessToken, identityAccessToken, identity, oidcTokenData } = await server.services.identityOidcAuth.login({ identityId: req.body.identityId, jwt: req.body.jwt @@ -67,7 +67,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_OIDC_AUTH, metadata: { @@ -100,7 +100,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Attach OIDC Auth configuration onto identity", + description: "Attach OIDC Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -201,7 +201,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Update OIDC Auth configuration on identity", + description: "Update OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -300,7 +300,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Retrieve OIDC Auth configuration on identity", + description: "Retrieve OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -349,7 +349,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Delete OIDC Auth configuration on identity", + description: "Delete OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-org-membership-router.ts b/backend/src/server/routes/v1/identity-org-membership-router.ts new file mode 100644 index 000000000..c9b93965a --- /dev/null +++ b/backend/src/server/routes/v1/identity-org-membership-router.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; + +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; +import { ApiDocsTags, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedOrgIdentityMembershipSchema = z.object({ + id: z.string().uuid(), + orgId: z.string(), + identityId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + // this is hidden so not updating tags + tags: [ApiDocsTags.ProjectIdentities], + description: "Create org identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .max(1) + }), + response: { + 200: z.object({ + identityMembership: sanitizedOrgIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + identityId: req.params.identityId, + roles: req.body.roles + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } + }; + } + }); + + server.route({ + method: "DELETE", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.ProjectIdentities], + description: "Delete org identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: sanitizedOrgIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-project-router.ts b/backend/src/server/routes/v1/identity-project-router.ts index 9de7ed1aa..fd39c7efe 100644 --- a/backend/src/server/routes/v1/identity-project-router.ts +++ b/backend/src/server/routes/v1/identity-project-router.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { + AccessScope, IdentitiesSchema, IdentityProjectMembershipsSchema, ProjectMembershipRole, - ProjectUserMembershipRolesSchema + ProjectUserMembershipRolesSchema, + TemporaryPermissionMode } from "@app/db/schemas"; import { ApiDocsTags, ORGANIZATIONS, PROJECT_IDENTITIES } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; @@ -14,7 +16,6 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-project-types"; -import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -56,7 +57,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), temporaryMode: z - .nativeEnum(ProjectUserMembershipTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), temporaryRange: z .string() @@ -82,16 +83,22 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) const { role, roles } = req.body; if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); - const identityMembership = await server.services.identityProject.createProjectIdentity({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - projectId: req.params.projectId, - roles: roles || [{ role }] + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + identityId: req.params.identityId, + roles: roles || [{ role, isTemporary: false }] + } }); - return { identityMembership }; + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; } }); @@ -130,7 +137,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), temporaryMode: z - .nativeEnum(ProjectUserMembershipTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), temporaryRange: z .string() @@ -153,16 +160,24 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const roles = await server.services.identityProject.updateProjectIdentity({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - projectId: req.params.projectId, - roles: req.body.roles + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } }); - return { roles }; + + return { + roles: membership.roles.map((el) => ({ ...el, projectMembershipId: membership.id })) + }; } }); @@ -193,15 +208,21 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { - const identityMembership = await server.services.identityProject.deleteProjectIdentity({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - projectId: req.params.projectId + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } }); - return { identityMembership }; + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; } }); diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 65b9448c5..f8e6c78ee 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -34,7 +34,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Create identity", + description: "Create machine identity", security: [ { bearerAuth: [] @@ -109,7 +109,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Update identity", + description: "Update machine identity", security: [ { bearerAuth: [] @@ -173,7 +173,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Delete identity", + description: "Delete machine identity", security: [ { bearerAuth: [] @@ -222,7 +222,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Get an identity by id", + description: "Get a machine identity by id", security: [ { bearerAuth: [] @@ -249,7 +249,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true, orgId: true }).extend({ authMethods: z.array(z.string()), activeLockoutAuthMethods: z.array(z.string()) }) @@ -280,7 +280,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "List identities", + description: "List machine identities", security: [ { bearerAuth: [] @@ -330,7 +330,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Search identities", + description: "Search machine identities", security: [ { bearerAuth: [] @@ -393,7 +393,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true, orgId: true }).extend({ authMethods: z.array(z.string()) }) }).array(), @@ -427,7 +427,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "List project memberships that identity with id is part of", + description: "List project memberships that machine identity with id is part of", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index 0bb9e08ea..b7a44c62c 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Login with TLS Certificate Auth", + description: "Login with TLS Certificate Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId) }), @@ -64,7 +64,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid throw new BadRequestError({ message: "Missing TLS certificate in header" }); } - const { identityTlsCertAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityTlsCertAuth, accessToken, identityAccessToken, identity } = await server.services.identityTlsCertAuth.login({ identityId: req.body.identityId, clientCertificate: clientCertificate as string @@ -72,7 +72,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH, metadata: { @@ -102,7 +102,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Attach TLS Certificate Auth configuration onto identity", + description: "Attach TLS Certificate Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -203,7 +203,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Update TLS Certificate Auth configuration on identity", + description: "Update TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Retrieve TLS Certificate Auth configuration on identity", + description: "Retrieve TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -354,7 +354,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Delete TLS Certificate Auth configuration on identity", + description: "Delete TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index e22c41889..aafffdfdb 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Attach Token Auth configuration onto identity", + description: "Attach Token Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -112,7 +112,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update Token Auth configuration on identity", + description: "Update Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -198,7 +198,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Retrieve Token Auth configuration on identity", + description: "Retrieve Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -247,7 +247,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Delete Token Auth configuration on identity", + description: "Delete Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -297,7 +297,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Create token for identity with Token Auth", + description: "Create token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -319,7 +319,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider } }, handler: async (req) => { - const { identityTokenAuth, accessToken, identityAccessToken, identityMembershipOrg } = + const { identityTokenAuth, accessToken, identityAccessToken, identity } = await server.services.identityTokenAuth.createTokenAuthToken({ actor: req.permission.type, actorId: req.permission.id, @@ -332,7 +332,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.orgId, + orgId: identity.orgId, event: { type: EventType.CREATE_TOKEN_IDENTITY_TOKEN_AUTH, metadata: { @@ -361,7 +361,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Get tokens for identity with Token Auth", + description: "Get tokens for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -393,7 +393,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, event: { type: EventType.GET_TOKENS_IDENTITY_TOKEN_AUTH, metadata: { @@ -416,7 +416,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update token for identity with Token Auth", + description: "Update token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -447,7 +447,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, event: { type: EventType.UPDATE_TOKEN_IDENTITY_TOKEN_AUTH, metadata: { @@ -472,7 +472,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Revoke token for identity with Token Auth", + description: "Revoke token for machine identity with Token Auth", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 6d911a88e..88a4cb775 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -32,7 +32,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Login with Universal Auth", + description: "Login with Universal Auth for machine identity", body: z.object({ clientId: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientId), clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret) @@ -52,14 +52,14 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessToken, identityAccessToken, validClientSecretInfo, - identityMembershipOrg, + identity, accessTokenTTL, accessTokenMaxTTL } = await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: identityMembershipOrg?.orgId, + orgId: identity.orgId, event: { type: EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH, metadata: { @@ -90,7 +90,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Attach Universal Auth configuration onto identity", + description: "Attach Universal Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -208,7 +208,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Update Universal Auth configuration on identity", + description: "Update Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -331,7 +331,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Retrieve Universal Auth configuration on identity", + description: "Retrieve Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -380,7 +380,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Delete Universal Auth configuration on identity", + description: "Delete Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -429,7 +429,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Create Universal Auth Client Secret for identity", + description: "Create Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -487,7 +487,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "List Universal Auth Client Secrets for identity", + description: "List Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -537,7 +537,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Get Universal Auth Client Secret for identity", + description: "Get Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -567,7 +567,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, orgId: clientSecretData.orgId, event: { - type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID, metadata: { identityId: clientSecretData.identityId, clientSecretId: clientSecretData.id @@ -589,7 +589,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke Universal Auth Client Secrets for identity", + description: "Revoke Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -641,7 +641,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Clear Universal Auth Lockouts for identity", + description: "Clear Universal Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 89865b1a1..4300f5698 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,6 +11,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; +import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; @@ -32,6 +33,7 @@ import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-rou import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; +import { registerOrgIdentityMembershipRouter } from "./identity-org-membership-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; @@ -89,6 +91,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); + await server.register(registerOrgIdentityMembershipRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); @@ -146,6 +149,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index b98e94be0..0f78e0448 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; +import { AccessScope, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; import { inviteUserRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -23,13 +23,6 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { .array() .refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"), organizationId: z.string().trim(), - projects: z - .object({ - id: z.string(), - projectRoleSlug: z.string().array().default([ProjectMembershipRole.Member]) - }) - .array() - .optional(), organizationRoleSlug: z.string().default(OrgMembershipRole.Member) }), response: { @@ -50,15 +43,16 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; - const { signupTokens: completeInviteLinks } = await server.services.org.inviteUserToOrganization({ - orgId: req.body.organizationId, - actor: req.permission.type, - actorId: req.permission.id, - inviteeEmails: req.body.inviteeEmails, - projects: req.body.projects, - organizationRoleSlug: req.body.organizationRoleSlug, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + const { signUpTokens: completeInviteLinks } = await server.services.membershipUser.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + usernames: req.body.inviteeEmails, + roles: [{ isTemporary: false, role: req.body.organizationRoleSlug }] + } }); await server.services.telemetry.sendPostHogEvents({ diff --git a/backend/src/server/routes/v1/org-admin-router.ts b/backend/src/server/routes/v1/org-admin-router.ts index d4b1ee188..8260b3a8f 100644 --- a/backend/src/server/routes/v1/org-admin-router.ts +++ b/backend/src/server/routes/v1/org-admin-router.ts @@ -86,7 +86,7 @@ export const registerOrgAdminRouter = async (server: FastifyZodProvider) => { }); } - return { membership }; + return { membership: { ...membership, userId: req.permission.id, projectId: req.params.projectId } }; } }); }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index b8eb3ad6b..76b3eae51 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -2,10 +2,12 @@ import RE2 from "re2"; import { z } from "zod"; import { + AccessScope, AuditLogsSchema, GroupsSchema, IncidentContactsSchema, OrgMembershipsSchema, + OrgMembershipStatus, OrgRolesSchema, UsersSchema } from "@app/db/schemas"; @@ -58,7 +60,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: sanitizedOrganizationSchema + organization: sanitizedOrganizationSchema.extend({ + subOrganization: z + .object({ + id: z.string(), + name: z.string() + }) + .optional() + }) }) } }, @@ -68,6 +77,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { req.permission.id, req.params.organizationId, req.permission.authMethod, + req.permission.rootOrgId, req.permission.orgId ); return { organization }; @@ -263,7 +273,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { req.permission.authMethod, req.permission.orgId ); - return { users }; + return { users: users.map((el) => ({ ...el, status: el.status || OrgMembershipStatus.Accepted })) }; } }); @@ -466,4 +476,68 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { groups }; } }); + + server.route({ + method: "GET", + url: "/users/available", + schema: { + response: { + 200: z.object({ + users: z + .object({ + id: z.string().uuid(), + username: z.string(), + email: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { users } = await server.services.membershipUser.listAvailableUsers({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Organization + }, + data: {} + }); + + return { users }; + } + }); + + server.route({ + method: "GET", + url: "/identities/available", + schema: { + response: { + 200: z.object({ + identities: z + .object({ + id: z.string().uuid(), + name: z.string(), + hasDeleteProtection: z.boolean() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Organization + }, + data: {} + }); + + return { identities }; + } + }); }; diff --git a/backend/src/server/routes/v1/pki-sync-routers/aws-certificate-manager-pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/aws-certificate-manager-pki-sync-router.ts new file mode 100644 index 000000000..21bfadbac --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/aws-certificate-manager-pki-sync-router.ts @@ -0,0 +1,22 @@ +import { + AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION, + AwsCertificateManagerPkiSyncSchema, + CreateAwsCertificateManagerPkiSyncSchema, + UpdateAwsCertificateManagerPkiSyncSchema +} from "@app/services/pki-sync/aws-certificate-manager"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerSyncPkiEndpoints } from "./pki-sync-endpoints"; + +export const registerAwsCertificateManagerPkiSyncRouter = async (server: FastifyZodProvider) => + registerSyncPkiEndpoints({ + destination: PkiSync.AwsCertificateManager, + server, + responseSchema: AwsCertificateManagerPkiSyncSchema, + createSchema: CreateAwsCertificateManagerPkiSyncSchema, + updateSchema: UpdateAwsCertificateManagerPkiSyncSchema, + syncOptions: { + canImportCertificates: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION.canImportCertificates, + canRemoveCertificates: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION.canRemoveCertificates + } + }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/index.ts b/backend/src/server/routes/v1/pki-sync-routers/index.ts index 326b650e9..4b81db27f 100644 --- a/backend/src/server/routes/v1/pki-sync-routers/index.ts +++ b/backend/src/server/routes/v1/pki-sync-routers/index.ts @@ -1,9 +1,11 @@ import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { registerAwsCertificateManagerPkiSyncRouter } from "./aws-certificate-manager-pki-sync-router"; import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router"; export * from "./pki-sync-router"; export const PKI_SYNC_REGISTER_ROUTER_MAP: Record Promise> = { - [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter + [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter, + [PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter }; diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index f3cdfe701..57fdad031 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -1,10 +1,11 @@ import { z } from "zod"; import { - OrgMembershipRole, + AccessScope, ProjectMembershipRole, ProjectMembershipsSchema, ProjectUserMembershipRolesSchema, + TemporaryPermissionMode, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; @@ -14,7 +15,6 @@ import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ @@ -67,14 +67,23 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const memberships = await server.services.projectMembership.getProjectMemberships({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId + const { data: memberships } = await server.services.membershipUser.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: {} }); - return { memberships }; + + return { + memberships: memberships.map((el) => ({ + ...el, + userId: el.actorUserId as string, + projectId: req.params.projectId + })) + }; } }); @@ -125,15 +134,30 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.getProjectMembershipById({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId, - id: req.params.membershipId + const { userId } = await server.services.convertor.userMembershipIdToUserId( + req.params.membershipId, + AccessScope.Project, + req.permission.orgId + ); + const membership = await server.services.membershipUser.getMembershipByUserId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + userId + } }); - return { membership }; + + return { + membership: { + ...membership, + userId, + projectId: req.params.projectId + } + }; } }); @@ -242,20 +266,17 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; - const { projectMemberships: memberships } = await server.services.org.inviteUserToOrganization({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - inviteeEmails: usernamesAndEmails, - orgId: req.permission.orgId, - organizationRoleSlug: OrgMembershipRole.NoAccess, - projects: [ - { - id: req.params.projectId, - projectRoleSlug: req.body.roleSlugs || [ProjectMembershipRole.Member] - } - ] + const { memberships } = await server.services.membershipUser.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + roles: (req.body.roleSlugs || [ProjectMembershipRole.Member]).map((role) => ({ isTemporary: false, role })), + usernames: usernamesAndEmails + } }); await server.services.auditLog.createAuditLog({ @@ -263,15 +284,21 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider ...req.auditLogInfo, event: { type: EventType.ADD_BATCH_PROJECT_MEMBER, - metadata: memberships.map(({ userId, id }) => ({ - userId: userId || "", + metadata: memberships.map(({ actorUserId, id }) => ({ + userId: actorUserId || "", membershipId: id, email: "" })) } }); - return { memberships }; + return { + memberships: memberships.map((el) => ({ + ...el, + userId: el.actorUserId as string, + projectId: req.params.projectId + })) + }; } }); @@ -305,7 +332,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider z.object({ role: z.string(), isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryMode: z.nativeEnum(TemporaryPermissionMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() }) @@ -323,17 +350,28 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectMembership.updateProjectMembership({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId, - membershipId: req.params.membershipId, - roles: req.body.roles + const { userId } = await server.services.convertor.userMembershipIdToUserId( + req.params.membershipId, + AccessScope.Project, + req.permission.orgId + ); + + const { membership } = await server.services.membershipUser.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + userId + }, + data: { + roles: req.body.roles + } }); - return { roles }; + return { roles: membership.roles.map((el) => ({ ...el, projectMembershipId: req.params.membershipId })) }; } }); @@ -396,13 +434,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider event: { type: EventType.REMOVE_PROJECT_MEMBER, metadata: { - userId: membership.userId, + userId: membership.actorUserId as string, email: "" } } }); } - return { memberships }; + return { + memberships: memberships.map((el) => ({ + ...el, + userId: el.actorUserId as string, + projectId: req.params.projectId + })) + }; } }); @@ -431,13 +475,22 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const membership = await server.services.projectMembership.deleteProjectMembership({ - actorId: req.permission.id, - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId, - membershipId: req.params.membershipId + const { userId } = await server.services.convertor.userMembershipIdToUserId( + req.params.membershipId, + AccessScope.Project, + req.permission.orgId + ); + + const { membership } = await server.services.membershipUser.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + userId + } }); await server.services.auditLog.createAuditLog({ @@ -446,12 +499,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider event: { type: EventType.REMOVE_PROJECT_MEMBER, metadata: { - userId: membership.userId, + userId: membership.actorUserId as string, email: "" } } }); - return { membership }; + + return { + membership: { + ...membership, + userId, + projectId: req.params.projectId + } + }; } }); @@ -479,7 +539,13 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider actor: req.permission.type, projectId: req.params.projectId }); - return { membership }; + return { + membership: { + ...membership, + userId: membership.actorUserId as string, + projectId: req.params.projectId + } + }; } }); }; diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index fed56277e..e778dbd7c 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; @@ -63,5 +64,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.LaravelForge, + server, + responseSchema: LaravelForgeSyncSchema, + createSchema: CreateLaravelForgeSyncSchema, + updateSchema: UpdateLaravelForgeSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts index 9db3a2013..9da2f4b69 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts @@ -425,4 +425,42 @@ export const registerSyncSecretsEndpoints = { + const { destinationConfig, excludeSyncId, projectId } = req.body; + + const result = await server.services.secretSync.checkDuplicateDestination( + { + destinationConfig: destinationConfig as Record, + destination, + excludeSyncId, + projectId + }, + req.permission + ); + + return result; + } + }); }; diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 71e8b2cca..1bfa32eeb 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; @@ -84,7 +85,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, - BitbucketSyncSchema + BitbucketSyncSchema, + LaravelForgeSyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -117,7 +119,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, - BitbucketSyncListItemSchema + BitbucketSyncListItemSchema, + LaravelForgeSyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts new file mode 100644 index 000000000..8a7189727 --- /dev/null +++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts @@ -0,0 +1,367 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .refine( + (val) => { + if (!val) return true; + if (val.length < 2) return false; + const unit = val.slice(-1); + const number = val.slice(0, -1); + const digitRegex = new RE2("^\\d+$"); + return ["d", "h", "m", "y"].includes(unit) && digitRegex.test(number); + }, + { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + } + ) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() +}); + +const createCertificateTemplateV2Schema = z.object({ + projectId: z.string().min(1), + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +const updateCertificateTemplateV2Schema = z.object({ + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: createCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, ...data } = req.body; + const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + projectId, + data + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, + projectId: certificateTemplate.projectId + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificateTemplates: certificateTemplateV2ResponseSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_TEMPLATES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateTemplates: templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + body: updateCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); +}; diff --git a/backend/src/server/routes/v2/deprecated-group-project-router.ts b/backend/src/server/routes/v2/deprecated-group-project-router.ts index f0e4ee705..5be7df838 100644 --- a/backend/src/server/routes/v2/deprecated-group-project-router.ts +++ b/backend/src/server/routes/v2/deprecated-group-project-router.ts @@ -1,19 +1,21 @@ import { z } from "zod"; import { + AccessScope, GroupProjectMembershipsSchema, GroupsSchema, ProjectMembershipRole, ProjectUserMembershipRolesSchema, + TemporaryPermissionMode, UsersSchema } from "@app/db/schemas"; import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; +import { isUuidV4 } from "@app/lib/validator"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodProvider) => { server.route({ @@ -54,7 +56,7 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro z.object({ role: z.string(), isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryMode: z.nativeEnum(TemporaryPermissionMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() }) @@ -73,17 +75,32 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro } }, handler: async (req) => { - const groupMembership = await server.services.groupProject.addGroupToProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - roles: req.body.roles || [{ role: req.body.role }], - projectId: req.params.projectId, - groupIdOrName: req.params.groupIdOrName + let groupId = req.params.groupIdOrName; + if (!isUuidV4(req.params.groupIdOrName)) { + const groupDetails = await server.services.convertor.getGroupIdFromName(groupId, req.permission.orgId); + groupId = groupDetails.groupId; + } + + const { membership: groupMembership } = await server.services.membershipGroup.createMembership({ + permission: req.permission, + data: { + groupId, + roles: req.body.roles || [{ role: req.body.role, isTemporary: false }] + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMembership }; + return { + groupMembership: { + ...groupMembership, + projectId: req.params.projectId, + groupId: groupMembership.actorGroupId as string + } + }; } }); @@ -115,7 +132,7 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro z.object({ role: z.string(), isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryMode: z.nativeEnum(TemporaryPermissionMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() }) @@ -131,17 +148,22 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro } }, handler: async (req) => { - const roles = await server.services.groupProject.updateGroupInProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId, - groupId: req.params.groupId, - roles: req.body.roles + const { membership: groupMembership } = await server.services.membershipGroup.updateMembership({ + permission: req.permission, + selector: { + groupId: req.params.groupId + }, + data: { + roles: req.body.roles + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { roles }; + return { roles: groupMembership.roles.map((el) => ({ ...el, projectMembershipId: groupMembership.id })) }; } }); @@ -172,16 +194,25 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro } }, handler: async (req) => { - const groupMembership = await server.services.groupProject.removeGroupFromProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - groupId: req.params.groupId, - projectId: req.params.projectId + const { membership: groupMembership } = await server.services.membershipGroup.deleteMembership({ + permission: req.permission, + selector: { + groupId: req.params.groupId + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMembership }; + return { + groupMembership: { + ...groupMembership, + projectId: req.params.projectId, + groupId: groupMembership.actorGroupId as string + } + }; } }); @@ -233,15 +264,17 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro } }, handler: async (req) => { - const groupMemberships = await server.services.groupProject.listGroupsInProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.params.projectId + const { memberships: groupMemberships } = await server.services.membershipGroup.listMemberships({ + permission: req.permission, + data: {}, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMemberships }; + return { groupMemberships: groupMemberships.map((el) => ({ ...el, groupId: el.actorGroupId as string })) }; } }); @@ -292,15 +325,25 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro } }, handler: async (req) => { - const groupMembership = await server.services.groupProject.getGroupInProject({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.params + const { membership: groupMembership } = await server.services.membershipGroup.getMembershipByGroupId({ + permission: req.permission, + selector: { + groupId: req.params.groupId + }, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + } }); - return { groupMembership }; + return { + groupMembership: { + ...groupMembership, + projectId: req.params.projectId, + groupId: groupMembership.actorGroupId as string + } + }; } }); diff --git a/backend/src/server/routes/v2/deprecated-identity-project-router.ts b/backend/src/server/routes/v2/deprecated-identity-project-router.ts index c16c874ae..85ea7d112 100644 --- a/backend/src/server/routes/v2/deprecated-identity-project-router.ts +++ b/backend/src/server/routes/v2/deprecated-identity-project-router.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { + AccessScope, IdentitiesSchema, IdentityProjectMembershipsSchema, ProjectMembershipRole, - ProjectUserMembershipRolesSchema + ProjectUserMembershipRolesSchema, + TemporaryPermissionMode } from "@app/db/schemas"; import { ApiDocsTags, ORGANIZATIONS, PROJECT_IDENTITIES } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; @@ -14,7 +16,6 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-project-types"; -import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -56,7 +57,7 @@ export const registerDeprecatedIdentityProjectRouter = async (server: FastifyZod role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), temporaryMode: z - .nativeEnum(ProjectUserMembershipTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), temporaryRange: z .string() @@ -82,16 +83,22 @@ export const registerDeprecatedIdentityProjectRouter = async (server: FastifyZod const { role, roles } = req.body; if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); - const identityMembership = await server.services.identityProject.createProjectIdentity({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - projectId: req.params.projectId, - roles: roles || [{ role }] + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + identityId: req.params.identityId, + roles: roles || [{ role, isTemporary: false }] + } }); - return { identityMembership }; + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; } }); @@ -130,7 +137,7 @@ export const registerDeprecatedIdentityProjectRouter = async (server: FastifyZod role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), temporaryMode: z - .nativeEnum(ProjectUserMembershipTemporaryMode) + .nativeEnum(TemporaryPermissionMode) .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), temporaryRange: z .string() @@ -153,16 +160,24 @@ export const registerDeprecatedIdentityProjectRouter = async (server: FastifyZod } }, handler: async (req) => { - const roles = await server.services.identityProject.updateProjectIdentity({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - projectId: req.params.projectId, - roles: req.body.roles + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } }); - return { roles }; + + return { + roles: membership.roles.map((el) => ({ ...el, projectMembershipId: membership.id })) + }; } }); @@ -193,15 +208,21 @@ export const registerDeprecatedIdentityProjectRouter = async (server: FastifyZod } }, handler: async (req) => { - const identityMembership = await server.services.identityProject.deleteProjectIdentity({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - identityId: req.params.identityId, - projectId: req.params.projectId + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } }); - return { identityMembership }; + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; } }); diff --git a/backend/src/server/routes/v2/deprecated-project-membership-router.ts b/backend/src/server/routes/v2/deprecated-project-membership-router.ts index d88d2f996..4dff4d5ea 100644 --- a/backend/src/server/routes/v2/deprecated-project-membership-router.ts +++ b/backend/src/server/routes/v2/deprecated-project-membership-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { OrgMembershipRole, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; +import { AccessScope, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, PROJECT_USERS } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -51,20 +51,17 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; - const { projectMemberships: memberships } = await server.services.org.inviteUserToOrganization({ - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actor: req.permission.type, - inviteeEmails: usernamesAndEmails, - orgId: req.permission.orgId, - organizationRoleSlug: OrgMembershipRole.NoAccess, - projects: [ - { - id: req.params.projectId, - projectRoleSlug: req.body.roleSlugs || [ProjectMembershipRole.Member] - } - ] + const { memberships } = await server.services.membershipUser.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + usernames: usernamesAndEmails, + roles: (req.body.roleSlugs || [ProjectMembershipRole.Member]).map((role) => ({ isTemporary: false, role })) + } }); await server.services.auditLog.createAuditLog({ @@ -72,15 +69,21 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ ...req.auditLogInfo, event: { type: EventType.ADD_BATCH_PROJECT_MEMBER, - metadata: memberships.map(({ userId, id }) => ({ - userId: userId || "", + metadata: memberships.map(({ actorUserId, id }) => ({ + userId: actorUserId || "", membershipId: id, email: "" })) } }); - return { memberships }; + return { + memberships: memberships.map((el) => ({ + ...el, + userId: el.actorUserId as string, + projectId: req.params.projectId + })) + }; } }); @@ -143,13 +146,19 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ event: { type: EventType.REMOVE_PROJECT_MEMBER, metadata: { - userId: membership.userId, + userId: membership.actorUserId as string, email: "" } } }); } - return { memberships }; + return { + memberships: memberships.map((el) => ({ + ...el, + userId: el.actorUserId as string, + projectId: req.params.projectId + })) + }; } }); }; diff --git a/backend/src/server/routes/v2/identity-org-router.ts b/backend/src/server/routes/v2/identity-org-router.ts index 8680a2dca..630e09dda 100644 --- a/backend/src/server/routes/v2/identity-org-router.ts +++ b/backend/src/server/routes/v2/identity-org-router.ts @@ -60,7 +60,7 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { permissions: true, description: true }).optional(), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true }).extend({ authMethods: z.array(z.string()) }) }) diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index aade29bb7..db4ebb176 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,4 +1,5 @@ import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router"; import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router"; import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; @@ -19,6 +20,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); + await server.register(registerCertificateTemplatesV2Router, { prefix: "/certificate-templates" }); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 9135d2356..93320f3e0 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { OrgMembershipsSchema, + OrgMembershipStatus, ProjectMembershipsSchema, ProjectsSchema, UserEncryptionKeysSchema, @@ -63,7 +64,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { req.permission.authMethod, req.permission.orgId ); - return { users }; + return { users: users.map((el) => ({ ...el, status: el.status || OrgMembershipStatus.Accepted })) }; } }); @@ -138,6 +139,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ membership: OrgMembershipsSchema.extend({ + customRoleSlug: z.string().nullish(), metadata: z .object({ key: z.string().trim().min(1), @@ -168,7 +170,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { orgId: req.params.organizationId, membershipId: req.params.membershipId }); - return { membership }; + return { membership: { ...membership, status: membership.status || OrgMembershipStatus.Accepted } }; } }); @@ -220,7 +222,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, ...req.body }); - return { membership }; + return { + membership: { + ...membership, + role: req.body.role || "", + orgId: req.params.organizationId, + status: membership.status || OrgMembershipStatus.Accepted + } + }; } }); @@ -260,7 +269,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { membershipId: req.params.membershipId, actorOrgId: req.permission.orgId }); - return { membership }; + return { + membership: { + ...membership, + status: membership.status || OrgMembershipStatus.Accepted, + role: "", + orgId: req.params.organizationId + } + }; } }); @@ -302,7 +318,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { membershipIds: req.body.membershipIds, actorOrgId: req.permission.orgId }); - return { memberships }; + return { + memberships: memberships.map((el) => ({ + ...el, + status: el?.status || OrgMembershipStatus.Accepted, + role: "", + orgId: req.params.organizationId + })) + }; } }); diff --git a/backend/src/server/routes/v2/secret-folder-router.ts b/backend/src/server/routes/v2/secret-folder-router.ts index 0bf062452..251ad8e85 100644 --- a/backend/src/server/routes/v2/secret-folder-router.ts +++ b/backend/src/server/routes/v2/secret-folder-router.ts @@ -263,7 +263,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.path) + .describe(FOLDERS.DELETE.path), + forceDelete: z.boolean().optional().default(false).describe(FOLDERS.DELETE.forceDelete) }), response: { 200: z.object({ @@ -279,7 +280,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - idOrName: req.params.folderIdOrName + idOrName: req.params.folderIdOrName, + forceDelete: req.body.forceDelete }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts new file mode 100644 index 000000000..549310738 --- /dev/null +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -0,0 +1,346 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + ACMESANType, + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +interface CertificateRequestForService { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); +}; + +const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; +}; + +export const registerCertificatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateRequestForService: CertificateRequestForService = { + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + altNames: req.body.altNames, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateRequest: mappedCertificateRequest + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + commonName: req.body.commonName || "", + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + csr: req.body.csr, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: "" + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/order-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .min(1, "At least one subject alternative name must be provided"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + commonName: validateTemplateRegexField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + orderId: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string(), + status: z.nativeEnum(CertificateOrderStatus) + }) + ), + authorizations: z.array( + z.object({ + identifier: z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }), + status: z.nativeEnum(CertificateOrderStatus), + expires: z.string().optional(), + challenges: z.array( + z.object({ + type: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + url: z.string(), + token: z.string() + }) + ) + }) + ), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateOrder: { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + orderId: data.orderId, + profileName: data.profileName + } + } + }); + + return data; + } + }); +}; diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index a3b744485..f0737e01d 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ExternalMigrationProviders, + VaultImportStatus, VaultMappingType } from "@app/services/external-migration/external-migration-types"; @@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider return { enabled }; } }); + + server.route({ + method: "GET", + url: "/vault/configs", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + configs: z + .object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const configs = await server.services.migration.getVaultExternalMigrationConfigs({ + actor: req.permission + }); + + return { configs }; + } + }); + + server.route({ + method: "POST", + url: "/vault/configs", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.createVaultExternalMigration({ + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "PUT", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.updateVaultExternalMigration({ + id: req.params.id, + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "DELETE", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.deleteVaultExternalMigration({ + id: req.params.id, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "GET", + url: "/vault/namespaces", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + namespaces: z.array(z.object({ id: z.string(), name: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const namespaces = await server.services.migration.getVaultNamespaces({ + actor: req.permission + }); + + return { namespaces }; + } + }); + + server.route({ + method: "GET", + url: "/vault/policies", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + policies: z.array(z.object({ name: z.string(), rules: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const policies = await server.services.migration.getVaultPolicies({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { policies }; + } + }); + + server.route({ + method: "GET", + url: "/vault/mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultMounts({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { mounts }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + authType: z.string().optional() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultAuthMounts({ + actor: req.permission, + namespace: req.query.namespace, + authType: req.query.authType + }); + + return { mounts }; + } + }); + + server.route({ + method: "POST", + url: "/vault/import-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectId: z.string(), + environment: z.string(), + secretPath: z.string(), + vaultNamespace: z.string(), + vaultSecretPath: z.string() + }), + response: { + 200: z.object({ + status: z.nativeEnum(VaultImportStatus) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.migration.importVaultSecrets({ + actor: req.permission, + auditLogInfo: req.auditLogInfo, + ...req.body + }); + + return result; + } + }); + + server.route({ + method: "GET", + url: "/vault/secret-paths", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + response: { + 200: z.object({ + secretPaths: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretPaths = await server.services.migration.getVaultSecretPaths({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { secretPaths }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-roles/kubernetes", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + + response: { + 200: z.object({ + roles: z.array( + z.object({ + name: z.string(), + mountPath: z.string(), + bound_service_account_names: z.array(z.string()), + bound_service_account_namespaces: z.array(z.string()), + token_ttl: z.number().optional(), + token_max_ttl: z.number().optional(), + token_policies: z.array(z.string()).optional(), + token_bound_cidrs: z.array(z.string()).optional(), + token_explicit_max_ttl: z.number().optional(), + token_no_default_policy: z.boolean().optional(), + token_num_uses: z.number().optional(), + token_period: z.number().optional(), + token_type: z.string().optional(), + audience: z.string().optional(), + alias_name_source: z.string().optional(), + config: z.object({ + kubernetes_host: z.string(), + kubernetes_ca_cert: z.string().optional(), + issuer: z.string().optional(), + disable_iss_validation: z.boolean().optional(), + disable_local_ca_jwt: z.boolean().optional() + }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.migration.getVaultKubernetesAuthRoles({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { roles }; + } + }); }; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index d7fa94d6b..47c3c2cb8 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerCertificatesRouter } from "./certificates-router"; import { registerDeprecatedSecretRouter } from "./deprecated-secret-router"; import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; @@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); + await server.register(registerCertificatesRouter, { prefix: "/certificates" }); }; diff --git a/backend/src/services/additional-privilege/additional-privilege-dal.ts b/backend/src/services/additional-privilege/additional-privilege-dal.ts new file mode 100644 index 000000000..b11bd90d6 --- /dev/null +++ b/backend/src/services/additional-privilege/additional-privilege-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAdditionalPrivilegeDALFactory = ReturnType; + +export const additionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.AdditionalPrivilege); + + return orm; +}; diff --git a/backend/src/services/additional-privilege/additional-privilege-service.ts b/backend/src/services/additional-privilege/additional-privilege-service.ts new file mode 100644 index 000000000..2af9e6419 --- /dev/null +++ b/backend/src/services/additional-privilege/additional-privilege-service.ts @@ -0,0 +1,254 @@ +// eslint-disable-next-line simple-import-sort/imports +import { RawRule } from "@casl/ability"; +import { packRules } from "@casl/ability/extra"; + +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; +import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; + +import { TMembershipDALFactory } from "../membership/membership-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TAdditionalPrivilegeDALFactory } from "./additional-privilege-dal"; +import { + TAdditionalPrivilegesScopeFactory, + TCreateAdditionalPrivilegesDTO, + TDeleteAdditionalPrivilegesDTO, + TGetAdditionalPrivilegesByIdDTO, + TGetAdditionalPrivilegesByNameDTO, + TListAdditionalPrivilegesDTO, + TUpdateAdditionalPrivilegesDTO +} from "./additional-privilege-types"; +import { newNamespaceAdditionalPrivilegesFactory } from "./namespace/namespace-additional-privilege-factory"; +import { newOrgAdditionalPrivilegesFactory } from "./org/org-additional-privilege-factory"; +import { newProjectAdditionalPrivilegesFactory } from "./project/project-additional-privilege-factory"; +import { ActorType } from "../auth/auth-type"; + +type TAdditionalPrivilegeServiceFactoryDep = { + additionalPrivilegeDAL: TAdditionalPrivilegeDALFactory; + permissionService: Pick; + orgDAL: Pick; + membershipDAL: Pick; +}; + +export type TAdditionalPrivilegeServiceFactory = ReturnType; + +export const additionalPrivilegeServiceFactory = ({ + additionalPrivilegeDAL, + permissionService, + orgDAL, + membershipDAL +}: TAdditionalPrivilegeServiceFactoryDep) => { + const scopeFactory: Record = { + [AccessScope.Organization]: newOrgAdditionalPrivilegesFactory({}), + [AccessScope.Project]: newProjectAdditionalPrivilegesFactory({ + membershipDAL, + orgDAL, + permissionService + }), + [AccessScope.Namespace]: newNamespaceAdditionalPrivilegesFactory({}) + }; + + const createAdditionalPrivilege = async (dto: TCreateAdditionalPrivilegesDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onCreateAdditionalPrivilegesGuard(dto); + const scope = factory.getScopeField(dto.scopeData); + const dbActorField = data.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + + const existingSlug = await additionalPrivilegeDAL.findOne({ + name: data.name, + [dbActorField]: data.actorId, + [scope.key]: scope.value + }); + if (existingSlug) throw new BadRequestError({ message: `Additional privilege with name ${data.name} exists` }); + + validateHandlebarTemplate("Additional Privilege Create", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + + if (!data.isTemporary) { + const additionalPrivilege = await additionalPrivilegeDAL.create({ + name: data.name, + [dbActorField]: data.actorId, + [scope.key]: scope.value, + isTemporary: data.isTemporary, + permissions: JSON.stringify(packRules(data.permissions as RawRule[])) + }); + + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + } + + if (!data.temporaryAccessStartTime || !data.temporaryRange) { + throw new BadRequestError({ message: "Temporary mode expects start time and range" }); + } + + const relativeTempAllocatedTimeInMs = ms(data.temporaryRange); + const additionalPrivilege = await additionalPrivilegeDAL.create({ + [dbActorField]: data.actorId, + [scope.key]: scope.value, + name: data.name, + isTemporary: data.isTemporary, + permissions: JSON.stringify(packRules(data.permissions as RawRule[])), + temporaryAccessEndTime: new Date( + new Date(data.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs + ), + temporaryAccessStartTime: new Date(data.temporaryAccessStartTime), + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: data.temporaryRange + }); + + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + }; + + const updateAdditionalPrivilege = async (dto: TUpdateAdditionalPrivilegesDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onUpdateAdditionalPrivilegesGuard(dto); + const scope = factory.getScopeField(dto.scopeData); + const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + + const existingPrivilege = await additionalPrivilegeDAL.findOne({ + [dbActorField]: dto.selector.actorId, + id: dto.selector.id, + [scope.key]: scope.value + }); + if (!existingPrivilege) + throw new NotFoundError({ message: `Additional privilege with id ${dto.selector.id} doesn't exist` }); + + validateHandlebarTemplate("Additional Privilege Create", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + + const updatedData = { ...existingPrivilege, ...data }; + + if (!updatedData.isTemporary) { + const additionalPrivilege = await additionalPrivilegeDAL.updateById(existingPrivilege.id, { + name: updatedData.name, + isTemporary: data.isTemporary, + permissions: data.permissions ? JSON.stringify(packRules(data.permissions as RawRule[])) : undefined + }); + + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + } + + if (!updatedData.temporaryAccessStartTime || !updatedData.temporaryRange) { + throw new BadRequestError({ message: "Temporary mode expects start time and range" }); + } + + const relativeTempAllocatedTimeInMs = ms(updatedData.temporaryRange); + const additionalPrivilege = await additionalPrivilegeDAL.updateById(existingPrivilege.id, { + name: updatedData.name, + isTemporary: updatedData.isTemporary, + permissions: JSON.stringify(packRules(updatedData.permissions as RawRule[])), + temporaryAccessEndTime: new Date( + new Date(updatedData.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs + ), + temporaryAccessStartTime: new Date(updatedData.temporaryAccessStartTime), + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: updatedData.temporaryRange + }); + + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + }; + + const deleteAdditionalPrivilege = async (dto: TDeleteAdditionalPrivilegesDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onDeleteAdditionalPrivilegesGuard(dto); + const scope = factory.getScopeField(dto.scopeData); + const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + + const existingPrivilege = await additionalPrivilegeDAL.findOne({ + id: selector.id, + [dbActorField]: dto.selector.actorId, + [scope.key]: scope.value + }); + if (!existingPrivilege) + throw new NotFoundError({ message: `Additional privilege with id ${selector.id} doesn't exist` }); + + const additionalPrivilege = await additionalPrivilegeDAL.deleteById(existingPrivilege.id); + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + }; + + const getAdditionalPrivilegeById = async (dto: TGetAdditionalPrivilegesByIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onGetAdditionalPrivilegesByIdGuard(dto); + const scope = factory.getScopeField(dto.scopeData); + const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + + const additionalPrivilege = await additionalPrivilegeDAL.findOne({ + id: selector.id, + [dbActorField]: dto.selector.actorId, + [scope.key]: scope.value + }); + if (!additionalPrivilege) + throw new NotFoundError({ message: `Additional privilege with id ${selector.id} doesn't exist` }); + + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + }; + + const getAdditionalPrivilegeByName = async (dto: TGetAdditionalPrivilegesByNameDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onGetAdditionalPrivilegesByIdGuard(dto); + const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + const scope = factory.getScopeField(dto.scopeData); + + const additionalPrivilege = await additionalPrivilegeDAL.findOne({ + name: selector.name, + [dbActorField]: dto.selector.actorId, + [scope.key]: scope.value + }); + if (!additionalPrivilege) + throw new NotFoundError({ message: `Additional privilege with name ${selector.name} doesn't exist` }); + + return { + additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + }; + }; + + const listAdditionalPrivileges = async (dto: TListAdditionalPrivilegesDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onListAdditionalPrivilegesGuard(dto); + const scope = factory.getScopeField(dto.scopeData); + const dbActorField = dto.selector.actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"; + + const additionalPrivileges = await additionalPrivilegeDAL.find({ + [dbActorField]: dto.selector.actorId, + [scope.key]: scope.value + }); + + return { + additionalPrivileges: additionalPrivileges.map((el) => ({ + ...el, + permissions: unpackPermissions(el.permissions) + })) + }; + }; + + return { + createAdditionalPrivilege, + updateAdditionalPrivilege, + deleteAdditionalPrivilege, + getAdditionalPrivilegeById, + getAdditionalPrivilegeByName, + listAdditionalPrivileges + }; +}; diff --git a/backend/src/services/additional-privilege/additional-privilege-types.ts b/backend/src/services/additional-privilege/additional-privilege-types.ts new file mode 100644 index 000000000..eeb5a6a0b --- /dev/null +++ b/backend/src/services/additional-privilege/additional-privilege-types.ts @@ -0,0 +1,87 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +import { ActorType } from "../auth/auth-type"; + +export interface TAdditionalPrivilegesScopeFactory { + onCreateAdditionalPrivilegesGuard: (arg: TCreateAdditionalPrivilegesDTO) => Promise; + onUpdateAdditionalPrivilegesGuard: (arg: TUpdateAdditionalPrivilegesDTO) => Promise; + onDeleteAdditionalPrivilegesGuard: (arg: TDeleteAdditionalPrivilegesDTO) => Promise; + onListAdditionalPrivilegesGuard: (arg: TListAdditionalPrivilegesDTO) => Promise; + onGetAdditionalPrivilegesByIdGuard: ( + arg: TGetAdditionalPrivilegesByIdDTO | TGetAdditionalPrivilegesByNameDTO + ) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; +} + +export type TCreateAdditionalPrivilegesDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + actorId: string; + actorType: ActorType.USER | ActorType.IDENTITY; + name: string; + permissions: unknown; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }; +}; + +export type TUpdateAdditionalPrivilegesDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + actorId: string; + actorType: ActorType.USER | ActorType.IDENTITY; + }; + data: Partial<{ + name: string; + permissions: unknown; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }>; +}; + +export type TListAdditionalPrivilegesDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + actorId: string; + actorType: ActorType.USER | ActorType.IDENTITY; + }; +}; + +export type TDeleteAdditionalPrivilegesDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + actorId: string; + actorType: ActorType.USER | ActorType.IDENTITY; + }; +}; + +export type TGetAdditionalPrivilegesByIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + actorId: string; + actorType: ActorType.USER | ActorType.IDENTITY; + }; +}; + +export type TGetAdditionalPrivilegesByNameDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + name: string; + actorId: string; + actorType: ActorType.USER | ActorType.IDENTITY; + }; +}; diff --git a/backend/src/services/additional-privilege/namespace/namespace-additional-privilege-factory.ts b/backend/src/services/additional-privilege/namespace/namespace-additional-privilege-factory.ts new file mode 100644 index 000000000..e2ccde286 --- /dev/null +++ b/backend/src/services/additional-privilege/namespace/namespace-additional-privilege-factory.ts @@ -0,0 +1,52 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TAdditionalPrivilegesScopeFactory } from "../additional-privilege-types"; + +type TNamespaceAdditionalPrivilegesScopeFactoryDep = Record; + +export const newNamespaceAdditionalPrivilegesFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceAdditionalPrivilegesScopeFactoryDep +): TAdditionalPrivilegesScopeFactory => { + const getScopeField: TAdditionalPrivilegesScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const onCreateAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onCreateAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace additional privileges create not implemented" }); + }; + + const onUpdateAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onUpdateAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace additional privileges update not implemented" }); + }; + + const onDeleteAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onDeleteAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace additional privileges delete not implemented" }); + }; + + const onListAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onListAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace additional privileges list not implemented" }); + }; + + const onGetAdditionalPrivilegesByIdGuard: TAdditionalPrivilegesScopeFactory["onGetAdditionalPrivilegesByIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace additional privileges get by id not implemented" }); + }; + + return { + onCreateAdditionalPrivilegesGuard, + onUpdateAdditionalPrivilegesGuard, + onDeleteAdditionalPrivilegesGuard, + onListAdditionalPrivilegesGuard, + onGetAdditionalPrivilegesByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/additional-privilege/org/org-additional-privilege-factory.ts b/backend/src/services/additional-privilege/org/org-additional-privilege-factory.ts new file mode 100644 index 000000000..cc1fbdd6c --- /dev/null +++ b/backend/src/services/additional-privilege/org/org-additional-privilege-factory.ts @@ -0,0 +1,52 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TAdditionalPrivilegesScopeFactory } from "../additional-privilege-types"; + +type TOrgAdditionalPrivilegesScopeFactoryDep = Record; + +export const newOrgAdditionalPrivilegesFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TOrgAdditionalPrivilegesScopeFactoryDep +): TAdditionalPrivilegesScopeFactory => { + const getScopeField: TAdditionalPrivilegesScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const onCreateAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onCreateAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Org additional privileges create not implemented" }); + }; + + const onUpdateAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onUpdateAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Org additional privileges update not implemented" }); + }; + + const onDeleteAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onDeleteAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Org additional privileges delete not implemented" }); + }; + + const onListAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onListAdditionalPrivilegesGuard"] = + async () => { + throw new InternalServerError({ message: "Org additional privileges list not implemented" }); + }; + + const onGetAdditionalPrivilegesByIdGuard: TAdditionalPrivilegesScopeFactory["onGetAdditionalPrivilegesByIdGuard"] = + async () => { + throw new InternalServerError({ message: "Org additional privileges get by id not implemented" }); + }; + + return { + onCreateAdditionalPrivilegesGuard, + onUpdateAdditionalPrivilegesGuard, + onDeleteAdditionalPrivilegesGuard, + onListAdditionalPrivilegesGuard, + onGetAdditionalPrivilegesByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/additional-privilege/project/project-additional-privilege-factory.ts b/backend/src/services/additional-privilege/project/project-additional-privilege-factory.ts new file mode 100644 index 000000000..4b5179a6f --- /dev/null +++ b/backend/src/services/additional-privilege/project/project-additional-privilege-factory.ts @@ -0,0 +1,219 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionIdentityActions, + ProjectPermissionMemberActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, PermissionBoundaryError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TAdditionalPrivilegesScopeFactory } from "../additional-privilege-types"; + +type TProjectAdditionalPrivilegesScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + membershipDAL: Pick; +}; + +export const newProjectAdditionalPrivilegesFactory = ({ + permissionService, + orgDAL, + membershipDAL +}: TProjectAdditionalPrivilegesScopeFactoryDep): TAdditionalPrivilegesScopeFactory => { + const $getPermission = (permission: OrgServiceActor, projectId: string) => { + return permissionService.getProjectPermission({ + actor: permission.type, + actorId: permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: permission.authMethod, + projectId, + actorOrgId: permission.orgId + }); + }; + + const getScopeField: TAdditionalPrivilegesScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + const onCreateAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onCreateAdditionalPrivilegesGuard"] = + async (dto) => { + const scope = getScopeField(dto.scopeData); + + const { actorType } = dto.data; + const { permission } = await $getPermission(dto.permission, scope.value); + const permissionSet = + actorType === ActorType.USER + ? ([ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member] as const) + : ([ProjectPermissionIdentityActions.Edit, ProjectPermissionSub.Identity] as const); + ForbiddenError.from(permission).throwUnlessCan(...permissionSet); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const { permission: targetUserPermission, memberships } = await $getPermission( + { ...dto.permission, type: actorType, id: dto.data.actorId }, + scope.value + ); + + const permissionAction = + actorType === ActorType.USER + ? ProjectPermissionMemberActions.GrantPrivileges + : ProjectPermissionIdentityActions.GrantPrivileges; + const permissionSubject = + actorType === ActorType.USER ? ProjectPermissionSub.Member : ProjectPermissionSub.Identity; + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + permissionAction, + permissionSubject, + permission, + targetUserPermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged actor", + shouldUseNewPrivilegeSystem, + permissionAction, + permissionSubject + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const membership = memberships.find( + (el) => el[actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"] === dto.data.actorId + ); + if (!membership) throw new BadRequestError({ message: "Actor doesn't have membership" }); + }; + + const onUpdateAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onUpdateAdditionalPrivilegesGuard"] = + async (dto) => { + const scope = getScopeField(dto.scopeData); + const { actorType } = dto.selector; + + const { permission } = await $getPermission(dto.permission, scope.value); + const permissionSet = + actorType === ActorType.USER + ? ([ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member] as const) + : ([ProjectPermissionIdentityActions.Edit, ProjectPermissionSub.Identity] as const); + ForbiddenError.from(permission).throwUnlessCan(...permissionSet); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const { permission: targetUserPermission, memberships } = await $getPermission( + { ...dto.permission, type: actorType, id: dto.selector.actorId }, + scope.value + ); + + const permissionAction = + actorType === ActorType.USER + ? ProjectPermissionMemberActions.GrantPrivileges + : ProjectPermissionIdentityActions.GrantPrivileges; + const permissionSubject = + actorType === ActorType.USER ? ProjectPermissionSub.Member : ProjectPermissionSub.Identity; + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + permissionAction, + permissionSubject, + permission, + targetUserPermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update more privileged actor", + shouldUseNewPrivilegeSystem, + permissionAction, + permissionSubject + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const membership = memberships.find( + (el) => el[actorType === ActorType.IDENTITY ? "actorIdentityId" : "actorUserId"] === dto.selector.actorId + ); + if (!membership) throw new BadRequestError({ message: "Actor doesn't have membership" }); + }; + + const onDeleteAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onDeleteAdditionalPrivilegesGuard"] = + async (dto) => { + const scope = getScopeField(dto.scopeData); + const { actorType } = dto.selector; + + const { permission } = await $getPermission(dto.permission, scope.value); + const permissionSet = + actorType === ActorType.USER + ? ([ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member] as const) + : ([ProjectPermissionIdentityActions.Edit, ProjectPermissionSub.Identity] as const); + ForbiddenError.from(permission).throwUnlessCan(...permissionSet); + + const membership = await membershipDAL.findOne({ + scopeOrgId: dto.permission.orgId, + scopeProjectId: scope.value, + [actorType === ActorType.USER ? "actorUserId" : "actorIdentityId"]: dto.selector.actorId + }); + + if (!membership) throw new BadRequestError({ message: "Actor doesn't have membership" }); + }; + + const onListAdditionalPrivilegesGuard: TAdditionalPrivilegesScopeFactory["onListAdditionalPrivilegesGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { actorType } = dto.selector; + + const permissionSet = + actorType === ActorType.USER + ? ([ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member] as const) + : ([ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity] as const); + const { permission } = await $getPermission(dto.permission, scope.value); + ForbiddenError.from(permission).throwUnlessCan(...permissionSet); + + const membership = await membershipDAL.findOne({ + scopeOrgId: dto.permission.orgId, + scopeProjectId: scope.value, + [actorType === ActorType.USER ? "actorUserId" : "actorIdentityId"]: dto.selector.actorId + }); + + if (!membership) throw new BadRequestError({ message: "Actor doesn't have membership" }); + }; + + const onGetAdditionalPrivilegesByIdGuard: TAdditionalPrivilegesScopeFactory["onGetAdditionalPrivilegesByIdGuard"] = + async (dto) => { + const scope = getScopeField(dto.scopeData); + const { actorType } = dto.selector; + + const permissionSet = + actorType === ActorType.USER + ? ([ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member] as const) + : ([ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity] as const); + const { permission } = await $getPermission(dto.permission, scope.value); + ForbiddenError.from(permission).throwUnlessCan(...permissionSet); + + const membership = await membershipDAL.findOne({ + scopeOrgId: dto.permission.orgId, + scopeProjectId: scope.value, + [actorType === ActorType.USER ? "actorUserId" : "actorIdentityId"]: dto.selector.actorId + }); + + if (!membership) throw new BadRequestError({ message: "Actor doesn't have membership" }); + }; + + return { + onCreateAdditionalPrivilegesGuard, + onUpdateAdditionalPrivilegesGuard, + onDeleteAdditionalPrivilegesGuard, + onListAdditionalPrivilegesGuard, + onGetAdditionalPrivilegesByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 996cd872a..54b70c7d3 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -37,7 +37,8 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index a86fa7420..21989c6ae 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -103,6 +103,11 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { + getLaravelForgeConnectionListItem, + LaravelForgeConnectionMethod, + validateLaravelForgeConnectionCredentials +} from "./laravel-forge"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; @@ -187,6 +192,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getOnePassConnectionListItem(), getHerokuConnectionListItem(), getRenderConnectionListItem(), + getLaravelForgeConnectionListItem(), getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), @@ -215,6 +221,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { return false; case ProjectType.SSH: return false; + case ProjectType.PAM: + return false; default: return true; } @@ -314,6 +322,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.LaravelForge]: validateLaravelForgeConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, @@ -336,6 +345,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case GitHubConnectionMethod.App: case GitHubRadarConnectionMethod.App: return "GitHub App"; + case GitHubConnectionMethod.Pat: + return "Personal Access Token"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: @@ -366,6 +377,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -461,7 +473,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, - [AppConnection.Redis]: platformManagedCredentialsNotSupported + [AppConnection.Redis]: platformManagedCredentialsNotSupported, + [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index e3235d2f7..c01d9d1b4 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -28,6 +28,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OnePass]: "1Password", [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", + [AppConnection.LaravelForge]: "Laravel Forge", [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", @@ -70,6 +71,7 @@ export const APP_CONNECTION_PLAN_MAP: Record { - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (projectId) { const project = await projectDAL.findProjectById(projectId); @@ -474,13 +481,14 @@ export const appConnectionServiceFactory = ({ "Failed to update app connection due to plan restriction. Upgrade plan to access enterprise app connections." ); - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); if (appConnection.projectId) { const { permission } = await permissionService.getProjectPermission({ @@ -632,13 +640,14 @@ export const appConnectionServiceFactory = ({ subject(ProjectPermissionSub.AppConnections, { connectionId }) ); } else { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Delete, @@ -701,13 +710,14 @@ export const appConnectionServiceFactory = ({ subject(ProjectPermissionSub.AppConnections, { connectionId }) ); } else { - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionAppConnectionActions.Connect, @@ -744,13 +754,14 @@ export const appConnectionServiceFactory = ({ }; const listAvailableAppConnectionsForUser = async (app: AppConnection, actor: OrgServiceActor, projectId?: string) => { - const { permission: orgPermission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission: orgPermission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); let availableProjectConnections: TAppConnections[] = []; @@ -802,13 +813,14 @@ export const appConnectionServiceFactory = ({ if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - appConnection.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: appConnection.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAppConnectionActions.Read, @@ -864,6 +876,7 @@ export const appConnectionServiceFactory = ({ supabase: supabaseConnectionService(connectAppConnectionById), digitalOcean: digitalOceanAppPlatformConnectionService(connectAppConnectionById), netlify: netlifyConnectionService(connectAppConnectionById), - okta: oktaConnectionService(connectAppConnectionById) + okta: oktaConnectionService(connectAppConnectionById), + laravelForge: laravelForgeConnectionService(connectAppConnectionById) }; }; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index cf6f45f6c..739b4924e 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -148,6 +148,12 @@ import { THumanitecConnectionInput, TValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; +import { + TLaravelForgeConnection, + TLaravelForgeConnectionConfig, + TLaravelForgeConnectionInput, + TValidateLaravelForgeConnectionCredentialsSchema +} from "./laravel-forge"; import { TLdapConnection, TLdapConnectionConfig, @@ -256,6 +262,7 @@ export type TAppConnection = { id: string } & ( | TOnePassConnection | THerokuConnection | TRenderConnection + | TLaravelForgeConnection | TFlyioConnection | TGitLabConnection | TCloudflareConnection @@ -302,6 +309,7 @@ export type TAppConnectionInput = { id: string } & ( | TOnePassConnectionInput | THerokuConnectionInput | TRenderConnectionInput + | TLaravelForgeConnectionInput | TFlyioConnectionInput | TGitLabConnectionInput | TCloudflareConnectionInput @@ -366,6 +374,7 @@ export type TAppConnectionConfig = | TOnePassConnectionConfig | THerokuConnectionConfig | TRenderConnectionConfig + | TLaravelForgeConnectionConfig | TFlyioConnectionConfig | TGitLabConnectionConfig | TCloudflareConnectionConfig @@ -407,6 +416,7 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateOnePassConnectionCredentialsSchema | TValidateHerokuConnectionCredentialsSchema | TValidateRenderConnectionCredentialsSchema + | TValidateLaravelForgeConnectionCredentialsSchema | TValidateFlyioConnectionCredentialsSchema | TValidateGitLabConnectionCredentialsSchema | TValidateCloudflareConnectionCredentialsSchema diff --git a/backend/src/services/app-connection/github/github-connection-enums.ts b/backend/src/services/app-connection/github/github-connection-enums.ts index 77a4eebac..18dac354f 100644 --- a/backend/src/services/app-connection/github/github-connection-enums.ts +++ b/backend/src/services/app-connection/github/github-connection-enums.ts @@ -1,4 +1,5 @@ export enum GitHubConnectionMethod { OAuth = "oauth", - App = "github-app" + App = "github-app", + Pat = "pat" } diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 5cbf5c60d..806df039f 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -248,10 +248,18 @@ export const makePaginatedGitHubRequest = async ( ): Promise => { const { credentials, method } = appConnection; - const token = - method === GitHubConnectionMethod.OAuth - ? credentials.accessToken - : await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); + let token: string; + + switch (method) { + case GitHubConnectionMethod.OAuth: + token = credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); + } const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; const initialUrlObj = new URL(baseUrl); @@ -460,6 +468,35 @@ export const validateGitHubConnectionCredentials = async ( gatewayV2Service: Pick ) => { const { credentials, method } = config; + + // PAT validation + if (method === GitHubConnectionMethod.Pat) { + try { + const apiUrl = await getGitHubInstanceApiUrl(config); + await requestWithGitHubGateway(config, gatewayService, gatewayV2Service, { + url: `https://${apiUrl}/user`, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${credentials.personalAccessToken}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + return { + personalAccessToken: credentials.personalAccessToken, + instanceType: credentials.instanceType, + host: credentials.host + }; + } catch (e: unknown) { + logger.error(e, "Unable to verify GitHub PAT connection"); + + throw new BadRequestError({ + message: "Unable to validate Personal Access Token: verify token has proper permissions" + }); + } + } + const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index 1b8aa9c3f..20ef2c0e0 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -38,6 +38,19 @@ export const GitHubConnectionAppInputCredentialsSchema = z.union([ }) ]); +export const GitHubConnectionPatInputCredentialsSchema = z.union([ + z.object({ + personalAccessToken: z.string().trim().min(1, "Personal Access Token required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + personalAccessToken: z.string().trim().min(1, "Personal Access Token required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); + export const GitHubConnectionOAuthOutputCredentialsSchema = z.union([ z.object({ accessToken: z.string(), @@ -64,6 +77,19 @@ export const GitHubConnectionAppOutputCredentialsSchema = z.union([ }) ]); +export const GitHubConnectionPatOutputCredentialsSchema = z.union([ + z.object({ + personalAccessToken: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + personalAccessToken: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); + export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), @@ -76,6 +102,12 @@ export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("m credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.GitHub).credentials ) + }), + z.object({ + method: z.literal(GitHubConnectionMethod.Pat).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionPatInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) }) ]); @@ -88,7 +120,11 @@ export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsS export const UpdateGitHubConnectionSchema = z .object({ credentials: z - .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .union([ + GitHubConnectionAppInputCredentialsSchema, + GitHubConnectionOAuthInputCredentialsSchema, + GitHubConnectionPatInputCredentialsSchema + ]) .optional() .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) }) @@ -110,6 +146,10 @@ export const GitHubConnectionSchema = z.intersection( z.object({ method: z.literal(GitHubConnectionMethod.OAuth), credentials: GitHubConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: GitHubConnectionPatOutputCredentialsSchema }) ]) ); @@ -128,6 +168,13 @@ export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), host: z.string().optional() }) + }), + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }) ]); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts index a1e2c8f09..ce0e00f4a 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-enums.ts @@ -2,3 +2,7 @@ export enum HCVaultConnectionMethod { AccessToken = "access-token", AppRole = "app-role" } + +export enum HCVaultAuthType { + Kubernetes = "kubernetes" +} diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 3a79e2f8e..425c9c4d2 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -12,8 +12,73 @@ import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; +import { HCVaultAuthType, HCVaultConnectionMethod } from "./hc-vault-connection-enums"; +import { + THCVaultAuthMount, + THCVaultAuthMountResponse, + THCVaultConnection, + THCVaultConnectionConfig, + THCVaultKubernetesAuthConfig, + THCVaultKubernetesAuthRole, + THCVaultKubernetesAuthRoleWithConfig, + THCVaultMount, + THCVaultMountResponse +} from "./hc-vault-connection-types"; + +// HashiCorp Vault stores JSON data, so values can be any valid JSON type +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export const convertVaultValueToString = (value: JsonValue): string => { + if (value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + // For objects and arrays, serialize as JSON + return JSON.stringify(value); +}; + +// Concurrency limit for HC Vault API requests to avoid rate limiting +const HC_VAULT_CONCURRENCY_LIMIT = 20; + +/** + * Creates a concurrency limiter that restricts the number of concurrent async operations + * @param limit - Maximum number of concurrent operations + * @returns A function that takes an async function and executes it with concurrency control + */ +const createConcurrencyLimiter = (limit: number) => { + let activeCount = 0; + const queue: Array<() => void> = []; + + const next = () => { + activeCount -= 1; + if (queue.length > 0) { + const resolve = queue.shift(); + resolve?.(); + } + }; + + return async (fn: () => Promise): Promise => { + // If we're at the limit, wait in queue + if (activeCount >= limit) { + await new Promise((resolve) => { + queue.push(resolve); + }); + } + + activeCount += 1; + + try { + return await fn(); + } finally { + next(); + } + }; +}; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -181,30 +246,573 @@ export const validateHCVaultConnectionCredentials = async ( } }; -export const listHCVaultMounts = async ( +export const listHCVaultPolicies = async ( + namespace: string, connection: THCVaultConnection, gatewayService: Pick ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService); + try { + const { data: listData } = await requestWithHCVaultGateway<{ + data: { + policies: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const policyNames = listData.data.policies || []; + + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const policies = await Promise.all( + policyNames.map((policyName) => + limiter(async () => { + try { + const { data: policyData } = await requestWithHCVaultGateway<{ + data: { + name: string; + rules: string; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy/${policyName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return { + name: policyData.data.name, + rules: policyData.data.rules + }; + } catch (error: unknown) { + logger.error(error, `Unable to fetch policy details for ${policyName}`); + return { + name: policyName, + rules: "" + }; + } + }) + ) + ); + + return policies; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault policies"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list policies: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list policies from HashiCorp Vault" + }); + } +}; + +export const listHCVaultNamespaces = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const currentNamespace = connection.credentials.namespace || "/"; + + // Helper function to fetch namespaces at a specific path + const fetchNamespacesAtPath = async (namespacePath: string): Promise => { + try { + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + key_info?: { + [key: string]: { + id: string; + path: string; + custom_metadata?: Record; + }; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/namespaces?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespacePath + } + }); + + return data.data.keys || []; + } catch (error: unknown) { + if (error instanceof AxiosError && error.response?.status === 404) { + // No child namespaces at this path + return null; + } + throw error; + } + }; + + // Recursive function to get all namespaces at all depths with controlled parallelization + const recursivelyGetAllNamespaces = async ( + parentPath: string, + limiter: ReturnType + ): Promise => { + const childKeys = await fetchNamespacesAtPath(parentPath); + + if (childKeys === null || childKeys.length === 0) { + return []; + } + + // Process namespaces in parallel with concurrency control + const namespacesArrays = await Promise.all( + childKeys.map((namespaceKey) => + limiter(async () => { + // Remove trailing slash from the key + const cleanNamespaceKey = namespaceKey.replace(/\/$/, ""); + + // Build the full path + let fullNamespacePath: string; + if (parentPath === "/") { + fullNamespacePath = cleanNamespaceKey; + } else { + fullNamespacePath = `${parentPath}/${cleanNamespaceKey}`; + } + + // Recursively fetch child namespaces + const childNamespaces = await recursivelyGetAllNamespaces(fullNamespacePath, limiter); + + // Return this namespace and all its children + return [fullNamespacePath, ...childNamespaces]; + }) + ) + ); + + // Flatten the arrays into a single array + return namespacesArrays.flat(); + }; + + try { + // Create concurrency limiter to avoid overwhelming the Vault instance + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + // Get all namespaces starting from currentNamespace + const childNamespaces = await recursivelyGetAllNamespaces(currentNamespace, limiter); + + // Build the result array with full paths + const namespaces = childNamespaces.map((path) => ({ + id: path, + name: path + })); + + // Always include the current/root namespace + namespaces.unshift({ + id: currentNamespace, + name: currentNamespace + }); + + return namespaces; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault namespaces"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list namespaces: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list namespaces from HashiCorp Vault" + }); + } +}; + +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick, + namespace?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const targetNamespace = namespace || connection.credentials.namespace; + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { url: `${instanceUrl}/v1/sys/mounts`, method: "GET", headers: { "X-Vault-Token": accessToken, - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + ...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {}) } }); - const mounts: string[] = []; + const mounts: THCVaultMount[] = []; - // Filter for "kv" version 2 type only Object.entries(data.data).forEach(([path, mount]) => { - if (mount.type === "kv" && mount.options?.version === "2") { - mounts.push(path); - } + mounts.push({ + path, + type: mount.type, + version: mount.options?.version + }); }); return mounts; }; + +export const listHCVaultSecretPaths = async ( + namespace: string, + connection: THCVaultConnection, + gatewayService: Pick, + filterMountPath?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise => { + try { + let path: string; + if (kvVersion === "2") { + // For KV v2: /v1/{mount}/metadata/{path}?list=true + path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + } else { + // For KV v1: /v1/{mount}/{path}?list=true + path = secretPath ? `${mountPath}/${secretPath}` : mountPath; + } + + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${path}?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data.keys; + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 404) { + return null; + } + + throw error; + } + }; + + // Recursive function to get all secret paths in a mount with controlled parallelization + const recursivelyGetAllPaths = async ( + mountPath: string, + kvVersion: "1" | "2", + limiter: ReturnType, + currentPath: string = "" + ): Promise => { + const paths = await getPaths(mountPath, currentPath, kvVersion); + + if (paths === null || paths.length === 0) { + return []; + } + + // Process paths in parallel with concurrency control + const secretPathsArrays = await Promise.all( + paths.map((path) => + limiter(async () => { + const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path; + const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath; + + if (path.endsWith("/")) { + // it's a folder so we recurse into it + return recursivelyGetAllPaths(mountPath, kvVersion, limiter, fullItemPath); + } + // it's a secret so we return it + return [`${mountPath}/${fullItemPath}`]; + }) + ) + ); + + // Flatten the arrays into a single array + return secretPathsArrays.flat(); + }; + + // Get all mounts + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + + // Filter for KV mounts (kv, kv-v1, kv-v2) + let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); + + // If filterMountPath is provided, filter to only that mount + if (filterMountPath) { + const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash + kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath); + } + + // Create concurrency limiter to avoid overwhelming the Vault instance + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + // Collect all secret paths from all KV mounts in parallel + const allSecretPathsArrays = await Promise.all( + kvMounts.map(async (mount) => { + const kvVersion = mount.version === "2" ? "2" : "1"; + const cleanMountPath = mount.path.replace(/\/$/, ""); // Remove trailing slash + return recursivelyGetAllPaths(cleanMountPath, kvVersion, limiter); + }) + ); + + // Flatten the arrays into a single array + const allSecretPaths = allSecretPathsArrays.flat(); + + return allSecretPaths; +}; + +export const getHCVaultSecretsForPath = async ( + namespace: string, + secretPath: string, + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + // Extract mount and path from the secretPath + // secretPath format: {mount}/{path} + const pathParts = secretPath.split("/"); + const mountPath = pathParts[0]; + const actualPath = pathParts.slice(1).join("/"); + + if (!mountPath || !actualPath) { + throw new BadRequestError({ + message: "Invalid secret path format. Expected format: {mount}/{path}" + }); + } + + // Get mounts to determine KV version + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + const mount = mounts.find((m) => m.path.replace(/\/$/, "") === mountPath); + + if (!mount) { + throw new BadRequestError({ + message: `Mount '${mountPath}' not found in HashiCorp Vault` + }); + } + + const kvVersion = mount.version === "2" ? "2" : "1"; + + // Fetch secrets based on KV version + if (kvVersion === "2") { + // For KV v2: /v1/{mount}/data/{path} + const { data } = await requestWithHCVaultGateway<{ + data: { + data: Record; // KV v2 has nested data structure, supports all JSON types + metadata: { + created_time: string; + deletion_time: string; + destroyed: boolean; + version: number; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${mountPath}/data/${actualPath}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data.data; + } + + // For KV v1: /v1/{mount}/{path} + const { data } = await requestWithHCVaultGateway<{ + data: Record; // KV v1 has flat data structure, supports all JSON types + lease_duration: number; + lease_id: string; + renewable: boolean; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${mountPath}/${actualPath}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data; + } catch (error: unknown) { + logger.error(error, "Unable to fetch secrets from HC Vault path"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch secrets: ${error.message || "Unknown error"}` + }); + } + + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: "Unable to fetch secrets from HashiCorp Vault" + }); + } +}; + +export const getHCVaultAuthMounts = async ( + namespace: string, + authType: HCVaultAuthType | undefined, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/auth`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const authMounts: THCVaultAuthMount[] = []; + + Object.entries(data.data).forEach(([path, authMethod]) => { + // If authType is specified, filter by it; otherwise, include all + if (!authType || authMethod.type === authType) { + authMounts.push({ + path, + type: authMethod.type, + description: authMethod.description, + accessor: authMethod.accessor + }); + } + }); + + return authMounts; + } catch (error: unknown) { + const authTypeStr = authType || "all"; + logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault` + }); + } +}; + +export const getHCVaultKubernetesAuthRoles = async ( + namespace: string, + mountPath: string, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + // Remove trailing slash from mount path + const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath; + + try { + // 1. Get the Kubernetes auth configuration for this mount + const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthConfig }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/config`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const kubernetesConfig = configResponse.data; + + // 2. List all roles in this mount + const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/role`, + method: "LIST", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const roleNames = roleListResponse.data.keys; + + if (!roleNames || roleNames.length === 0) { + return []; + } + + // 3. Fetch details for each role with concurrency control + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const roleDetailsPromises = roleNames.map((roleName) => + limiter(async () => { + const { data: roleResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthRole }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/role/${roleName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + // 4. Merge the role with the config + return { + ...roleResponse.data, + name: roleName, + config: kubernetesConfig, + mountPath: cleanMountPath + } as THCVaultKubernetesAuthRoleWithConfig; + }) + ); + + const roles = await Promise.all(roleDetailsPromises); + + return roles; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault Kubernetes auth roles"); + + if (error instanceof AxiosError) { + const errorMessage = + (error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error"; + throw new BadRequestError({ + message: `Failed to list Kubernetes auth roles: ${errorMessage}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Kubernetes auth roles from HashiCorp Vault" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index 589c7c1bd..037964da4 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -21,7 +21,8 @@ export const hcVaultConnectionService = ( try { const mounts = await listHCVaultMounts(appConnection, gatewayService); - return mounts; + // Filter for KV version 2 mounts only and extract just the paths + return mounts.filter((mount) => mount.type === "kv" && mount.version === "2").map((mount) => mount.path); } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); return []; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index 6f254eda0..d25dbc9e6 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -33,3 +33,65 @@ export type THCVaultMountResponse = { }; }; }; + +export type THCVaultMount = { + path: string; + type: string; + version?: string | null; +}; + +export type THCVaultAuthMountResponse = { + data: { + [key: string]: { + type: string; + description: string; + accessor: string; + config: { + default_lease_ttl: number; + max_lease_ttl: number; + force_no_cache: boolean; + }; + local: boolean; + seal_wrap: boolean; + external_entropy_access: boolean; + options: Record | null; + }; + }; +}; + +export type THCVaultAuthMount = { + path: string; + type: string; + description: string; + accessor: string; +}; + +export type THCVaultKubernetesAuthConfig = { + kubernetes_host: string; + kubernetes_ca_cert?: string; + issuer?: string; + disable_iss_validation?: boolean; + disable_local_ca_jwt?: boolean; +}; + +export type THCVaultKubernetesAuthRole = { + name: string; + bound_service_account_names: string[]; + bound_service_account_namespaces: string[]; + token_ttl?: number; + token_max_ttl?: number; + token_policies?: string[]; + token_bound_cidrs?: string[]; + token_explicit_max_ttl?: number; + token_no_default_policy?: boolean; + token_num_uses?: number; + token_period?: number; + token_type?: string; + audience?: string; + alias_name_source?: string; +}; + +export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & { + config: THCVaultKubernetesAuthConfig; + mountPath: string; +}; diff --git a/backend/src/services/app-connection/laravel-forge/index.ts b/backend/src/services/app-connection/laravel-forge/index.ts new file mode 100644 index 000000000..9a3637d06 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-connection-enums"; +export * from "./laravel-forge-connection-fns"; +export * from "./laravel-forge-connection-schemas"; +export * from "./laravel-forge-connection-types"; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts new file mode 100644 index 000000000..548d3adfb --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts @@ -0,0 +1,3 @@ +export enum LaravelForgeConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts new file mode 100644 index 000000000..e63c659a7 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts @@ -0,0 +1,165 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; +import { + TLaravelForgeConnection, + TLaravelForgeConnectionConfig, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + TRawLaravelForgeOrganization, + TRawLaravelForgeServer, + TRawLaravelForgeSite +} from "./laravel-forge-connection-types"; + +export const getLaravelForgeConnectionListItem = () => { + return { + name: "Laravel Forge" as const, + app: AppConnection.LaravelForge as const, + methods: Object.values(LaravelForgeConnectionMethod) as [LaravelForgeConnectionMethod.ApiToken] + }; +}; + +export const validateLaravelForgeConnectionCredentials = async (config: TLaravelForgeConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + // Using the /api/me endpoint to validate the API token + await request.get(`${IntegrationUrls.LARAVELFORGE_API_URL}/api/me`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return inputCredentials; +}; + +type TLaravelForgeApiResponse = { + data: T[]; + links?: { + next?: string; + }; + meta?: { + next_cursor?: string; + prev_cursor?: string | null; + }; +}; + +const fetchAllPages = async ( + apiToken: string, + url: string, + params?: Record +): Promise => { + const allItems: T[] = []; + let nextUrl: string | null = url; + const queryParams = params || {}; + + while (nextUrl) { + try { + const response: { data: TLaravelForgeApiResponse } = await request.get>(nextUrl, { + params: queryParams, + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + + if (!response?.data?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${url}: Response was empty or malformed` + }); + } + + allItems.push(...response.data.data); + + if (response.data.links?.next) { + nextUrl = response.data.links.next; + } else { + nextUrl = null; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${url}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +}; + +export const listLaravelForgeOrganizations = async ( + appConnection: TLaravelForgeConnection +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawOrganizations = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs` + ); + + return rawOrganizations.map((org: TRawLaravelForgeOrganization) => ({ + id: org.id, + name: org.attributes.name, + slug: org.attributes.slug + })); +}; + +export const listLaravelForgeServers = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawServers = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers` + ); + + return rawServers.map((server: TRawLaravelForgeServer) => ({ + id: server.id, + name: server.attributes.name + })); +}; + +export const listLaravelForgeSites = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string, + serverId: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawSites = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers/${serverId}/sites` + ); + + return rawSites.map((site: TRawLaravelForgeSite) => ({ + id: site.id, + name: site.attributes.name + })); +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts new file mode 100644 index 000000000..1647b38a1 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts @@ -0,0 +1,58 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; + +export const LaravelForgeConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API token required").describe(AppConnections.CREDENTIALS.LARAVEL_FORGE.apiToken) +}); + +const BaseLaravelForgeConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.LaravelForge) }); + +export const LaravelForgeConnectionSchema = BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema +}); + +export const SanitizedLaravelForgeConnectionSchema = z.discriminatedUnion("method", [ + BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateLaravelForgeConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(LaravelForgeConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.LaravelForge).method), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.LaravelForge).credentials + ) + }) +]); + +export const CreateLaravelForgeConnectionSchema = ValidateLaravelForgeConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.LaravelForge) +); + +export const UpdateLaravelForgeConnectionSchema = z + .object({ + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.LaravelForge).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LaravelForge)); + +export const LaravelForgeConnectionListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + app: z.literal(AppConnection.LaravelForge), + methods: z.nativeEnum(LaravelForgeConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts new file mode 100644 index 000000000..fc3c2bf80 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts @@ -0,0 +1,74 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listLaravelForgeOrganizations, + listLaravelForgeServers, + listLaravelForgeSites +} from "./laravel-forge-connection-fns"; +import { + TLaravelForgeConnection, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite +} from "./laravel-forge-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const laravelForgeConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async ( + connectionId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const organizations = await listLaravelForgeOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to list organizations for Laravel Forge connection"); + return []; + } + }; + + const listServers = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const servers = await listLaravelForgeServers(appConnection, organizationSlug); + return servers; + } catch (error) { + logger.error(error, "Failed to list servers for Laravel Forge connection"); + return []; + } + }; + + const listSites = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string, + serverId: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const sites = await listLaravelForgeSites(appConnection, organizationSlug, serverId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites for Laravel Forge connection"); + return []; + } + }; + + return { + listOrganizations, + listServers, + listSites + }; +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts new file mode 100644 index 000000000..ad134ef3d --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts @@ -0,0 +1,63 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateLaravelForgeConnectionSchema, + LaravelForgeConnectionSchema, + ValidateLaravelForgeConnectionCredentialsSchema +} from "./laravel-forge-connection-schemas"; + +export type TLaravelForgeConnection = z.infer; + +export type TLaravelForgeConnectionInput = z.infer & { + app: AppConnection.LaravelForge; +}; + +export type TValidateLaravelForgeConnectionCredentialsSchema = typeof ValidateLaravelForgeConnectionCredentialsSchema; + +export type TLaravelForgeConnectionConfig = DiscriminativePick< + TLaravelForgeConnectionInput, + "method" | "app" | "credentials" +> & { + orgSlug: string; +}; + +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + id: string; + name: string; +}; + +export type TRawLaravelForgeOrganization = { + id: string; + attributes: { + name: string; + slug: string; + }; +}; + +export type TRawLaravelForgeServer = { + id: string; + attributes: { + name: string; + }; +}; + +export type TRawLaravelForgeSite = { + id: string; + attributes: { + name: string; + }; +}; diff --git a/backend/src/services/app-connection/railway/railway-connection-public-client.ts b/backend/src/services/app-connection/railway/railway-connection-public-client.ts index 1c8bd9cc2..47eb3f396 100644 --- a/backend/src/services/app-connection/railway/railway-connection-public-client.ts +++ b/backend/src/services/app-connection/railway/railway-connection-public-client.ts @@ -75,7 +75,7 @@ class RailwayPublicClient { async send( query: string, options: RailwaySendReqOptions, - variables: Record> = {}, + variables: Record = {}, retryAttempt: number = 0 ): Promise { const body = { @@ -117,6 +117,25 @@ class RailwayPublicClient { } } + async getDeployments( + config: RailwaySendReqOptions, + variables: { input: { serviceId: string; environmentId: string }; first?: number } + ) { + return this.send>( + `query deployments($input: DeploymentListInput!, $first: Int) { deployments(first: $first, input: $input) { edges { node { id } } } }`, + config, + variables + ); + } + + async redeployDeployment(config: RailwaySendReqOptions, variables: { input: { deploymentId: string } }) { + return this.send>( + `mutation deploymentRedeploy($deploymentId: String!) { deploymentRedeploy(id: $deploymentId) { id } }`, + config, + { deploymentId: variables.input.deploymentId } + ); + } + async getSubscriptionType(config: RailwaySendReqOptions & { projectId: string }) { const res = await this.send( `query project($projectId: String!) { project(id: $projectId) { subscriptionType }}`, @@ -213,7 +232,9 @@ class RailwayPublicClient { async deleteVariable( config: RailwaySendReqOptions, - variables: { input: { projectId: string; environmentId: string; name: string; serviceId?: string } } + variables: { + input: { projectId: string; environmentId: string; name: string; skipDeploys?: boolean; serviceId?: string }; + } ) { await this.send }>>( `mutation variableDelete($input: VariableDeleteInput!) { variableDelete(input: $input) }`, @@ -222,6 +243,26 @@ class RailwayPublicClient { ); } + async upsertCollection( + config: RailwaySendReqOptions, + variables: { + input: { + projectId: string; + environmentId: string; + variables: Record; + skipDeploys?: boolean; + serviceId?: string; + replace?: boolean; + }; + } + ) { + return this.send>( + `mutation variableCollectionUpsert($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }`, + config, + variables + ); + } + async upsertVariable( config: RailwaySendReqOptions, variables: { input: { projectId: string; environmentId: string; name: string; value: string; serviceId?: string } } diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 613aa0766..28a986fe8 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -1,12 +1,13 @@ import { Knex } from "knex"; -import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; +import { AccessScope, TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; -import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { AuthModeJwtTokenPayload, AuthModeRefreshJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TTokenDALFactory } from "./auth-token-dal"; import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types"; @@ -14,7 +15,8 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; userDAL: Pick; - orgMembershipDAL: Pick; + orgDAL: Pick; + membershipUserDAL: Pick; }; export type TAuthTokenServiceFactory = ReturnType; @@ -80,7 +82,7 @@ export const getTokenConfig = (tokenType: TokenType) => { } }; -export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { +export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgDAL }: TAuthTokenServiceFactoryDep) => { const createTokenForUser = async ({ type, userId, orgId, aliasId, payload }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); @@ -194,7 +196,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu }; // to parse jwt identity in inject identity plugin - const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload) => { + const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload, subOrganizationSelector?: string) => { const session = await tokenDAL.findOneTokenSession({ id: token.tokenVersionId, userId: token.userId @@ -207,21 +209,56 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu const user = await userDAL.findById(session.userId); if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` }); + let orgId = ""; + let rootOrgId = ""; + let parentOrgId = ""; if (token.organizationId) { - const orgMembership = await orgMembershipDAL.findOne({ - userId: user.id, - orgId: token.organizationId - }); + if (subOrganizationSelector) { + const subOrganization = await orgDAL.findOne({ + rootOrgId: token.organizationId, + slug: subOrganizationSelector + }); + if (!subOrganization) + throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); - if (!orgMembership) { - throw new ForbiddenRequestError({ message: "User not member of organization" }); - } - if (!orgMembership.isActive) { - throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: subOrganization.id, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User not member of organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + } + orgId = subOrganization.id; + rootOrgId = token.organizationId; + parentOrgId = subOrganization.parentOrgId as string; + } else { + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: token.organizationId, + scope: AccessScope.Organization + }); + + if (!orgMembership) { + throw new ForbiddenRequestError({ message: "User not member of organization" }); + } + + if (!orgMembership.isActive) { + throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); + } + + orgId = token.organizationId; + rootOrgId = token.organizationId; + parentOrgId = token.organizationId; } } - return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId }; + return { user, tokenVersionId: token.tokenVersionId, orgId, rootOrgId, parentOrgId }; }; return { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index d69c836e9..31a9cc5a8 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -1,6 +1,13 @@ import { Knex } from "knex"; -import { OrgMembershipRole, OrgMembershipStatus, TableName, TUsers, UserDeviceSchema } from "@app/db/schemas"; +import { + AccessScope, + OrgMembershipRole, + OrgMembershipStatus, + TableName, + TUsers, + UserDeviceSchema +} from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; @@ -14,11 +21,12 @@ 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"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TNotificationServiceFactory } from "../notification/notification-service"; import { NotificationType } from "../notification/notification-types"; import { TOrgDALFactory } from "../org/org-dal"; import { getDefaultOrgMembershipRole } from "../org/org-role-fns"; -import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { LoginMethod } from "../super-admin/super-admin-types"; import { TTotpServiceFactory } from "../totp/totp-service"; @@ -48,7 +56,8 @@ type TAuthLoginServiceFactoryDep = { smtpService: TSmtpService; totpService: Pick; auditLogService: Pick; - orgMembershipDAL: TOrgMembershipDALFactory; + membershipUserDAL: TMembershipUserDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; notificationService: Pick; }; @@ -58,10 +67,11 @@ export const authLoginServiceFactory = ({ tokenService, smtpService, orgDAL, - orgMembershipDAL, totpService, auditLogService, - notificationService + notificationService, + membershipUserDAL, + membershipRoleDAL }: TAuthLoginServiceFactoryDep) => { /* * Private @@ -163,8 +173,8 @@ export const authLoginServiceFactory = ({ if (organizationId) { const org = await orgDAL.findById(organizationId); if (org) { - await orgMembershipDAL.update( - { userId: user.id, orgId: org.id }, + await membershipUserDAL.update( + { actorUserId: user.id, scopeOrgId: org.id, scope: AccessScope.Organization }, { lastLoginAuthMethod: authMethod, lastLoginTime: new Date() } ); if (org.userTokenExpiration) { @@ -858,21 +868,34 @@ export const authLoginServiceFactory = ({ } orgId = defaultOrg.id; const [orgMembership] = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.userId` as "userId"]: user.id, - [`${TableName.OrgMembership}.orgId` as "id"]: orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: user.id, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }); if (!orgMembership) { const { role, roleId } = await getDefaultOrgMembershipRole(defaultOrg.defaultMembershipRole); - await orgMembershipDAL.create({ - userId: user.id, - inviteEmail: email, - orgId, - role, - roleId, - status: OrgMembershipStatus.Accepted, - isActive: true + await membershipUserDAL.transaction(async (tx) => { + const membership = await membershipUserDAL.create( + { + actorUserId: user?.id, + inviteEmail: email, + scopeOrgId: orgId, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted, + isActive: true + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role, + customRoleId: roleId + }, + tx + ); }); } } @@ -895,10 +918,11 @@ export const authLoginServiceFactory = ({ if (org) { // checks for the membership and only sets the orgId / orgName if the user is a member of the specified org const orgMembership = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.userId` as "userId"]: user.id, - [`${TableName.OrgMembership}.orgId` as "orgId"]: org.id, - [`${TableName.OrgMembership}.isActive` as "isActive"]: true, - [`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted + [`${TableName.Membership}.actorUserId` as "actorUserId"]: user.id, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: org.id, + [`${TableName.Membership}.isActive` as "isActive"]: true, + [`${TableName.Membership}.status` as "status"]: OrgMembershipStatus.Accepted, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }); if (orgMembership) { diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 21a51ef3f..75f77d0fc 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -1,3 +1,4 @@ +import { AccessScope } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; @@ -6,7 +7,7 @@ import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; -import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TTotpConfigDALFactory } from "../totp/totp-config-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -23,7 +24,7 @@ import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; userDAL: TUserDALFactory; - orgMembershipDAL: Pick; + membershipUserDAL: Pick; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; totpConfigDAL: Pick; @@ -33,7 +34,7 @@ export type TAuthPasswordFactory = ReturnType; export const authPaswordServiceFactory = ({ authDAL, userDAL, - orgMembershipDAL, + membershipUserDAL, tokenService, smtpService, totpConfigDAL @@ -54,7 +55,10 @@ export const authPaswordServiceFactory = ({ const hasEmailAuth = user.authMethods?.includes(AuthMethod.EMAIL); if (!hasEmailAuth) { - const orgMemberships = await orgMembershipDAL.find({ userId: user.id }); + const orgMemberships = await membershipUserDAL.find({ + actorUserId: user.id, + scope: AccessScope.Organization + }); const lastLoginMethod = orgMemberships .filter((membership) => membership.lastLoginAuthMethod) diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 7bc3a5ef6..14f4387b9 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,4 +1,4 @@ -import { OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { AccessScope, OrgMembershipStatus, TableName } from "@app/db/schemas"; import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -8,17 +8,15 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { getMinExpiresIn } from "@app/lib/fn"; import { isDisposableEmail } from "@app/lib/validator"; -import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TMembershipGroupDALFactory } from "../membership-group/membership-group-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; -import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { getServerCfg } from "../super-admin/super-admin-service"; import { TUserDALFactory } from "../user/user-dal"; @@ -42,14 +40,12 @@ type TAuthSignupDep = { projectKeyDAL: Pick; projectDAL: Pick; projectBotDAL: Pick; - groupProjectDAL: Pick; orgService: Pick; orgDAL: TOrgDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; licenseService: Pick; - projectMembershipDAL: Pick; - projectUserMembershipRoleDAL: Pick; + membershipGroupDAL: TMembershipGroupDALFactory; }; export type TAuthSignupFactory = ReturnType; @@ -60,11 +56,11 @@ export const authSignupServiceFactory = ({ projectKeyDAL, projectDAL, projectBotDAL, - groupProjectDAL, tokenService, smtpService, orgService, orgDAL, + membershipGroupDAL, licenseService }: TAuthSignupDep) => { // first step of signup. create user and send email @@ -200,9 +196,10 @@ export const authSignupServiceFactory = ({ organizationId ) { const [pendingOrgMembership] = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.Membership}.actorUserId` as "actorUserId"]: user.id, status: OrgMembershipStatus.Invited, - [`${TableName.OrgMembership}.orgId` as "orgId"]: organizationId + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: organizationId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }); if (pendingOrgMembership) { @@ -241,18 +238,18 @@ export const authSignupServiceFactory = ({ } const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, - { userId: user.id, status: OrgMembershipStatus.Accepted } + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited, scope: AccessScope.Organization }, + { actorUserId: user.id, status: OrgMembershipStatus.Accepted } ); - const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; + const uniqueOrgId = [...new Set(updatedMembersips.map(({ scopeOrgId }) => scopeOrgId))]; await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId))); await convertPendingGroupAdditionsToGroupMemberships({ userIds: [user.id], userDAL, userGroupMembershipDAL, - groupProjectDAL, projectKeyDAL, + membershipGroupDAL, projectDAL, projectBotDAL }); @@ -261,7 +258,13 @@ export const authSignupServiceFactory = ({ let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME; if (organizationId) { - const org = await orgService.findOrganizationById(user.id, organizationId, authMethod, organizationId); + const org = await orgService.findOrganizationById( + user.id, + organizationId, + authMethod, + organizationId, + organizationId + ); if (org && org.userTokenExpiration) { tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); refreshTokenExpiresIn = org.userTokenExpiration; @@ -351,21 +354,21 @@ export const authSignupServiceFactory = ({ ); const updatedMembersips = await orgDAL.updateMembership( - { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited }, - { userId: us.id, status: OrgMembershipStatus.Accepted }, + { inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited, scope: AccessScope.Organization }, + { actorUserId: us.id, status: OrgMembershipStatus.Accepted }, tx ); - const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; + const uniqueOrgId = [...new Set(updatedMembersips.map(({ scopeOrgId }) => scopeOrgId))]; await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId, tx))); await convertPendingGroupAdditionsToGroupMemberships({ userIds: [user.id], userDAL, userGroupMembershipDAL, - groupProjectDAL, projectKeyDAL, projectDAL, projectBotDAL, + membershipGroupDAL, tx }); diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.test.ts b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts new file mode 100644 index 000000000..870055711 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { signatureAlgorithmToAlgCfg } from "./certificate-authority-fns"; + +describe("signatureAlgorithmToAlgCfg", () => { + describe("RSA algorithms", () => { + it("should handle RSA-SHA256 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + + it("should handle RSA-SHA384 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA384", CertKeyAlgorithm.RSA_4096); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-384", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }); + }); + + it("should handle RSA-SHA256 with RSA_3072 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_3072); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }); + }); + + it("should handle RSA-SHA512 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-512", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + }); + + describe("ECDSA algorithms", () => { + it("should handle ECDSA-SHA256 with P-256 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with P-384 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + + it("should handle ECDSA-SHA256 with EC_prime256v1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_prime256v1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with EC_secp384r1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_secp384r1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); + + describe("hash format normalization", () => { + it("should normalize SHA256 to SHA-256", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should normalize SHA384 to SHA-384", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + expect(result.hash).toBe("SHA-384"); + }); + + it("should normalize SHA512 to SHA-512", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_4096); + expect(result.hash).toBe("SHA-512"); + }); + + it("should handle SHA1 format", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA1", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-1"); + }); + + it("should handle SHA224 format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA224", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-224"); + }); + + it("should handle case insensitive hash normalization", () => { + const result = signatureAlgorithmToAlgCfg("RSA-sha256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle already normalized hash formats", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle SHA-3 family hashes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA3256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA3-256"); + }); + }); + + describe("dynamic key algorithm support", () => { + it("should support future RSA key sizes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", "RSA_8192"); + + expect(result.name).toBe("RSASSA-PKCS1-v1_5"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support future EC curves", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_secp521r1"); + + expect(result.name).toBe("ECDSA"); + expect(result.namedCurve).toBe("P-521"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support EC_P384 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_P384"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); +}); diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 9991e462e..aff81dcc5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import * as x509 from "@peculiar/x509"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -68,6 +69,13 @@ export const parseDistinguishedName = (dn: string): TDNParts => { export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_3072: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }; case CertKeyAlgorithm.RSA_4096: return { name: "RSASSA-PKCS1-v1_5", @@ -99,6 +107,73 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { } }; +export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => { + // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc. + if (!signatureAlgorithm || typeof signatureAlgorithm !== "string" || !signatureAlgorithm.includes("-")) { + throw new Error(`Invalid signature algorithm format: ${signatureAlgorithm}`); + } + + const [keyType, hashType] = signatureAlgorithm.split("-"); + + if (!keyType || !hashType) { + throw new Error(`Malformed signature algorithm: ${signatureAlgorithm}`); + } + + const normalizeHashType = (hash: string) => { + const upperHash = hash.toUpperCase(); + + if (upperHash === "SHA1" || upperHash === "SHA-1") return "SHA-1"; + + if (upperHash === "SHA224" || upperHash === "SHA-224") return "SHA-224"; + if (upperHash === "SHA256" || upperHash === "SHA-256") return "SHA-256"; + if (upperHash === "SHA384" || upperHash === "SHA-384") return "SHA-384"; + if (upperHash === "SHA512" || upperHash === "SHA-512") return "SHA-512"; + + if (upperHash === "SHA3224" || upperHash === "SHA3-224") return "SHA3-224"; + if (upperHash === "SHA3256" || upperHash === "SHA3-256") return "SHA3-256"; + if (upperHash === "SHA3384" || upperHash === "SHA3-384") return "SHA3-384"; + if (upperHash === "SHA3512" || upperHash === "SHA3-512") return "SHA3-512"; + + throw new Error(`Unsupported hash algorithm: ${hash}`); + }; + + const normalizedHash = hashType ? normalizeHashType(hashType) : undefined; + + switch (keyType) { + case "RSA": + return { + name: "RSASSA-PKCS1-v1_5", + hash: normalizedHash || "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: + keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : keyAlgorithm === CertKeyAlgorithm.RSA_3072 ? 3072 : 2048 + }; + case "ECDSA": + // eslint-disable-next-line no-case-declarations + const is384Curve = + keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384"; + // eslint-disable-next-line no-case-declarations + const is521Curve = keyAlgorithm === "EC_secp521r1" || keyAlgorithm === "EC_P521"; + // eslint-disable-next-line no-case-declarations + let namedCurve: string; + if (is521Curve) { + namedCurve = "P-521"; + } else if (is384Curve) { + namedCurve = "P-384"; + } else { + namedCurve = "P-256"; + } + return { + name: "ECDSA", + namedCurve, + hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") + }; + default: + // Fallback to key algorithm default + return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); + } +}; + /** * Return the public and private key of CA with id [caId] * Note: credentials are returned as crypto.webcrypto.CryptoKey @@ -111,7 +186,8 @@ export const getCaCredentials = async ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm }: TGetCaCredentialsDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); @@ -132,7 +208,7 @@ export const getCaCredentials = async ({ cipherTextBlob: caSecret.encryptedPrivateKey }); - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const alg = signatureAlgorithm || keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const skObj = crypto.nativeCrypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); const caPrivateKey = await crypto.nativeCrypto.subtle.importKey( "pkcs8", diff --git a/backend/src/services/certificate-authority/certificate-authority-schemas.ts b/backend/src/services/certificate-authority/certificate-authority-schemas.ts index 61d620156..5ecc50a4b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/certificate-authority-schemas.ts @@ -18,7 +18,7 @@ export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick( export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId), enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) }); @@ -26,7 +26,7 @@ export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId), enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) }); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index ab89ea996..5b9cd78ee 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -32,6 +32,8 @@ import { CertExtendedKeyUsageOIDToName, CertKeyAlgorithm, CertKeyUsage, + CertSignatureAlgorithm, + CertSignatureType, CertStatus, TAltNameMapping } from "../../certificate/certificate-types"; @@ -48,7 +50,8 @@ import { getCaCertChains, getCaCredentials, keyAlgorithmToAlgCfg, - parseDistinguishedName + parseDistinguishedName, + signatureAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; @@ -1174,7 +1177,10 @@ export const internalCertificateAuthorityServiceFactory = ({ actor, actorOrgId, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm, + isFromProfile }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1221,7 +1227,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1277,13 +1283,43 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); - const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const effectiveKeyAlgorithm = + (keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; + + const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]); + + if ( + (requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) || + (requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa + ? CertSignatureAlgorithm.RSA_SHA256.split("-")[0] + : isEcdsaCa + ? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] + : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + // Determine signing algorithm for certificate signing + const signingAlg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ name: `CN=${commonName}`, keys: leafKeys, - signingAlgorithm: alg, + signingAlgorithm: keyGenAlg, extensions: [ // eslint-disable-next-line no-bitwise new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) @@ -1296,7 +1332,8 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm: signingAlg }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); @@ -1319,7 +1356,7 @@ export const internalCertificateAuthorityServiceFactory = ({ // handle key usages let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; if (keyUsages === undefined && !certificateTemplate) { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } if (keyUsages === undefined && certificateTemplate) { @@ -1405,7 +1442,7 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, signingKey: caPrivateKey, publicKey: csrObj.publicKey, - signingAlgorithm: alg, + signingAlgorithm: signingAlg, extensions }); @@ -1517,7 +1554,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore, notAfter, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm } = dto; let collectionId = pkiCollectionId; @@ -1563,7 +1602,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!dto.isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1622,7 +1661,29 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA) + + const isRsaCa = caKeyAlgorithm.startsWith(CertSignatureType.RSA); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertSignatureType.ECDSA); + + if ( + (requestedKeyType === CertSignatureType.RSA && !isRsaCa) || + (requestedKeyType === CertSignatureType.ECDSA && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa ? CertSignatureType.RSA : isEcdsaCa ? CertSignatureType.ECDSA : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm; + const alg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = new x509.Pkcs10CertificateRequest(csr); @@ -1671,7 +1732,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (csrKeyUsageExtension) { selectedKeyUsages = csrKeyUsages; } else { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = dto.isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } } diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index fadd7b88d..22cb86d28 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TProjectPermission } from "@app/lib/types"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -131,6 +136,9 @@ export type TIssueCertFromCaDTO = { notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: CertSignatureAlgorithm; + keyAlgorithm?: CertKeyAlgorithm; + isFromProfile?: boolean; } & Omit; export type TSignCertFromCaDTO = @@ -148,6 +156,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } | ({ isInternal: false; @@ -163,6 +174,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } & Omit); export type TGetCaCertificateTemplatesDTO = { @@ -184,6 +198,7 @@ export type TGetCaCredentialsDTO = { certificateAuthoritySecretDAL: Pick; projectDAL: Pick; kmsService: Pick; + signatureAlgorithm?: RsaHashedImportParams | EcKeyImportParams; }; export type TGetCaCertChainsDTO = { diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts new file mode 100644 index 000000000..bbd589110 --- /dev/null +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -0,0 +1,187 @@ +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + +export enum CertKeyUsageType { + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" +} + +export enum CertExtendedKeyUsageType { + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + OCSP_SIGNING = "ocsp_signing", + TIME_STAMPING = "time_stamping" +} + +export enum CertIncludeType { + MANDATORY = "mandatory", + OPTIONAL = "optional", + PROHIBIT = "prohibit" +} + +export enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny" +} + +export enum CertSanEffect { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +export enum CertDurationUnit { + DAYS = "days", + MONTHS = "months", + YEARS = "years" +} + +export enum CertSubjectAttributeType { + COMMON_NAME = "common_name", + ORGANIZATION = "organization", + COUNTRY = "country" +} + +export const mapKeyUsageToLegacy = (usage: CertKeyUsageType): string => { + switch (usage) { + case CertKeyUsageType.DIGITAL_SIGNATURE: + return "digitalSignature"; + case CertKeyUsageType.KEY_ENCIPHERMENT: + return "keyEncipherment"; + case CertKeyUsageType.NON_REPUDIATION: + return "nonRepudiation"; + case CertKeyUsageType.DATA_ENCIPHERMENT: + return "dataEncipherment"; + case CertKeyUsageType.KEY_AGREEMENT: + return "keyAgreement"; + case CertKeyUsageType.KEY_CERT_SIGN: + return "keyCertSign"; + case CertKeyUsageType.CRL_SIGN: + return "cRLSign"; + case CertKeyUsageType.ENCIPHER_ONLY: + return "encipherOnly"; + case CertKeyUsageType.DECIPHER_ONLY: + return "decipherOnly"; + default: + return usage; + } +}; + +export const mapLegacyKeyUsageToStandard = (usage: string): CertKeyUsageType => { + switch (usage) { + case "digitalSignature": + case "digital_signature": + return CertKeyUsageType.DIGITAL_SIGNATURE; + case "keyEncipherment": + case "key_encipherment": + return CertKeyUsageType.KEY_ENCIPHERMENT; + case "nonRepudiation": + case "non_repudiation": + return CertKeyUsageType.NON_REPUDIATION; + case "dataEncipherment": + case "data_encipherment": + return CertKeyUsageType.DATA_ENCIPHERMENT; + case "keyAgreement": + case "key_agreement": + return CertKeyUsageType.KEY_AGREEMENT; + case "keyCertSign": + case "key_cert_sign": + return CertKeyUsageType.KEY_CERT_SIGN; + case "cRLSign": + case "crl_sign": + return CertKeyUsageType.CRL_SIGN; + case "encipherOnly": + case "encipher_only": + return CertKeyUsageType.ENCIPHER_ONLY; + case "decipherOnly": + case "decipher_only": + return CertKeyUsageType.DECIPHER_ONLY; + default: + throw new Error(`Unknown key usage: ${usage}`); + } +}; + +export const mapExtendedKeyUsageToLegacy = (usage: CertExtendedKeyUsageType): string => { + switch (usage) { + case CertExtendedKeyUsageType.CLIENT_AUTH: + return "clientAuth"; + case CertExtendedKeyUsageType.SERVER_AUTH: + return "serverAuth"; + case CertExtendedKeyUsageType.CODE_SIGNING: + return "codeSigning"; + case CertExtendedKeyUsageType.EMAIL_PROTECTION: + return "emailProtection"; + case CertExtendedKeyUsageType.OCSP_SIGNING: + return "ocspSigning"; + case CertExtendedKeyUsageType.TIME_STAMPING: + return "timeStamping"; + default: + return usage; + } +}; + +export const mapLegacyExtendedKeyUsageToStandard = (usage: string): CertExtendedKeyUsageType => { + switch (usage) { + case "clientAuth": + case "client_auth": + return CertExtendedKeyUsageType.CLIENT_AUTH; + case "serverAuth": + case "server_auth": + return CertExtendedKeyUsageType.SERVER_AUTH; + case "codeSigning": + case "code_signing": + return CertExtendedKeyUsageType.CODE_SIGNING; + case "emailProtection": + case "email_protection": + return CertExtendedKeyUsageType.EMAIL_PROTECTION; + case "ocspSigning": + case "ocsp_signing": + return CertExtendedKeyUsageType.OCSP_SIGNING; + case "timeStamping": + case "time_stamping": + return CertExtendedKeyUsageType.TIME_STAMPING; + default: + throw new Error(`Unknown extended key usage: ${usage}`); + } +}; + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" +} + +export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); +export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); +export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); +export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType); +export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit); +export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); +export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); +export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); +export const KEY_ALGORITHM_OPTIONS = Object.values(CertKeyAlgorithm); +export const SIGNATURE_ALGORITHM_OPTIONS = Object.values(CertSignatureAlgorithm); diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts new file mode 100644 index 000000000..b88f183db --- /dev/null +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -0,0 +1,198 @@ +import RE2 from "re2"; + +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + mapExtendedKeyUsageToLegacy, + mapKeyUsageToLegacy, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "./certificate-constants"; + +interface CertificateRequestInput { + keyUsages?: string[]; + extendedKeyUsages?: string[]; +} + +export const mapEnumsForValidation = (request: T): T => { + const mapKeyUsage = (usage: string): string => { + try { + return mapLegacyKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + const mapExtendedKeyUsage = (usage: string): string => { + try { + return mapLegacyExtendedKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + return { + ...request, + keyUsages: request.keyUsages?.map(mapKeyUsage), + extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage) + } as T; +}; + +export const normalizeDateForApi = (date: Date | string | undefined): string | undefined => { + if (!date) return undefined; + return date instanceof Date ? date.toISOString() : date; +}; + +export const bufferToString = (data: Buffer | string): string => { + return String(data); +}; + +export const buildCertificateSubjectFromTemplate = ( + request: Record, + templateAttributes?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): Record => { + const subject: Record = {}; + const attributeMap: Record = { + common_name: "commonName", + organization: "organization", + country: "country" + }; + + if (!templateAttributes || templateAttributes.length === 0) { + return subject; + } + + templateAttributes.forEach((attr) => { + const requestKey = attributeMap[attr.type]; + const value = request[requestKey]; + + if (value && typeof value === "string" && (attr.allowed || attr.required)) { + subject[attr.type] = value; + } + }); + + return subject; +}; + +const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); +}; + +const createWildcardRegex = (pattern: string): RE2 => { + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = pattern.replace(escapeRegex, "\\$&"); + const wildcardRegex = new RE2(/\*/g); + const regexPattern = escaped.replace(wildcardRegex, ".*"); + return new RE2(`^${regexPattern}$`); +}; + +const validateValueAgainstPatterns = (value: string, patterns: string[]): boolean => { + if (!patterns || patterns.length === 0) { + return false; + } + + for (const pattern of patterns) { + if (isWildcardPattern(pattern)) { + try { + const regex = createWildcardRegex(pattern); + if (regex.test(value)) { + return true; + } + } catch { + if (pattern === value) { + return true; + } + } + } else if (pattern === value) { + return true; + } + } + + return false; +}; + +export const buildSubjectAlternativeNamesFromTemplate = ( + request: { subjectAlternativeNames?: Array<{ type: string; value: string }> }, + templateSans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): string => { + if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) { + return ""; + } + + if (!templateSans || templateSans.length === 0) { + return request.subjectAlternativeNames.map((san) => san.value).join(","); + } + + const allowedSans: string[] = []; + + request.subjectAlternativeNames.forEach((san) => { + const templateSan = templateSans.find((template) => template.type === san.type); + + if (!templateSan) { + allowedSans.push(san.value); + return; + } + + if (templateSan.denied && validateValueAgainstPatterns(san.value, templateSan.denied)) { + throw new Error(`SAN value '${san.value}' is explicitly denied for type '${san.type}'`); + } + + const isRequired = templateSan.required && validateValueAgainstPatterns(san.value, templateSan.required); + const isAllowed = templateSan.allowed && validateValueAgainstPatterns(san.value, templateSan.allowed); + + if (isRequired || isAllowed || (!templateSan.allowed && !templateSan.required)) { + allowedSans.push(san.value); + } else { + throw new Error(`SAN value '${san.value}' is not allowed for type '${san.type}'`); + } + }); + + return allowedSans.join(","); +}; + +export const convertLegacyKeyUsage = (usage: CertKeyUsage): CertKeyUsageType => { + return mapLegacyKeyUsageToStandard(usage); +}; + +export const convertToLegacyKeyUsage = (usage: CertKeyUsageType): CertKeyUsage => { + return mapKeyUsageToLegacy(usage) as CertKeyUsage; +}; + +export const convertLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsage): CertExtendedKeyUsageType => { + return mapLegacyExtendedKeyUsageToStandard(usage); +}; + +export const convertToLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsageType): CertExtendedKeyUsage => { + return mapExtendedKeyUsageToLegacy(usage) as CertExtendedKeyUsage; +}; + +export const convertKeyUsageArrayFromLegacy = (usages?: CertKeyUsage[]): CertKeyUsageType[] | undefined => { + return usages?.map(convertLegacyKeyUsage); +}; + +export const convertKeyUsageArrayToLegacy = (usages?: CertKeyUsageType[]): CertKeyUsage[] | undefined => { + return usages?.map(convertToLegacyKeyUsage); +}; + +export const convertExtendedKeyUsageArrayFromLegacy = ( + usages?: CertExtendedKeyUsage[] +): CertExtendedKeyUsageType[] | undefined => { + return usages?.map(convertLegacyExtendedKeyUsage); +}; + +export const convertExtendedKeyUsageArrayToLegacy = ( + usages?: CertExtendedKeyUsageType[] +): CertExtendedKeyUsage[] | undefined => { + return usages?.map(convertToLegacyExtendedKeyUsage); +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts new file mode 100644 index 000000000..b6408a986 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts @@ -0,0 +1,737 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-bitwise */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; +import { certificateEstV3ServiceFactory, TCertificateEstV3ServiceFactory } from "./certificate-est-v3-service"; + +// Mock the x509 module +vi.mock("@peculiar/x509", () => ({ + Pkcs10CertificateRequest: vi.fn(), + GeneralNames: vi.fn(), + KeyUsagesExtension: vi.fn(), + ExtendedKeyUsageExtension: vi.fn(), + X509Certificate: vi.fn(), + KeyUsageFlags: { + digitalSignature: 1, + nonRepudiation: 2, + keyEncipherment: 4, + dataEncipherment: 8, + keyAgreement: 16, + keyCertSign: 32, + cRLSign: 64, + encipherOnly: 128, + decipherOnly: 256 + } +})); + +// Mock other dependencies +vi.mock("@app/services/certificate-authority/certificate-authority-fns", () => ({ + parseDistinguishedName: vi.fn((subject: string) => { + const parts = subject.split(","); + const result: any = {}; + parts.forEach((part) => { + const [key, value] = part.split("="); + switch (key.trim()) { + case "CN": + result.commonName = value; + break; + case "O": + result.organization = value; + break; + case "OU": + result.ou = value; + break; + case "L": + result.locality = value; + break; + case "ST": + result.province = value; + break; + case "C": + result.country = value; + break; + default: + break; + } + }); + return result; + }) +})); + +vi.mock("@app/services/certificate-authority/certificate-authority-validators", () => ({ + validateAndMapAltNameType: vi.fn((value: string) => { + if (value.includes(".") && !value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "dns", value }; + } + if (value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "ip", value }; + } + return null; + }) +})); + +vi.mock("@app/services/certificate-common/certificate-constants", () => ({ + mapLegacyKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + digitalSignature: "digital_signature", + keyEncipherment: "key_encipherment", + keyCertSign: "key_cert_sign" + }; + return mapping[usage] || usage; + }), + mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + clientAuth: "client_auth", + serverAuth: "server_auth", + codeSigning: "code_signing" + }; + return mapping[usage] || usage; + }), + CertKeyUsageType: { + DIGITAL_SIGNATURE: "digital_signature", + KEY_ENCIPHERMENT: "key_encipherment", + KEY_CERT_SIGN: "key_cert_sign" + }, + CertExtendedKeyUsageType: { + CLIENT_AUTH: "client_auth", + SERVER_AUTH: "server_auth", + CODE_SIGNING: "code_signing" + }, + CertSubjectAlternativeNameType: { + DNS_NAME: "dns_name", + IP_ADDRESS: "ip_address", + RFC822_NAME: "rfc822_name", + UNIFORM_RESOURCE_IDENTIFIER: "uniform_resource_identifier" + } +})); + +vi.mock("@app/services/certificate/certificate-types", () => ({ + mapLegacyAltNameType: vi.fn((type: string) => { + const mapping: Record = { + dns: "dns_name", + ip: "ip_address", + email: "rfc822_name", + url: "uniform_resource_identifier" + }; + return mapping[type] || type; + }), + TAltNameType: { + EMAIL: "email", + DNS: "dns", + IP: "ip", + URL: "url" + }, + CertExtendedKeyUsageOIDToName: { + "1.3.6.1.5.5.7.3.1": "serverAuth", + "1.3.6.1.5.5.7.3.2": "clientAuth", + "1.3.6.1.5.5.7.3.3": "codeSigning" + }, + CertKeyUsage: { + DIGITAL_SIGNATURE: "digitalSignature", + KEY_ENCIPHERMENT: "keyEncipherment", + KEY_CERT_SIGN: "keyCertSign", + NON_REPUDIATION: "nonRepudiation", + DATA_ENCIPHERMENT: "dataEncipherment", + KEY_AGREEMENT: "keyAgreement", + CRL_SIGN: "cRLSign", + ENCIPHER_ONLY: "encipherOnly", + DECIPHER_ONLY: "decipherOnly" + }, + CertExtendedKeyUsage: { + CLIENT_AUTH: "clientAuth", + SERVER_AUTH: "serverAuth", + CODE_SIGNING: "codeSigning" + } +})); + +vi.mock("@app/services/certificate-common/certificate-utils", () => ({ + mapEnumsForValidation: vi.fn((req: any) => req) +})); + +vi.mock("../../ee/services/certificate-est/certificate-est-fns", () => ({ + convertRawCertsToPkcs7: vi.fn(() => "mocked-pkcs7-response") +})); + +describe("CertificateEstV3Service Security Fix", () => { + let service: TCertificateEstV3ServiceFactory; + + const mockInternalCertificateAuthorityService = { + signCertFromCa: vi.fn() + }; + + const mockCertificateTemplateV2Service = { + validateCertificateRequest: vi.fn() + }; + + const mockCertificateAuthorityDAL = { + findById: vi.fn(), + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateAuthorityCertDAL = { + find: vi.fn(), + findById: vi.fn() + }; + + const mockProjectDAL = { + findOne: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn() + }; + + const mockKmsService = { + decryptWithKmsKey: vi.fn(), + generateKmsKey: vi.fn() + }; + + const mockLicenseService = { + getPlan: vi.fn() + }; + + const mockCertificateProfileDAL = { + findByIdWithConfigs: vi.fn() + }; + + const mockEstEnrollmentConfigDAL = { + findById: vi.fn() + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + caId: "ca-123", + certificateTemplateId: "template-v2-123", + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }; + + const mockEstConfig = { + id: "est-config-123", + disableBootstrapCaValidation: true + }; + + const mockProject = { + id: "project-123", + orgId: "org-123" + }; + + const mockPlan = { + pkiEst: true + }; + + beforeEach(async () => { + const { Pkcs10CertificateRequest, GeneralNames } = await import("@peculiar/x509"); + + service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService: mockInternalCertificateAuthorityService, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + projectDAL: mockProjectDAL, + kmsService: mockKmsService, + licenseService: mockLicenseService, + certificateProfileDAL: mockCertificateProfileDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL + }); + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockEstEnrollmentConfigDAL.findById.mockResolvedValue(mockEstConfig); + mockProjectDAL.findOne.mockResolvedValue(mockProject); + mockLicenseService.getPlan.mockResolvedValue(mockPlan); + + // Set up the default CSR parsing behavior + (Pkcs10CertificateRequest as any).mockImplementation((csr: string) => { + const parsed = JSON.parse(csr); + const mockExtensions: any[] = []; + + if (parsed.sans && parsed.sans.length > 0) { + mockExtensions.push({ type: "2.5.29.17", value: "mock-san-value" }); + } + + return { + subject: parsed.subject, + extensions: mockExtensions, + getExtension: vi.fn((oid: string) => { + if (oid === "2.5.29.15" && parsed.keyUsages && parsed.keyUsages.length > 0) { + // Calculate usages as bitwise OR of key usage flags + let usages = 0; + parsed.keyUsages.forEach((usage: string) => { + switch (usage) { + case "digital_signature": + usages |= 1; // KeyUsageFlags.digitalSignature + break; + case "key_encipherment": + usages |= 4; // KeyUsageFlags.keyEncipherment + break; + case "key_cert_sign": + usages |= 32; // KeyUsageFlags.keyCertSign + break; + default: + break; + } + }); + return { usages }; + } + if (oid === "2.5.29.37" && parsed.extendedKeyUsages && parsed.extendedKeyUsages.length > 0) { + const ekuOids = parsed.extendedKeyUsages.map((eku: string) => { + switch (eku) { + case "client_auth": + return "1.3.6.1.5.5.7.3.2"; + case "server_auth": + return "1.3.6.1.5.5.7.3.1"; + case "code_signing": + return "1.3.6.1.5.5.7.3.3"; + default: + return "1.3.6.1.5.5.7.3.1"; + } + }); + return { usages: ekuOids }; + } + return undefined; + }) + }; + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const createMockCSR = ( + options: { + subject?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + sans?: Array<{ type: string; value: string }>; + } = {} + ) => { + const { + subject = "CN=test.example.com,O=Test Org,C=US", + keyUsages = [], + extendedKeyUsages = [], + sans = [] + } = options; + + return JSON.stringify({ + subject, + keyUsages, + extendedKeyUsages, + sans + }); + }; + + describe("CSR Extraction and Template Validation", () => { + it("should extract subject attributes from CSR", async () => { + const csr = createMockCSR({ + subject: "CN=test.example.com,O=Test Organization,OU=IT Department,L=San Francisco,ST=California,C=US" + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "test.example.com", + organization: "Test Organization", + organizationUnit: "IT Department", + locality: "San Francisco", + state: "California", + country: "US" + }) + ); + }); + + it("should extract key usages from CSR", async () => { + const csr = createMockCSR({ + keyUsages: ["digital_signature", "key_encipherment"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["digital_signature", "key_encipherment"]) + }) + ); + }); + + it("should extract extended key usages from CSR", async () => { + const csr = createMockCSR({ + extendedKeyUsages: ["client_auth", "server_auth"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + extendedKeyUsages: expect.arrayContaining(["client_auth", "server_auth"]) + }) + ); + }); + + it("should extract Subject Alternative Names from CSR", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + const csr = createMockCSR({ + sans: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + subjectAlternativeNames: expect.arrayContaining([ + expect.objectContaining({ + type: "dns_name", + value: "test.example.com" + }), + expect.objectContaining({ + type: "ip_address", + value: "192.168.1.1" + }) + ]) + }) + ); + }); + }); + + describe("Template Validation Enforcement", () => { + const basicCSR = createMockCSR(); + + it("should enforce template validation and reject invalid requests", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Common name 'test.example.com' is not allowed", "Key usage 'digital_signature' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + + it("should allow valid requests that pass template validation", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.any(Object) + ); + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + csr: basicCSR, + isFromProfile: true + }); + }); + + it("should validate template for both simpleEnrollByProfile and simpleReenrollByProfile", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value 'evil.com' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalled(); + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + }); + + describe("Policy Bypass Prevention", () => { + const maliciousCSR = createMockCSR({ + subject: "CN=evil.com,O=Evil Corp,C=XX", + keyUsages: ["key_cert_sign"], + sans: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + }); + + it("should block attempts to bypass subject attribute policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Organization 'Evil Corp' is denied", "Country 'XX' is not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "evil.com", + organization: "Evil Corp", + country: "XX" + }) + ); + }); + + it("should block attempts to bypass key usage policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Key usage 'key_cert_sign' is denied - certificate authority privileges not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["key_cert_sign"]) + }) + ); + }); + + it("should block attempts to bypass SAN policies", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value '*.example.com' matches denied wildcard pattern", "SAN value '127.0.0.1' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + }); + + describe("Error Handling", () => { + const basicCSR = createMockCSR(); + + it("should handle profile not found", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "nonexistent", + sslClientCert: "" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should handle non-EST enrollment type", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue({ + ...mockProfile, + enrollmentType: EnrollmentType.API + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + + it("should handle template validation service errors", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockRejectedValue( + new Error("Template validation service unavailable") + ); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow("Template validation service unavailable"); + }); + }); + + describe("Integration with existing flow", () => { + const basicCSR = createMockCSR(); + + beforeEach(() => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + }); + + it("should call internal CA service with correct parameters after validation", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + isFromProfile: true, + csr: basicCSR + }); + }); + + it("should use profile's CA ID instead of template ID to avoid v1/v2 mismatch", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + // Verify it uses caId from profile, not certificateTemplateId + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.objectContaining({ + caId: "ca-123" + }) + ); + + // Verify it does NOT pass certificateTemplateId to avoid v1/v2 confusion + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.not.objectContaining({ + certificateTemplateId: expect.anything() + }) + ); + }); + }); +}); diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts new file mode 100644 index 000000000..f6dbfba52 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -0,0 +1,408 @@ +import * as x509 from "@peculiar/x509"; + +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { isCertChainValid } from "@app/services/certificate/certificate-fns"; +import { + CertExtendedKeyUsageOIDToName, + CertKeyUsage, + mapLegacyAltNameType, + TAltNameMapping, + TAltNameType +} from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { + getCaCertChain, + getCaCertChains, + parseDistinguishedName +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; +import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { convertRawCertsToPkcs7 } from "../../ee/services/certificate-est/certificate-est-fns"; +import { TLicenseServiceFactory } from "../../ee/services/license/license-service"; + +type TCertificateEstV3ServiceFactoryDep = { + internalCertificateAuthorityService: Pick; + certificateTemplateV2Service: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + licenseService: Pick; + certificateProfileDAL: Pick; + estEnrollmentConfigDAL: Pick; +}; + +export type TCertificateEstV3ServiceFactory = ReturnType; + +export const certificateEstV3ServiceFactory = ({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL +}: TCertificateEstV3ServiceFactoryDep) => { + const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => + value.type === TAltNameType.EMAIL || + value.type === TAltNameType.DNS || + value.type === TAltNameType.IP || + value.type === TAltNameType.URL + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; + }; + const simpleEnrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + if (!estConfig.disableBootstrapCaValidation) { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = estConfig.encryptedCaChain + ? ( + await kmsDecryptor({ + cipherTextBlob: estConfig.encryptedCaChain + }) + ).toString() + : ""; + + const caCerts = extractX509CertFromChain(decryptedCaChain)?.map((cert) => { + return new x509.X509Certificate(cert); + }); + + if (!caCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const certObj = new x509.X509Certificate(leafCertificate); + if (!(await isCertChainValid([certObj, ...caCerts]))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const simpleReenrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const cert = new x509.X509Certificate(leafCertificate); + const caCertChains = await getCaCertChains({ + caId: profile.caId, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService + }); + + const verifiedChains = await Promise.all( + caCertChains.map((chain) => { + const caCert = new x509.X509Certificate(chain.certificate); + const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || []; + + return isCertChainValid([cert, caCert, ...caChain]); + }) + ); + + if (!verifiedChains.some(Boolean)) { + throw new BadRequestError({ + message: "Invalid client certificate: unable to build a valid certificate chain" + }); + } + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + if (csrObj.subject !== cert.subject) { + throw new BadRequestError({ + message: "Subject mismatch" + }); + } + + let csrSanSet: Set = new Set(); + const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (csrSanExtension) { + const sanNames = new x509.GeneralNames(csrSanExtension.value); + csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + let certSanSet: Set = new Set(); + const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (certSanExtension) { + const sanNames = new x509.GeneralNames(certSanExtension.value); + certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) { + throw new BadRequestError({ + message: "Subject alternative names mismatch" + }); + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const getCaCertsByProfile = async ({ profileId }: { profileId: string }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca?.internalCa?.id) { + throw new NotFoundError({ + message: `Internal Certificate Authority with ID '${profile.caId}' not found` + }); + } + + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId as string, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + let certificates: x509.X509Certificate[] = []; + if (caCertChain && caCertChain.trim()) { + try { + certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); + } catch (error) { + certificates = []; + } + } + + const caCertificate = new x509.X509Certificate(caCert); + + return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); + }; + + return { + simpleEnrollByProfile, + simpleReenrollByProfile, + getCaCertsByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts new file mode 100644 index 000000000..20cb9f3bc --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -0,0 +1,552 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +export type TCertificateProfileDALFactory = ReturnType; + +export const certificateProfileDALFactory = (db: TDbClient) => { + const certificateProfileOrm = ormify(db, TableName.PkiCertificateProfile); + + const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ + TCertificateProfile + ]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate profile" }); + } + }; + + const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .update(data) + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + + const deleteById = async (id: string, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .del() + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate profile" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as + | TCertificateProfile + | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id" }); + } + }; + + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { + try { + const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin( + TableName.CertificateAuthority, + `${TableName.PkiCertificateProfile}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .leftJoin( + TableName.PkiCertificateTemplateV2, + `${TableName.PkiCertificateProfile}.certificateTemplateId`, + `${TableName.PkiCertificateTemplateV2}.id` + ) + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), + db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), + db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), + db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"), + db.ref("id").withSchema(TableName.PkiCertificateTemplateV2).as("templateId"), + db.ref("projectId").withSchema(TableName.PkiCertificateTemplateV2).as("templateProjectId"), + db.ref("name").withSchema(TableName.PkiCertificateTemplateV2).as("templateName"), + db.ref("description").withSchema(TableName.PkiCertificateTemplateV2).as("templateDescription"), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estConfigDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays") + ) + .where(`${TableName.PkiCertificateProfile}.id`, id) + .first(); + + const result = await query; + + if (!result) return undefined; + + const estConfig = + result.estConfigId && result.estConfigHashedPassphrase + ? ({ + id: result.estConfigId, + disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : "" + } as TCertificateProfileWithConfigs["estConfig"]) + : undefined; + + const apiConfig = result.apiConfigId + ? ({ + id: result.apiConfigId, + autoRenew: !!result.apiConfigAutoRenew, + autoRenewDays: result.apiConfigAutoRenewDays || undefined + } as TCertificateProfileWithConfigs["apiConfig"]) + : undefined; + + const certificateAuthority = + result.caId && result.caProjectId && result.caStatus && result.caName + ? ({ + id: result.caId, + projectId: result.caProjectId, + status: result.caStatus, + name: result.caName + } as TCertificateProfileWithConfigs["certificateAuthority"]) + : undefined; + + const certificateTemplate = + result.templateId && result.templateProjectId && result.templateName + ? ({ + id: result.templateId, + projectId: result.templateProjectId, + name: result.templateName, + description: result.templateDescription || undefined + } as TCertificateProfileWithConfigs["certificateTemplate"]) + : undefined; + + const transformedResult: TCertificateProfileWithConfigs = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig, + certificateAuthority, + certificateTemplate + }; + + return transformedResult; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with configs" }); + } + }; + + const findBySlugAndProjectId = async ( + slug: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by slug and project id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + } = {}, + tx?: Knex + ): Promise => { + try { + const { + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 7 + } = options; + + let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( + `${TableName.PkiCertificateProfile}.projectId`, + projectId + ); + + if (search) { + baseQuery = baseQuery.where((builder) => { + void builder.where((qb) => { + void qb + .whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`) + .orWhereILike(`${TableName.PkiCertificateProfile}.description`, `%${search}%`); + }); + }); + } + + if (enrollmentType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); + } + + if (caId) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); + } + + let query = baseQuery + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + ); + + if (includeMetrics) { + query = query.leftJoin( + TableName.Certificate, + `${TableName.PkiCertificateProfile}.id`, + `${TableName.Certificate}.profileId` + ); + + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + query = query + .select( + selectAllTableCols(TableName.PkiCertificateProfile), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), + db.raw("COUNT(certificates.id) as total_certificates"), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', + [expiringDate] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates', + [now] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .groupBy( + `${TableName.PkiCertificateProfile}.id`, + `${TableName.PkiEstEnrollmentConfig}.id`, + `${TableName.PkiApiEnrollmentConfig}.id` + ); + } + + const results = (await query + .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") + .offset(offset) + .limit(limit)) as Record[]; + + return results.map((result: Record) => { + const estConfig = + result.estId && result.estHashedPassphrase + ? { + id: result.estId as string, + disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : "" + } + : undefined; + + const apiConfig = result.apiId + ? { + id: result.apiId as string, + autoRenew: !!result.apiAutoRenew, + autoRenewDays: (result.apiAutoRenewDays as number) || undefined + } + : undefined; + + const baseProfile = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig + }; + + if (includeMetrics) { + return { + ...baseProfile, + total_certificates: result.total_certificates, + active_certificates: result.active_certificates, + expired_certificates: result.expired_certificates, + expiring_certificates: result.expiring_certificates, + revoked_certificates: result.revoked_certificates + } as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs; + } + + return baseProfile as TCertificateProfileWithConfigs; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profiles by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { search, enrollmentType, caId } = options; + + let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`); + }); + }); + } + + if (enrollmentType) { + query = query.where({ enrollmentType }); + } + + if (caId) { + query = query.where({ caId }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate profiles by project id" }); + } + }; + + const findByNameAndProjectId = async ( + name: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug: name, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by name and project id" }); + } + }; + + const getCertificatesByProfile = async ( + profileId: string, + options: { + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { offset = 0, limit = 20, status, search } = options; + const now = new Date(); + + let query = (tx || db)(TableName.Certificate).where("profileId", profileId); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("cn", `%${search}%`).orWhereILike("serialNumber", `%${search}%`); + }); + }); + } + + if (status) { + switch (status) { + case "active": + query = query.where("notAfter", ">", now).whereNull("revokedAt"); + break; + case "expired": + query = query.where("notAfter", "<=", now).whereNull("revokedAt"); + break; + case "revoked": + query = query.whereNotNull("revokedAt"); + break; + default: + break; + } + } + + const certificates = await query + .select((tx || db).ref("id").withSchema(TableName.Certificate)) + .select((tx || db).ref("serialNumber").withSchema(TableName.Certificate)) + .select((tx || db).ref("cn").withSchema(TableName.Certificate)) + .select((tx || db).ref("status").withSchema(TableName.Certificate)) + .select((tx || db).ref("notBefore").withSchema(TableName.Certificate)) + .select((tx || db).ref("notAfter").withSchema(TableName.Certificate)) + .select((tx || db).ref("revokedAt").withSchema(TableName.Certificate)) + .select((tx || db).ref("createdAt").withSchema(TableName.Certificate)) + .orderBy("createdAt", "desc") + .offset(offset) + .limit(limit); + + return certificates.map((cert) => ({ + ...cert, + revokedAt: cert.revokedAt ?? null + })); + } catch (error) { + throw new DatabaseError({ error, name: "Get certificates by profile" }); + } + }; + + const getProfileMetrics = async ( + profileId: string, + expiringDays: number = 7, + tx?: Knex + ): Promise => { + try { + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + const metrics = await (tx || db)(TableName.Certificate) + .where("profileId", profileId) + .select( + db.raw("COUNT(*) as total_certificates"), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [ + expiringDate + ]), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]), + db.raw( + 'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .first(); + + return { + profileId, + totalCertificates: parseInt(String((metrics as Record)?.total_certificates || 0), 10), + activeCertificates: parseInt(String((metrics as Record)?.active_certificates || 0), 10), + expiredCertificates: parseInt(String((metrics as Record)?.expired_certificates || 0), 10), + expiringCertificates: parseInt(String((metrics as Record)?.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String((metrics as Record)?.revoked_certificates || 0), 10) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate profile metrics" }); + } + }; + + const isProfileInUse = async (profileId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); + + return parseInt((doc as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate profile is in use" }); + } + }; + + return { + ...certificateProfileOrm, + create, + updateById, + deleteById, + findById, + findByIdWithConfigs, + findBySlugAndProjectId, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + getCertificatesByProfile, + getProfileMetrics, + isProfileInUse + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts new file mode 100644 index 000000000..7b3e2cc57 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -0,0 +1,134 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EnrollmentType } from "./certificate-profile-types"; + +export const createCertificateProfileSchema = z + .object({ + projectId: z.string().uuid("Project ID must be valid"), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ); + +export const updateCertificateProfileSchema = z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ); + +export const getCertificateProfileByIdSchema = z.object({ + id: z.string().uuid() +}); + +export const getCertificateProfileBySlugSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + slug: z.string().min(1) +}); + +export const listCertificateProfilesSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional() +}); + +export const deleteCertificateProfileSchema = z.object({ + id: z.string().uuid() +}); + +export const listCertificatesByProfileSchema = z.object({ + profileId: z.string().uuid(), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.enum(["active", "expired", "revoked"]).optional(), + search: z.string().optional() +}); + +export const getCertificateProfileMetricsSchema = z.object({ + profileId: z.string().uuid(), + expiringDays: z.coerce.number().min(1).max(365).default(30) +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts new file mode 100644 index 000000000..dd2d7d2d6 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -0,0 +1,1181 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import type { TKmsServiceFactory } from "../kms/kms-service"; +import type { TProjectDALFactory } from "../project/project-dal"; +import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; +import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; + +vi.mock("@app/lib/crypto/cryptography", () => ({ + crypto: { + hashing: () => ({ + createHash: vi.fn().mockResolvedValue("mocked-hash") + }), + generateRandomPassword: vi.fn().mockReturnValue("mocked-password") + } +})); + +vi.mock("@app/lib/config/env", () => ({ + getConfig: () => ({ + SALT_ROUNDS: 12 + }) +})); + +describe("CertificateProfileService", () => { + let service: TCertificateProfileServiceFactory; + + const mockCertificateProfileDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findBySlugAndProjectId: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + findByNameAndProjectId: vi.fn(), + findByIdWithConfigs: vi.fn(), + getCertificatesByProfile: vi.fn(), + getProfileMetrics: vi.fn(), + isProfileInUse: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateProfileDALFactory; + + const mockCertificateTemplateV2DAL = { + findById: vi.fn(), + create: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateTemplateV2DALFactory; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const sampleProfile: TCertificateProfile = { + id: "profile-123", + projectId: "project-123", + description: "Test certificate profile", + slug: "test-profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const sampleProfileWithConfigs: TCertificateProfileWithConfigs = { + ...sampleProfile, + certificateAuthority: { + id: "ca-123", + projectId: "project-123", + status: "active", + name: "Test CA" + }, + certificateTemplate: { + id: "template-123", + projectId: "project-123", + name: "Test Template", + description: "Test template" + }, + apiConfig: { + id: "api-config-123", + autoRenew: true, + autoRenewDays: 30 + } + }; + + const sampleTemplate = { + id: "template-123", + projectId: "project-123", + name: "Test Template" + }; + + const mockApiEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "api-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + findProfilesForAutoRenewal: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TApiEnrollmentConfigDALFactory; + + const mockEstEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "est-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TEstEnrollmentConfigDALFactory; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn() + } + }) + } as unknown as Pick; + + const mockKmsService = { + encryptWithKmsKey: vi + .fn() + .mockResolvedValue(() => Promise.resolve({ cipherTextBlob: Buffer.from("encrypted-data") })), + decryptWithKmsKey: vi.fn().mockResolvedValue(() => Promise.resolve(Buffer.from("decrypted-ca-chain"))), + generateKmsKey: vi.fn() + } as unknown as Pick; + + const mockProjectDAL = { + findById: vi.fn(), + findOne: vi.fn(), + updateById: vi.fn(), + findProjectBySlug: vi.fn(), + transaction: vi.fn() + } as unknown as Pick; + + beforeEach(() => { + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Mock the transaction method to execute the callback and return the result + (mockCertificateProfileDAL.transaction as any).mockImplementation(async (fn: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await + return await fn(); + }); + + service = certificateProfileServiceFactory({ + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2DAL: mockCertificateTemplateV2DAL, + apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + permissionService: mockPermissionService, + kmsService: mockKmsService, + projectDAL: mockProjectDAL + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("createProfile", () => { + const validProfileData = { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + beforeEach(() => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should create profile successfully", async () => { + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123"); + expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith( + { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + projectId: "project-123" + }, + undefined + ); + }); + + it("should throw NotFoundError when certificate template not found", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template belongs to different project", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue({ + ...sampleTemplate, + projectId: "different-project" + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError when profile slug already exists", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for EST enrollment without EST config", async () => { + const invalidData = { + ...validProfileData, + enrollmentType: EnrollmentType.EST, + estConfigId: null + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for API enrollment without API config", async () => { + const invalidData = { + slug: "invalid-profile", + description: "Invalid test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123" + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should create profile with API enrollment", async () => { + const apiProfileData = { + slug: "api-profile", + description: "Profile with API enrollment", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: apiProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("updateProfile", () => { + const updateData = { + slug: "updated-profile", + description: "Updated description" + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...sampleProfile, + ...updateData, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should update profile successfully", async () => { + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }); + + expect(result.slug).toBe("updated-profile"); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData, undefined); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should validate certificate template when updating", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + + const updateWithTemplate = { + ...updateData, + certificateTemplateId: "template-123" + }; + + await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateWithTemplate + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("getProfileById", () => { + it("should return profile successfully", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileByIdWithConfigs", () => { + it("should return profile with configs successfully", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(sampleProfileWithConfigs); + + const result = await service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfileWithConfigs); + expect(mockCertificateProfileDAL.findByIdWithConfigs).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect( + service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileBySlug", () => { + it("should return profile by slug successfully", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "test-profile" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("test-profile", "project-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + + await expect( + service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "nonexistent" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listProfiles", () => { + const mockProfiles = [sampleProfile]; + + beforeEach(() => { + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfiles); + (mockCertificateProfileDAL.countByProjectId as any).mockResolvedValue(1); + }); + + it("should list profiles successfully", async () => { + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123" + }); + + expect(result.profiles).toEqual(mockProfiles); + expect(result.totalCount).toBe(1); + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with filters", async () => { + await service.listProfiles({ + ...mockActor, + projectId: "project-123", + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123" + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with metrics when includeMetrics is true", async () => { + const mockProfilesWithMetrics = [ + { + ...sampleProfile, + total_certificates: 10, + active_certificates: 8, + expired_certificates: 1, + expiring_certificates: 1, + revoked_certificates: 0 + } + ]; + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics); + + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123", + includeMetrics: true, + expiringDays: 15 + }); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]).toHaveProperty("metrics"); + expect(result.profiles[0].metrics).toEqual({ + profileId: sampleProfile.id, + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 1, + revokedCertificates: 0 + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: true, + expiringDays: 15 + }); + }); + }); + + describe("deleteProfile", () => { + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + }); + + it("should delete profile successfully", async () => { + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileCertificates", () => { + const mockCertificates = [ + { + id: "cert-123", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(mockCertificates); + }); + + it("should get profile certificates successfully", async () => { + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockCertificates); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: undefined + }); + }); + + it("should get profile certificates with filters", async () => { + await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileMetrics", () => { + const mockMetrics = { + profileId: "profile-123", + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 2, + revokedCertificates: 1 + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics); + }); + + it("should get profile metrics successfully", async () => { + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockMetrics); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30); + }); + + it("should get profile metrics with custom expiring days", async () => { + await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123", + expiringDays: 60 + }); + + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("comprehensive certificate profile scenarios", () => { + describe("profile configuration validation", () => { + it("should validate EST enrollment configuration", async () => { + const estProfileData = { + slug: "est-profile", + description: "Profile with EST enrollment", + enrollmentType: EnrollmentType.EST, + caId: "ca-123", + certificateTemplateId: "template-123", + estConfig: { + disableBootstrapCaValidation: false, + passphrase: "secret-passphrase", + caChain: + "-----BEGIN CERTIFICATE-----\nMIIC+DCCAeCgAwIBAgIUBmCvLQ7l6CmNYjGeGXqIaS9LPuUwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEChMJSW5maXNpY2FsMB4XDTI1MTAxNzE1MjczMFoXDTM1MTAx\nNzAwMDAwMFowFDESMBAGA1UEChMJSW5maXNpY2FsMIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAqRS0ZKh44Y1GHvD4/ryduaelVtfvqkdCmhxpCp7OTjIA\n/gPuVoBA31gxqMVcpDgIAk8dfqds0WFzFe2byhbBalNm3+FSYJkEKa1mdCnqM/mL\nt6O0V/dPv2dcepDluwWbHJIuFf5elH1F8eeyqZV5w6c980lOyDO0DVNqB6pjGlPq\njEVcvEdEtGSfIX3B2tmODilwUvl/lGjhnK6ghfots7i1Xno9VAY/YTqR0T+lyPx4\n23r+22gstJ7XCLA7aqfRyFyYaVKqubHPBwz2qKiBTc3Shc3ii/OHc5KjTpADNRDv\nvH7X5kOXYtdpGbMsJ1uY+MPwfbOVkxy4tg4HFejmyQIDAQABo0IwQDAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUpshrlfvvw+zkoLKf\nxNUYD92/YxIwDQYJKoZIhvcNAQELBQADggEBAAWDMNe8HnoOPHF1sIUcCvJjBeUz\neB++l5Er9P+UPpkSr7+KpD+9DQGWmaOT57Vp7nBYXd42828h+cq7KEG2w5Uf6fYD\nBuitrzj2IzNznvKwOMh/qAePC17tH4mnkSnsJCMg6cvG99GG+vQoMQW7+D6VshIH\nm5hNThNGSPznk+eNk+NlIIVzD4autRn+U5geYzDaZIWfmx95gwCPK2VVw1IDExA+\naQiZi4g1JviUB97E92rZzX+Ai4GYk+CKQTxAiZPZ2M9gRFLrjKIGbRu7FaL+9lwU\nWnax4HJZ/cdVUtVp8VgaAOy7qvl5WGZ4eLopLhMkW3RyPiFr4+M3vNJocqU=\n-----END CERTIFICATE-----" + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: estProfileData + }); + + expect(result.enrollmentType).toBe(EnrollmentType.EST); + expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, + hashedPassphrase: "mocked-hash", + encryptedCaChain: Buffer.from("encrypted-data") + }, + undefined + ); + }); + + it("should handle profile slug uniqueness validation", async () => { + vi.clearAllMocks(); + + const duplicateSlugData = { + slug: "different-profile-name", + description: "Profile with duplicate slug", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: duplicateSlugData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate auto-renewal configuration", async () => { + const autoRenewData = { + slug: "auto-renew-profile", + description: "Profile with auto-renewal", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 7 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + apiConfigId: "api-config-123", + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: autoRenewData + }); + + expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + autoRenew: true, + autoRenewDays: 7 + }, + undefined + ); + expect(result).toBeDefined(); + }); + }); + + describe("profile lifecycle management", () => { + it("should handle profile updates with enrollment type changes", async () => { + const currentProfile = { + ...sampleProfile, + enrollmentType: EnrollmentType.API, + apiConfigId: "api-config-123", + estConfigId: null + }; + + const updateToEst = { + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(currentProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...currentProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }); + + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateToEst + }); + + expect(mockEstEnrollmentConfigDAL.create).not.toHaveBeenCalled(); + expect(result.enrollmentType).toBe(EnrollmentType.EST); + }); + + it("should allow deletion of profiles", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + }); + + describe("certificate management", () => { + it("should filter certificates by status", async () => { + const activeCerts = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(activeCerts); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + status: "active" + }); + + expect(result).toEqual(activeCerts); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: "active", + search: undefined + }); + }); + + it("should search certificates by common name", async () => { + const searchResults = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "api.example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(searchResults); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + search: "api.example" + }); + + expect(result).toEqual(searchResults); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: "api.example" + }); + }); + }); + + describe("metrics and monitoring", () => { + it("should calculate profile metrics correctly", async () => { + const detailedMetrics = { + profileId: "profile-123", + totalCertificates: 50, + activeCertificates: 40, + expiredCertificates: 5, + expiringCertificates: 3, + revokedCertificates: 2 + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics); + + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123", + expiringDays: 14 + }); + + expect(result).toEqual(detailedMetrics); + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14); + }); + + it("should handle zero certificate metrics", async () => { + const emptyMetrics = { + profileId: "profile-123", + totalCertificates: 0, + activeCertificates: 0, + expiredCertificates: 0, + expiringCertificates: 0, + revokedCertificates: 0 + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics); + + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result.totalCertificates).toBe(0); + expect(result.activeCertificates).toBe(0); + }); + }); + + describe("error scenarios", () => { + it("should handle database connection errors gracefully", async () => { + (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed")); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow("Database connection failed"); + }); + + it("should handle invalid template reference during profile creation", async () => { + const profileData = { + slug: "invalid-template-profile", + description: "Profile with invalid template", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "nonexistent-template", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: profileData + }) + ).rejects.toThrow(NotFoundError); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("nonexistent-template"); + }); + + it("should handle concurrent profile creation conflicts", async () => { + const conflictingData = { + slug: "concurrent-profile", + description: "Profile created concurrently", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockRejectedValue(new Error("Unique constraint violation")); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: conflictingData + }) + ).rejects.toThrow("Unique constraint violation"); + }); + }); + + describe("permission and security", () => { + it("should validate project ownership for cross-project template access", async () => { + const crossProjectData = { + slug: "cross-project-profile", + description: "Profile using template from different project", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-456", + apiConfig: { + autoRenew: false + } + }; + + const foreignTemplate = { + id: "template-456", + projectId: "different-project-456", + slug: "foreign-template" + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: crossProjectData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate slug format constraints", async () => { + const invalidSlugData = { + slug: "invalid-slug-profile", + description: "Profile with invalid slug format", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + slug: invalidSlugData.slug, + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidSlugData + }); + + expect(result.slug).toBe(invalidSlugData.slug); + }); + }); + }); + + describe("getEstConfigurationByProfile", () => { + it("should return EST configuration for valid EST profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + const result = await service.getEstConfigurationByProfile({ ...mockActor, profileId }); + + expect(result).toEqual({ + orgId: "project-123", + isEnabled: true, + caChain: "mock-ca-chain", + disableBootstrapCertValidation: false, + hashedPassphrase: "" + }); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when profile is not configured for EST enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.API, // Wrong enrollment type + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + ForbiddenRequestError + ); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "Profile is not configured for EST enrollment" + ); + }); + + it("should throw NotFoundError when EST configuration is missing", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: undefined // Missing EST config + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "EST configuration not found for this profile" + ); + }); + }); +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts new file mode 100644 index 000000000..c43dee889 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -0,0 +1,824 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +const validateAndEncryptPemCaChain = async ( + caChain: string, + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates || certificates.length === 0) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + return { encryptedCaChain: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` }); + } +}; + +const decryptCaChain = async ( + encryptedCaChain: Buffer, + projectId: string, + kmsService: Pick, + projectDAL: Pick +): Promise => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: encryptedCaChain + }); + + return decryptedCaChain.toString(); + } catch (error) { + throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` }); + } +}; + +export type TCertificateProfileCreateData = Omit & { + estConfig?: TEstConfigData; + apiConfig?: TApiConfigData; +}; + +type TCertificateProfileServiceFactoryDep = { + certificateProfileDAL: TCertificateProfileDALFactory; + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; + estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + permissionService: Pick; + kmsService: Pick; + projectDAL: Pick; +}; + +export type TCertificateProfileServiceFactory = ReturnType; + +const convertDalToService = (dalResult: Record): TCertificateProfile => { + return { + ...dalResult, + enrollmentType: dalResult.enrollmentType as EnrollmentType + } as TCertificateProfile; +}; + +export const certificateProfileServiceFactory = ({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL +}: TCertificateProfileServiceFactoryDep) => { + const createProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionSub.CertificateProfiles + ); + + // Validate that certificate template exists and belongs to the same project + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + // Check for slug uniqueness within project + const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId); + if (existingSlugProfile) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + + // Validate enrollment configuration requirements + if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { + throw new ForbiddenRequestError({ + message: "EST enrollment requires EST configuration" + }); + } + if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { + throw new ForbiddenRequestError({ + message: "API enrollment requires API configuration" + }); + } + + // Create enrollment configs and profile + const profile = await certificateProfileDAL.transaction(async (tx) => { + let estConfigId: string | null = null; + let apiConfigId: string | null = null; + + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { + const appCfg = getConfig(); + // Hash the passphrase + const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS); + + let encryptedCaChainBuffer: Buffer | null = null; + if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + data.estConfig.caChain, + projectId, + kmsService, + projectDAL + ); + encryptedCaChainBuffer = encryptedCaChain; + } + + const estConfig = await estEnrollmentConfigDAL.create( + { + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation, + hashedPassphrase, + encryptedCaChain: encryptedCaChainBuffer + }, + tx + ); + estConfigId = estConfig.id; + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { + const apiConfig = await apiEnrollmentConfigDAL.create( + { + autoRenew: data.apiConfig.autoRenew, + autoRenewDays: data.apiConfig.autoRenewDays + }, + tx + ); + apiConfigId = apiConfig.id; + } + + // Create the profile with the created config IDs + const { estConfig, apiConfig, ...profileData } = data; + const profileResult = await certificateProfileDAL.create( + { + ...profileData, + projectId, + estConfigId, + apiConfigId + }, + tx + ); + + return profileResult; + }); + + return convertDalToService(profile); + }; + + const updateProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + data: TCertificateProfileUpdate; + }): Promise => { + const existingProfile = await certificateProfileDAL.findById(profileId); + if (!existingProfile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingProfile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionSub.CertificateProfiles + ); + + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== existingProfile.projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + if (data.slug && data.slug !== existingProfile.slug) { + const conflictingProfile = await certificateProfileDAL.findBySlugAndProjectId( + data.slug, + existingProfile.projectId + ); + if (conflictingProfile && conflictingProfile.id !== profileId) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + } + + const { estConfig, apiConfig, ...profileUpdateData } = data; + + const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { + if (estConfig && existingProfile.estConfigId) { + const updateData: { + disableBootstrapCaValidation: boolean; + hashedPassphrase?: string; + encryptedCaChain?: Buffer; + } = { + disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false + }; + + if (estConfig.passphrase) { + updateData.hashedPassphrase = await crypto + .hashing() + .createHash(estConfig.passphrase, getConfig().SALT_ROUNDS); + } + + if (estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + estConfig.caChain, + existingProfile.projectId, + kmsService, + projectDAL + ); + updateData.encryptedCaChain = encryptedCaChain; + } + + await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx); + } + + if (apiConfig && existingProfile.apiConfigId) { + await apiEnrollmentConfigDAL.updateById( + existingProfile.apiConfigId, + { + autoRenew: apiConfig.autoRenew, + autoRenewDays: apiConfig.autoRenewDays + }, + tx + ); + } + + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); + return profileResult; + }); + + return convertDalToService(updatedProfile); + }; + + const getProfileById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const converted = convertDalToService(profile); + + if (includeMetrics) { + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return { + ...converted, + metrics + }; + } + + return converted; + }; + + const getProfileByIdWithConfigs = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + if (profile.estConfig && profile.estConfig.caChain) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + profile.projectId, + kmsService, + projectDAL + ); + profile.estConfig.caChain = decryptedCaChain; + } else { + profile.estConfig.caChain = ""; + } + } catch (error) { + profile.estConfig.caChain = ""; + } + } + + return { + ...profile, + enrollmentType: profile.enrollmentType as EnrollmentType + }; + }; + + const getProfileBySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + return convertDalToService(profile); + }; + + const listProfiles = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise<{ + profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profiles = await certificateProfileDAL.findByProjectId(projectId, { + offset, + limit, + search, + enrollmentType, + caId, + includeMetrics, + expiringDays + }); + + const totalCount = await certificateProfileDAL.countByProjectId(projectId, { + search, + enrollmentType, + caId + }); + + const convertedProfiles = await Promise.all( + profiles.map(async (profile) => { + const profileWithConfigs = profile as TCertificateProfileWithConfigs; + + let decryptedEstConfig = profileWithConfigs.estConfig; + if (decryptedEstConfig && profileWithConfigs.estConfigId) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + projectId, + kmsService, + projectDAL + ); + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: decryptedCaChain + }; + } else if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } catch (error) { + if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } + } + + const converted = convertDalToService(profileWithConfigs); + let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { + ...converted, + estConfig: decryptedEstConfig, + apiConfig: profileWithConfigs.apiConfig + }; + + if (includeMetrics) { + const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; + result = { + ...result, + metrics: { + profileId: converted.id, + totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), + activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), + expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), + expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) + } + }; + } + + return result; + }) + ); + + return { + profiles: convertedProfiles, + totalCount + }; + }; + + const deleteProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionSub.CertificateProfiles + ); + + const deletedProfile = await certificateProfileDAL.deleteById(profileId); + if (!deletedProfile) { + throw new NotFoundError({ message: "Failed to delete certificate profile" }); + } + return convertDalToService(deletedProfile); + }; + + const getProfileCertificates = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + offset = 0, + limit = 20, + status, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, { + offset, + limit, + status, + search + }); + + return certificates; + }; + + const getProfileMetrics = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + expiringDays?: number; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return metrics; + }; + + const getEstConfigurationByProfile = async ( + params: + | { + profileId: string; + isInternal: true; + } + | { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + profileId: string; + isInternal?: false; + } + ) => { + const { profileId, isInternal = false } = params; + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (!isInternal) { + const { actor, actorId, actorAuthMethod, actorOrgId } = params as { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }; + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for EST enrollment" + }); + } + + if (!profile.estConfig) { + throw new NotFoundError({ message: "EST configuration not found for this profile" }); + } + + return { + orgId: profile.projectId, + isEnabled: true, + caChain: profile.estConfig.caChain, + disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation, + hashedPassphrase: profile.estConfig.passphrase + }; + }; + + return { + createProfile, + updateProfile, + getProfileById, + getProfileByIdWithConfigs, + getProfileBySlug, + listProfiles, + deleteProfile, + getProfileCertificates, + getProfileMetrics, + getEstConfigurationByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts new file mode 100644 index 000000000..a6d53a0f3 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -0,0 +1,86 @@ +import { + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate +} from "@app/db/schemas/pki-certificate-profiles"; + +export enum EnrollmentType { + API = "api", + EST = "est" +} + +export type TCertificateProfile = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileInsert = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileUpdate = Omit & { + enrollmentType?: EnrollmentType; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TCertificateProfileWithConfigs = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; + metrics?: TCertificateProfileMetrics; +}; + +export interface TCertificateProfileMetrics { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +} + +export interface TCertificateProfileCertificate { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: Date; + notAfter: Date; + revokedAt: Date | null; + createdAt: Date; +} + +export type TCertificateProfileWithRawMetrics = TCertificateProfile & { + total_certificates?: string; + active_certificates?: string; + expired_certificates?: string; + expiring_certificates?: string; + revoked_certificates?: string; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts new file mode 100644 index 000000000..3b935f26a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts @@ -0,0 +1,244 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiCertificateTemplatesV2Insert } from "@app/db/schemas/pki-certificate-templates-v2"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update +} from "./certificate-template-v2-types"; + +export type TCertificateTemplateV2DALFactory = ReturnType; + +interface CountResult { + count: string; +} + +export const certificateTemplateV2DALFactory = (db: TDbClient) => { + const certificateTemplateV2Orm = ormify(db, TableName.PkiCertificateTemplateV2); + + const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => { + const serialized = { ...data } as Record; + + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + + jsonFields.forEach((field) => { + const value = serialized[field]; + if (value !== undefined && typeof value !== "string") { + serialized[field] = JSON.stringify(value); + } + }); + + return serialized; + }; + + const parseJsonFields = (raw: Record): TCertificateTemplateV2 => { + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + const parsed = { ...raw } as Record; + + jsonFields.forEach((field) => { + const value = raw[field]; + if (value !== null && value !== undefined) { + if (typeof value === "string") { + try { + parsed[field] = JSON.parse(value); + } catch (error) { + throw new Error( + `Invalid JSON in field '${field}': ${error instanceof Error ? error.message : "Parse error"}` + ); + } + } else { + parsed[field] = value; + } + } else { + parsed[field] = undefined; + } + }); + + return parsed as TCertificateTemplateV2; + }; + + const create = async (data: TCertificateTemplateV2Insert, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .insert(serializedData as TPkiCertificateTemplatesV2Insert) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + throw new Error("Failed to create certificate template v2"); + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate template v2" }); + } + }; + + const updateById = async (id: string, data: TCertificateTemplateV2Update, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .update(serializedData) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate template v2" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .del() + .returning("*")) as Record[]; + + return certificateTemplateV2; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate template v2" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2).where({ id }).first()) as + | Record + | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { offset = 0, limit = 20, search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit); + + return certificateTemplatesV2.map((template: Record) => parseJsonFields(template)); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate templates v2 by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate templates v2 by project id" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ name, projectId }) + .first()) as Record | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" }); + } + }; + + const isTemplateInUse = async (templateId: string, tx?: Knex) => { + try { + const profileCount = await (tx || db)(TableName.PkiCertificateProfile) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const profileUsage = parseInt((profileCount as unknown as CountResult).count || "0", 10) > 0; + + const certCount = await (tx || db)(TableName.Certificate) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const certUsage = parseInt((certCount as unknown as CountResult).count || "0", 10) > 0; + + return profileUsage || certUsage; + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" }); + } + }; + + const getProfilesUsingTemplate = async (templateId: string, tx?: Knex) => { + try { + const profiles = await (tx || db)(TableName.PkiCertificateProfile) + .select("id", "slug", "description") + .where({ certificateTemplateId: templateId }); + + return profiles as Array<{ id: string; slug: string; description?: string }>; + } catch (error) { + throw new DatabaseError({ error, name: "Get profiles using certificate template v2" }); + } + }; + + return { + ...certificateTemplateV2Orm, + create, + updateById, + deleteById, + findById, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + isTemplateInUse, + getProfilesUsingTemplate + }; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts new file mode 100644 index 000000000..9fcc51a57 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts @@ -0,0 +1,181 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .regex(new RE2("^\\d+[dhmy]$"), { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + }) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one signature algorithm must be provided") + .optional(), + keyAlgorithm: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one key algorithm must be provided") + .optional() +}); + +export const certificateTemplateV2ResponseSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid("Project ID must be valid"), + name: z + .string() + .trim() + .min(1, "Template name is required") + .max(255, "Template name must be less than 255 characters") + .regex(new RE2("^[a-zA-Z0-9-_]+$"), "Template name must contain only letters, numbers, hyphens, and underscores"), + description: z.string().trim().max(1000, "Description must be less than 1000 characters").nullable().optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const certificateRequestSchema = z.object({ + commonName: z + .string() + .trim() + .min(1, "Common name cannot be empty") + .max(64, "Common name must be less than 64 characters") + .optional(), + organization: z + .string() + .trim() + .min(1, "Organization cannot be empty") + .max(64, "Organization must be less than 64 characters") + .optional(), + country: z + .string() + .trim() + .min(2, "Country code must be 2 characters") + .max(2, "Country code must be 2 characters") + .optional(), + keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).min(1, "At least one key usage must be provided").optional(), + extendedKeyUsages: z + .array(z.nativeEnum(CertExtendedKeyUsageType)) + .min(1, "At least one extended key usage must be provided") + .optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: sanTypeSchema, + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .min(1, "At least one SAN must be provided") + .optional(), + validity: z + .object({ + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .regex(new RE2("^\\d+[dhmy]$"), "TTL must be in format like '365d', '12m', '1y', or '24h'") + }) + .optional(), + signatureAlgorithm: z.string().trim().min(1, "Signature algorithm cannot be empty").optional(), + keyAlgorithm: z.string().trim().min(1, "Key algorithm cannot be empty").optional() +}); + +export const validateCertificateRequestSchema = z.object({ + templateId: z.string().uuid(), + request: certificateRequestSchema +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts new file mode 100644 index 000000000..daacc5dde --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -0,0 +1,1745 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + certificateTemplateV2ServiceFactory, + TCertificateTemplateV2ServiceFactory +} from "./certificate-template-v2-service"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TTemplateV2Policy +} from "./certificate-template-v2-types"; + +enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +describe("CertificateTemplateV2Service", () => { + let service: TCertificateTemplateV2ServiceFactory; + + const mockCertificateTemplateV2DAL = { + findBySlugAndProjectId: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + getProfilesUsingTemplate: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + insertMany: vi.fn(), + batchInsert: vi.fn(), + upsert: vi.fn(), + countDocuments: vi.fn() + } as any; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const samplePolicy: TTemplateV2Policy = { + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com", "example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example Inc", "Example Corp"], + denied: ["Malicious Corp"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com", "*.api.example.com"], + required: ["api.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["admin@example.com"], + denied: ["blocked@example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [CertKeyUsageType.DATA_ENCIPHERMENT] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [CertExtendedKeyUsageType.CLIENT_AUTH] + }, + validity: { + max: "90d" + }, + algorithms: { + signature: ["SHA256-RSA", "SHA256-ECDSA"], + keyAlgorithm: ["RSA-2048", "RSA-4096", "ECDSA-P256"] + } + }; + + const sampleTemplate: TCertificateTemplateV2 = { + id: "template-123", + projectId: "project-123", + name: "web-server-template", + description: "Template for web server certificates", + ...samplePolicy, + createdAt: new Date(), + updatedAt: new Date() + }; + + const mockPermission = { + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn().mockReturnValue(null), + rulesFor: vi.fn().mockReturnValue([]), + rules: [], + detectSubjectType: vi.fn().mockReturnValue("certificate-templates-v2"), + modelName: "certificate-templates-v2", + throwUnlessCan: vi.fn(), + unlessCan: vi.fn().mockReturnValue({ throwUnlessCan: vi.fn() }) + }; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: mockPermission + }) + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + mockPermissionService.getProjectPermission.mockResolvedValue({ + permission: mockPermission + }); + + mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null); + mockCertificateTemplateV2DAL.findBySlugAndProjectId.mockResolvedValue(null); + + service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createTemplateV2", () => { + const createData: Omit = { + name: "test-template", + description: "Test description", + ...samplePolicy + }; + + it("should create template with valid policy", async () => { + mockCertificateTemplateV2DAL.create.mockResolvedValue(sampleTemplate); + + const result = await service.createTemplateV2({ + ...mockActor, + projectId: "project-123", + data: createData + }); + + expect(mockCertificateTemplateV2DAL.create).toHaveBeenCalledWith({ + ...createData, + projectId: "project-123", + name: expect.any(String) + }); + expect(result).toEqual(sampleTemplate); + }); + + // Previously tested service-level validations that are now schema-level: + // - Missing attributes validation (now mandatory in schema) + // - Missing key usages validation (now mandatory in schema) + // - Default signature algorithm not in allowed list (now schema-level validation) + // - Default key algorithm not in allowed list (now schema-level validation) + }); + + describe("updateTemplateV2", () => { + it("should update template with valid data", async () => { + const updateData = { name: "updated-template-name" }; + const updatedTemplate = { ...sampleTemplate, ...updateData }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.updateById.mockResolvedValue(updatedTemplate); + + const result = await service.updateTemplateV2({ + ...mockActor, + templateId: "template-123", + data: updateData + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", { + ...updateData, + name: expect.any(String) + }); + expect(result).toEqual(updatedTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.updateTemplateV2({ + ...mockActor, + templateId: "nonexistent-template", + data: { name: "updated-name" } + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getTemplateV2ById", () => { + it("should return template when found", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + + const result = await service.getTemplateV2ById({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.getTemplateV2ById({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listTemplatesV2", () => { + it("should return templates list with pagination", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + const result = await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + offset: 0, + limit: 20 + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: undefined + }); + expect(result).toEqual({ templates, totalCount }); + }); + + it("should handle search parameter", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + search: "web server" + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: "web server" + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: "web server" + }); + }); + }); + + describe("deleteTemplateV2", () => { + it("should delete template when not in use", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(false); + mockCertificateTemplateV2DAL.deleteById.mockResolvedValue(sampleTemplate); + + const result = await service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.isTemplateInUse).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template is in use", async () => { + const mockProfiles = [ + { id: "profile-1", slug: "web-server-profile", description: "Web server certificate profile" }, + { id: "profile-2", slug: "api-gateway-profile", description: "API gateway certificate profile" } + ]; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(true); + mockCertificateTemplateV2DAL.getProfilesUsingTemplate.mockResolvedValue(mockProfiles); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }) + ).rejects.toThrow(ForbiddenRequestError); + + expect(mockCertificateTemplateV2DAL.getProfilesUsingTemplate).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).not.toHaveBeenCalled(); + }); + }); + + describe("validateCertificateRequest", () => { + const validRequest: TCertificateRequest = { + commonName: "api.example.com", + organization: "Example Inc", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + beforeEach(() => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + }); + + it("should validate valid certificate request", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect(service.validateCertificateRequest("nonexistent-template", validRequest)).rejects.toThrow( + NotFoundError + ); + }); + + it("should validate allowed attribute values against pattern", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should detect attribute values that don't match allowed patterns", async () => { + const invalidRequest = { ...validRequest, commonName: "forbidden.com" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'forbidden.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should detect denied attribute values", async () => { + const templateWithDeny = { + ...sampleTemplate, + subject: [ + ...sampleTemplate.subject!, + { + type: CertSubjectAttributeType.ORGANIZATION, + denied: ["Forbidden Corp"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDeny); + + const invalidRequest = { ...validRequest, organization: "Forbidden Corp" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("organization value 'Forbidden Corp' is denied by template policy"); + }); + + it("should detect missing required key usages", async () => { + const invalidRequest = { ...validRequest, keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required key usages: key_encipherment"); + }); + + it("should detect invalid key usages", async () => { + const invalidRequest = { + ...validRequest, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, "invalid_usage"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: invalid_usage"); + }); + + it("should detect missing required extended key usages", async () => { + const invalidRequest = { ...validRequest, extendedKeyUsages: [] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required extended key usages: server_auth"); + }); + + it("should detect invalid extended key usages", async () => { + const invalidRequest = { + ...validRequest, + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, "invalid_eku"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid extended key usages: invalid_eku"); + }); + + it("should detect missing required SAN entries", async () => { + const invalidRequest = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Required email SAN matching pattern 'admin@example.com' not found in request"); + }); + + it("should validate SAN values against allowed patterns", async () => { + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "forbidden.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "dns_name SAN value 'forbidden.com' does not match allowed patterns: *.example.com, *.api.example.com" + ); + }); + + it("should detect denied SAN values", async () => { + const templateWithDenySan = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["forbidden@example.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDenySan); + + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "forbidden@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'forbidden@example.com' found in request"); + }); + + it("should detect invalid signature algorithm", async () => { + const invalidRequest = { ...validRequest, signatureAlgorithm: "MD5-RSA" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Signature algorithm 'MD5-RSA' is not allowed by template policy"); + }); + + it("should detect invalid key algorithm", async () => { + const invalidRequest = { ...validRequest, keyAlgorithm: "RSA-1024" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key algorithm 'RSA-1024' is not allowed by template policy"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const invalidRequest = { ...validRequest, validity: { ttl: "180d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const templateWithMaxDuration = { + ...sampleTemplate, + validity: { + max: "90d" + } + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMaxDuration); + + const invalidRequest = { ...validRequest, validity: { ttl: "100d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should handle various TTL formats", async () => { + const testCases = [ + { ttl: "24h", shouldBeValid: true }, + { ttl: "30d", shouldBeValid: true }, + { ttl: "90d", shouldBeValid: true }, + { ttl: "3m", shouldBeValid: true }, + { ttl: "1y", shouldBeValid: false }, + { ttl: "invalid", shouldThrow: true } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { ...validRequest, validity: { ttl: testCase.ttl } }; + + if (testCase.shouldThrow) { + await expect(service.validateCertificateRequest("template-123", request)).rejects.toThrow( + `Invalid TTL format: ${testCase.ttl}` + ); + } else { + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }) + ); + }); + + it("should allow optional key usages and extended key usages", async () => { + const requestWithOptionalUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithOptionalUsages); + + expect(result.isValid).toBe(true); + }); + + it("should handle camelCase key usage mapping correctly", async () => { + const templateWithOptionalUsages = { + ...sampleTemplate, + keyUsages: { + requiredUsages: { + all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT] + }, + optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] } + }, + extendedKeyUsages: { + requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] }, + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] } + } + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); + + const requestWithCamelCaseUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should validate wildcard patterns in allow attributes", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithWildcard = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithWildcard); + expect(result.isValid).toBe(true); + }); + + it("should reject wildcard patterns that don't match", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithNonMatchingWildcard = { + commonName: "api.notexample.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithNonMatchingWildcard); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should require attribute value when allow rule exists", async () => { + const emptyAllowTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyAllowTemplate); + + const requestWithoutCommonName = { + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithoutCommonName); + expect(result.isValid).toBe(true); + }); + + it("should prevent certificates from including denied SANs", async () => { + const denyTemplate = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibitedSan = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + }); + + describe("comprehensive template validation scenarios", () => { + it("should handle template with minimal required fields only", async () => { + const minimalTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [], + validity: { + max: "30d" + }, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(minimalTemplate); + + const minimalReq = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + validity: { ttl: "15d" } + }; + + const result = await service.validateCertificateRequest("template-123", minimalReq); + expect(result.isValid).toBe(true); + }); + + it("should handle template with all fields set to allow", async () => { + const allowTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["*"] + } + ], + keyUsages: { + allowed: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(allowTemplate); + + const emptyRequest = { + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", emptyRequest); + expect(result.isValid).toBe(true); + }); + + it("should handle template with SAN fields denied", async () => { + const denyTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*"] + }, + { + type: CertSubjectAlternativeNameType.URI, + denied: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibited = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }, + { type: CertSubjectAlternativeNameType.URI as const, value: "https://example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibited); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + expect(result.errors).toContain("uri SAN matching denied pattern 'https://example.com' found in request"); + }); + + it("should validate complex attribute value constraints", async () => { + const constrainedTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com", "test.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(constrainedTemplate); + + const validConstrainedRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validConstrainedRequest); + expect(validResult.isValid).toBe(true); + + const invalidConstrainedRequest = { + commonName: "forbidden.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest); + expect(invalidResult.isValid).toBe(false); + expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list"); + }); + + it("should validate SAN value constraints with multiple types", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["192.168.1.*"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.100" }, + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validSanRequest); + expect(validResult.isValid).toBe(true); + + const missingSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + validity: { ttl: "30d" } + }; + + const missingResult = await service.validateCertificateRequest("template-123", missingSanRequest); + expect(missingResult.isValid).toBe(false); + expect(missingResult.errors).toContain( + "Required email SAN matching pattern '*@example.com' not found in request" + ); + }); + + it("should validate key usage combinations thoroughly", async () => { + const keyUsageTemplate = { + ...sampleTemplate, + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [ + CertExtendedKeyUsageType.SERVER_AUTH, + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(keyUsageTemplate); + + const minimalUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const minimalResult = await service.validateCertificateRequest("template-123", minimalUsageRequest); + expect(minimalResult.isValid).toBe(true); + + const extendedUsageRequest = { + commonName: "example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH], + validity: { ttl: "30d" } + }; + + const extendedResult = await service.validateCertificateRequest("template-123", extendedUsageRequest); + expect(extendedResult.isValid).toBe(true); + + const forbiddenUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, CertKeyUsageType.CRL_SIGN], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const forbiddenResult = await service.validateCertificateRequest("template-123", forbiddenUsageRequest); + expect(forbiddenResult.isValid).toBe(false); + expect(forbiddenResult.errors).toContain("Invalid key usages: crl_sign"); + }); + + it("should validate algorithm constraints thoroughly", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512"], + keyAlgorithm: ["RSA-2048", "RSA-4096"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const validAlgoRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validAlgoRequest); + expect(validResult.isValid).toBe(true); + + const invalidSigRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "RSA_2048", + validity: { ttl: "30d" } + }; + + const invalidSigResult = await service.validateCertificateRequest("template-123", invalidSigRequest); + expect(invalidSigResult.isValid).toBe(false); + expect(invalidSigResult.errors).toContain( + "Signature algorithm 'ECDSA-SHA256' is not allowed by template policy" + ); + + const invalidKeyRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "EC_prime256v1", + validity: { ttl: "30d" } + }; + + const invalidKeyResult = await service.validateCertificateRequest("template-123", invalidKeyRequest); + expect(invalidKeyResult.isValid).toBe(false); + expect(invalidKeyResult.errors).toContain("Key algorithm 'EC_prime256v1' is not allowed by template policy"); + }); + + it("should validate validity period edge cases", async () => { + const validityTemplate = { + ...sampleTemplate, + validity: { + max: "365d" + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(validityTemplate); + + const testCases = [ + { ttl: "1d", shouldBeValid: true, description: "minimum duration" }, + { ttl: "365d", shouldBeValid: true, description: "maximum duration" }, + { ttl: "366d", shouldBeValid: false, description: "exceeds maximum" }, + { ttl: "23h", shouldBeValid: true, description: "valid duration under max" }, + { ttl: "24h", shouldBeValid: true, description: "exactly 1 day in hours" }, + { ttl: "8760h", shouldBeValid: true, description: "exactly 365 days in hours" }, + { ttl: "12m", shouldBeValid: true, description: "exactly 365 days in months" }, + { ttl: "1y", shouldBeValid: true, description: "exactly 365 days in years" } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: testCase.ttl } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + } + }) + ); + }); + }); + + describe("unlisted field validation", () => { + it("should reject requests with unlisted subject attributes", async () => { + const templateWithLimitedAttributes = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedAttributes); + + const requestWithUnlistedKeyUsage = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.ENCIPHER_ONLY], // ENCIPHER_ONLY not allowed + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedKeyUsage); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: encipher_only"); + }); + + it("should reject requests with unlisted SAN types", async () => { + const templateWithLimitedSans = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedSans); + + const requestWithUnlistedSan = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" } // This should be rejected + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN is not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with unlisted key usages when template doesn't define any", async () => { + const templateWithoutKeyUsages = { + ...sampleTemplate, + keyUsages: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutKeyUsages); + + const requestWithKeyUsages = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithKeyUsages); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key usages are not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with algorithms when template doesn't define any", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", // This should be rejected + keyAlgorithm: "RSA-2048", // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA-2048' is not allowed by template policy (not defined in template)" + ); + }); + }); + + describe("comprehensive subject attribute validation", () => { + it("should validate all subject attribute types", async () => { + const comprehensiveTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + required: ["*"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(comprehensiveTemplate); + + const validComprehensiveRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validComprehensiveRequest); + expect(validResult.isValid).toBe(true); + + // Test missing mandatory field + const missingCommonNameRequest = { + ...validComprehensiveRequest, + commonName: undefined + }; + + const missingCommonNameResult = await service.validateCertificateRequest( + "template-123", + missingCommonNameRequest + ); + expect(missingCommonNameResult.isValid).toBe(false); + expect(missingCommonNameResult.errors).toContain("Missing required common_name attribute"); + }); + }); + + describe("improved wildcard pattern validation", () => { + it("should handle complex wildcard patterns", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["v1.api.example.com", "service-auth.internal.com", "exact-match.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + // Valid patterns + { commonName: "v1.api.example.com", shouldBeValid: true }, + { commonName: "service-auth.internal.com", shouldBeValid: true }, + { commonName: "exact-match.com", shouldBeValid: true }, + // Invalid patterns + { commonName: "api.example.com", shouldBeValid: false }, // Missing subdomain for *.api.example.com + { commonName: "service.internal.com", shouldBeValid: false }, // Missing dash and wildcard part + { commonName: "not-exact-match.com", shouldBeValid: false } + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect( + result.errors.some( + (error) => error.includes("does not match allowed patterns") || error.includes("not in allowed values") + ) + ).toBe(true); + } + } + }); + + it("should handle special regex characters in wildcard patterns", async () => { + const specialCharTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(specialCharTemplate); + + const testCases = [ + { commonName: "app.test-site.com", shouldBeValid: true }, + { commonName: "service[1-9].example.com", shouldBeValid: true }, // Should match exactly, not as regex + { commonName: "service1.example.com", shouldBeValid: false }, // Should not match as regex pattern + { commonName: "api.{prod,staging}.com", shouldBeValid: true }, // Should match exactly + { commonName: "api.prod.com", shouldBeValid: false } // Should not match as regex pattern + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }); + }); + + describe("algorithm validation", () => { + it("should validate signature algorithm constraints", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"], + keyAlgorithm: ["RSA_2048", "RSA_4096", "EC_prime256v1"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const testCases = [ + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048", + shouldBeValid: true, + description: "allowed algorithms" + }, + { + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + shouldBeValid: true, + description: "different allowed algorithms" + }, + { + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "EC_prime256v1", + shouldBeValid: true, + description: "ECDSA algorithms" + }, + { + signatureAlgorithm: "MD5-RSA", + keyAlgorithm: "RSA_2048", + shouldBeValid: false, + description: "disallowed signature algorithm" + }, + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_1024", + shouldBeValid: false, + description: "disallowed key algorithm" + }, + { + signatureAlgorithm: undefined, + keyAlgorithm: undefined, + shouldBeValid: true, + description: "no algorithms specified (should use defaults)" + } + ]; + + for (const testCase of testCases) { + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" } + ], + signatureAlgorithm: testCase.signatureAlgorithm, + keyAlgorithm: testCase.keyAlgorithm + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((error) => error.includes("algorithm") || error.includes("Algorithm"))).toBe( + true + ); + } + } + }); + + it("should validate when no algorithm constraints are defined but no algorithms in request", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined, + sans: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject algorithms when template has no algorithm constraints", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA_2048' is not allowed by template policy (not defined in template)" + ); + }); + + it("should allow requests that match any of multiple attribute policies of same type", async () => { + const multipleAttributePoliciesTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multipleAttributePoliciesTemplate); + + // Test case that matches first policy + const requestMatchingFirstPolicy = { + commonName: "test.infisical.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", requestMatchingFirstPolicy); + expect(result1.isValid).toBe(true); + + // Test case that matches second policy + const requestMatchingSecondPolicy = { + commonName: "test.infisical2.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical2.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result2 = await service.validateCertificateRequest("template-123", requestMatchingSecondPolicy); + expect(result2.isValid).toBe(true); + + // Test case that matches neither policy + const requestMatchingNeitherPolicy = { + commonName: "test.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result3 = await service.validateCertificateRequest("template-123", requestMatchingNeitherPolicy); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "common_name value 'test.example.com' does not match allowed patterns: *.infisical.com, *.infisical2.com" + ); + }); + }); + + describe("New validation logic with allow/deny/require", () => { + it("should validate complex attribute value constraints", async () => { + const complexTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example*"] + }, + { + type: CertSubjectAttributeType.COUNTRY, + denied: ["XX"] + } + ], + sans: [] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(complexTemplate); + + const validComplexRequest = { + commonName: "api.example.com", + organization: "Example Corp", + country: "US", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validComplexRequest); + expect(result1.isValid).toBe(true); + + const invalidCountryRequest = { ...validComplexRequest, country: "XX" }; + const result2 = await service.validateCertificateRequest("template-123", invalidCountryRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("country value 'XX' is denied by template policy"); + const invalidOrgRequest = { ...validComplexRequest, organization: "Different Corp" }; + const result3 = await service.validateCertificateRequest("template-123", invalidOrgRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "organization value 'Different Corp' does not match allowed patterns: Example*" + ); + }); + + it("should handle SAN allow/deny/require logic", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + denied: ["192.168.1.*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validSanRequest); + expect(result1.isValid).toBe(true); + + const missingEmailRequest = { + ...validSanRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result2 = await service.validateCertificateRequest("template-123", missingEmailRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("Required email SAN matching pattern '*@example.com' not found in request"); + + const deniedIpRequest = { + ...validSanRequest, + subjectAlternativeNames: [ + ...validSanRequest.subjectAlternativeNames, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.100" } + ] + }; + + const result3 = await service.validateCertificateRequest("template-123", deniedIpRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain("ip_address SAN matching denied pattern '192.168.1.100' found in request"); + }); + + it("should validate wildcard patterns correctly", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.acme.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.api.acme.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + { cn: "api.acme.com", san: "v1.api.acme.com", shouldPass: true }, + { cn: "www.acme.com", san: "beta.api.acme.com", shouldPass: true }, + { cn: "acme.com", san: "api.acme.com", shouldPass: false }, // Missing subdomain + { cn: "api.notacme.com", san: "v1.api.acme.com", shouldPass: false }, // Wrong domain + { cn: "api.acme.com", san: "api.acme.com", shouldPass: false } // SAN missing required subdomain + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.cn, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: testCase.san }], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldPass); + } + }); + + it("should enforce multiple required SAN types", async () => { + const multiRequiredTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.URI, + required: ["https://*.example.com/*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multiRequiredTemplate); + + const completeRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" }, + { type: CertSubjectAlternativeNameType.URI, value: "https://api.example.com/webhook" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", completeRequest); + expect(result1.isValid).toBe(true); + + const incompleteRequest = { + ...completeRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result2 = await service.validateCertificateRequest("template-123", incompleteRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain( + "Required uri SAN matching pattern 'https://*.example.com/*' not found in request" + ); + }); + }); + }); +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts new file mode 100644 index 000000000..c1942b793 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -0,0 +1,959 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CertSubjectAttributeType } from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update, + TTemplateValidationResult +} from "./certificate-template-v2-types"; + +type TCertificateTemplateV2ServiceFactoryDep = { + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + permissionService: Pick; +}; + +export const certificateTemplateV2ServiceFactory = ({ + certificateTemplateV2DAL, + permissionService +}: TCertificateTemplateV2ServiceFactoryDep) => { + const consolidateAttributeArray = < + T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } + >( + attributes: T[] + ): T[] => { + const consolidated = new Map(); + + attributes.forEach((attr) => { + const existing = consolidated.get(attr.type); + if (existing) { + throw new ForbiddenRequestError({ + message: `Duplicate attribute type '${attr.type}' found in request. Each attribute type must appear only once.` + }); + } else { + consolidated.set(attr.type, attr); + } + }); + + return Array.from(consolidated.values()); + }; + + const parseTTL = (ttl: string): number => { + const regex = new RE2("^(\\d+)([dmyh])$"); + const match = regex.exec(ttl); + if (!match) { + throw new Error(`Invalid TTL format: ${ttl}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + case "m": + return value * 30 * 24 * 60 * 60 * 1000; + case "y": + return value * 365 * 24 * 60 * 60 * 1000; + default: + throw new Error(`Unsupported TTL unit: ${unit}`); + } + }; + + const validateSubjectAttributePolicy = ( + subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!subject || subject.length === 0) return; + + // Validate each subject attribute policy + for (const attr of subject) { + // Ensure at least one field is provided + if (!attr.allowed && !attr.required && !attr.denied) { + throw new ForbiddenRequestError({ + message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value` + }); + } + + // Check for duplicate values within arrays + const arrays = [ + { name: "allowed", values: attr.allowed }, + { name: "required", values: attr.required }, + { name: "denied", values: attr.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'` + }); + } + } + } + } + }; + + const validateSanPolicy = ( + sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!sans || sans.length === 0) return; + + // Validate each SAN policy + for (const san of sans) { + if (!san.allowed && !san.required && !san.denied) { + throw new ForbiddenRequestError({ + message: `SAN type '${san.type}' must have at least one allowed, required, or denied value` + }); + } + + const arrays = [ + { name: "allowed", values: san.allowed }, + { name: "required", values: san.required }, + { name: "denied", values: san.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for SAN type '${san.type}'` + }); + } + } + } + } + }; + + const generateTemplateSlug = (baseName?: string): string => { + if (baseName) { + return slugify(baseName); + } + return slugify(alphaNumericNanoId(12)); + }; + + const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId); + if (!existingTemplate || (templateId && existingTemplate.id === templateId)) { + return desiredSlug; + } + const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`; + const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId); + if (!existingAlternative) { + return alternativeSlug; + } + + const randomSlug = slugify(alphaNumericNanoId(12)); + return randomSlug; + }; + + const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); + }; + + const createWildcardRegex = (pattern: string): RegExp => { + const wildcardRegex = new RE2(/\*/g); + const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__"); + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = withPlaceholder.replace(escapeRegex, "\\$&"); + const placeholderRegex = new RE2(/__WILDCARD__/g); + const regexPattern = escaped.replace(placeholderRegex, ".*"); + return new RE2(`^${regexPattern}$`); + }; + + const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "SHA256-RSA": "RSA-SHA256", + "SHA384-RSA": "RSA-SHA384", + "SHA512-RSA": "RSA-SHA512", + "SHA256-ECDSA": "ECDSA-SHA256", + "SHA384-ECDSA": "ECDSA-SHA384", + "SHA512-ECDSA": "ECDSA-SHA512" + }; + return mapping[templateFormat] || templateFormat; + }; + + const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "RSA-2048": "RSA_2048", + "RSA-3072": "RSA_3072", + "RSA-4096": "RSA_4096", + "ECDSA-P256": "EC_prime256v1", + "ECDSA-P384": "EC_secp384r1", + "ECDSA-P521": "EC_secp521r1" + }; + return mapping[templateFormat] || templateFormat; + }; + + const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => { + if (!keyUsages) return; + + if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: keyUsages.allowed }, + { name: "required", values: keyUsages.required }, + { name: "denied", values: keyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} key usages list` + }); + } + } + } + }; + + const validateExtendedKeyUsagePolicy = (extendedKeyUsages: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }) => { + if (!extendedKeyUsages) return; + + if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Extended key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: extendedKeyUsages.allowed }, + { name: "required", values: extendedKeyUsages.required }, + { name: "denied", values: extendedKeyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} extended key usages list` + }); + } + } + } + }; + + const validateValueAgainstConstraints = ( + value: string, + allowedValues: string[], + fieldName: string + ): { isValid: boolean; error?: string } => { + if (!allowedValues || allowedValues.length === 0) { + return { isValid: true }; + } + + const hasWildcards = allowedValues.some(isWildcardPattern); + + for (const allowedValue of allowedValues) { + if (isWildcardPattern(allowedValue)) { + try { + const regex = createWildcardRegex(allowedValue); + if (regex.test(value)) { + return { isValid: true }; + } + } catch (error) { + if (allowedValue === value) { + return { isValid: true }; + } + } + } else if (allowedValue === value) { + return { isValid: true }; + } + } + + if (hasWildcards) { + return { + isValid: false, + error: `${fieldName} value '${value}' does not match allowed patterns: ${allowedValues.join(", ")}` + }; + } + return { + isValid: false, + error: `${fieldName} value '${value}' is not in allowed values list` + }; + }; + + const validateRequestAgainstPolicy = ( + template: TCertificateTemplateV2, + request: TCertificateRequest + ): TTemplateValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate subject attributes + const subjectPolicies = template.subject; + const requestAttributes = new Map(); + if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName); + if (request.organization) { + requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization); + } + if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country); + + if (subjectPolicies && subjectPolicies.length > 0) { + for (const attrPolicy of subjectPolicies) { + const requestValue = requestAttributes.get(attrPolicy.type); + + if (attrPolicy.required && attrPolicy.required.length > 0) { + if (!requestValue) { + errors.push(`Missing required ${attrPolicy.type} attribute`); + } else { + // Validate that the request value matches the required pattern + const hasMatchingRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + if (!hasMatchingRequired) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}` + ); + } + } + } + + if (requestValue) { + let isValueDenied = false; + if (attrPolicy.denied && attrPolicy.denied.length > 0) { + const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); + if (validation.isValid) { + errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); + isValueDenied = true; + } + } + + if (!isValueDenied && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + let satisfiesRequired = false; + if (attrPolicy.required && attrPolicy.required.length > 0) { + satisfiesRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const allowedValidation = validateValueAgainstConstraints( + requestValue, + attrPolicy.allowed, + attrPolicy.type + ); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); + } + } + } + } + } + + // Check if any request attributes are not covered by template policies + for (const [attrType] of requestAttributes) { + const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType); + if (!hasPolicy) { + errors.push(`${attrType} is not allowed by template policy (not defined in template)`); + } + } + } else if (requestAttributes.size > 0) { + // No subject policies defined but request has subject attributes - deny all + for (const [attrType] of requestAttributes) { + errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`); + } + } + + // Validate Subject Alternative Names + const sansPolicies = template.sans; + if (sansPolicies && sansPolicies.length > 0) { + const requestSansByType = new Map(); + + // Group request SANs by type + if (request.subjectAlternativeNames) { + for (const san of request.subjectAlternativeNames) { + if (!requestSansByType.has(san.type)) { + requestSansByType.set(san.type, []); + } + requestSansByType.get(san.type)!.push(san.value); + } + } + + // Validate each SAN policy + for (const sanPolicy of sansPolicies) { + const requestSans = requestSansByType.get(sanPolicy.type) || []; + + // Check REQUIRED values - at least one SAN must match each required pattern + if (sanPolicy.required && sanPolicy.required.length > 0) { + for (const requiredValue of sanPolicy.required) { + const hasMatchingRequiredSan = requestSans.some((sanValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + + if (!hasMatchingRequiredSan) { + errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`); + } + } + } + + // Check DENIED values - no SAN should match denied patterns + if (sanPolicy.denied && sanPolicy.denied.length > 0) { + for (const sanValue of requestSans) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`); + if (validation.isValid) { + errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`); + } + } + } + + // Check ALLOWED values - if present, all SANs must match at least one allowed pattern + if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) { + for (const sanValue of requestSans) { + let satisfiesRequired = false; + if (sanPolicy.required && sanPolicy.required.length > 0) { + satisfiesRequired = sanPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + } + } + } + } + + // Check if any request SANs are for types not covered by template policies + for (const [requestSanType] of requestSansByType) { + const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType); + if (!hasPolicy) { + errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`); + } + } + } else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) { + // No SAN policies defined but request has SANs - deny all + for (const san of request.subjectAlternativeNames) { + errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`); + } + } + + // Validate key usages + const keyUsagePolicy = template.keyUsages; + if (keyUsagePolicy) { + // Check REQUIRED key usages - must have all required usages + if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) { + const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage)); + if (missingRequired.length > 0) { + errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED key usages - must not have any denied usages + if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) { + const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage)); + if (deniedUsages.length > 0) { + errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED key usages - if present, all usages must be in allowed list + if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) { + const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])]; + const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); + if (invalidUsages.length > 0) { + errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); + } + } + } else if (request.keyUsages && request.keyUsages.length > 0) { + errors.push(`Key usages are not allowed by template policy (not defined in template)`); + } + + // Validate extended key usages + const extendedKeyUsagePolicy = template.extendedKeyUsages; + if (extendedKeyUsagePolicy) { + // Check REQUIRED extended key usages - must have all required usages + if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) { + const missingRequired = extendedKeyUsagePolicy.required.filter( + (usage) => !request.extendedKeyUsages?.includes(usage) + ); + if (missingRequired.length > 0) { + errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED extended key usages - must not have any denied usages + if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) { + const deniedUsages = request.extendedKeyUsages.filter((usage) => + extendedKeyUsagePolicy?.denied?.includes(usage) + ); + if (deniedUsages.length > 0) { + errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED extended key usages - if present, all usages must be in allowed list + if ( + request.extendedKeyUsages && + extendedKeyUsagePolicy && + extendedKeyUsagePolicy.allowed && + extendedKeyUsagePolicy.allowed.length > 0 + ) { + const allAllowedExtendedUsages = [ + ...(extendedKeyUsagePolicy.required || []), + ...(extendedKeyUsagePolicy.allowed || []) + ]; + const invalidExtendedUsages = request.extendedKeyUsages.filter( + (usage) => !allAllowedExtendedUsages.includes(usage) + ); + if (invalidExtendedUsages.length > 0) { + errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`); + } + } + } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { + errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); + } + + // Validate algorithms with new structure + if (request.signatureAlgorithm) { + if (template.algorithms?.signature && template.algorithms.signature.length > 0) { + const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi); + if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { + errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.signature) { + errors.push( + `Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + if (request.keyAlgorithm) { + if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) { + const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi); + if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { + errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.keyAlgorithm) { + errors.push( + `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + // Validate validity with new structure + if (request.validity?.ttl && (request.notBefore || request.notAfter)) { + errors.push( + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + ); + } + + if (request.notBefore && request.notAfter && request.notBefore >= request.notAfter) { + errors.push("notBefore must be earlier than notAfter"); + } + + // Validate TTL against template validity constraints + if (request.validity?.ttl && template.validity) { + const requestDurationMs = parseTTL(request.validity.ttl); + + // Check maximum duration using max field + if (template.validity.max) { + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDurationMs > maxDurationMs) { + errors.push("Requested validity period exceeds maximum allowed duration"); + } + } + } + // Validate explicit date range against max duration + if ((request.notBefore || request.notAfter) && template.validity?.max) { + const notBefore = request.notBefore || new Date(); + const { notAfter } = request; + + if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) { + const requestDuration = notAfter.getTime() - notBefore.getTime(); + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDuration > maxDurationMs) { + errors.push( + `Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}` + ); + } + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + }; + + const createTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionSub.CertificateTemplates + ); + + if (!data) { + throw new Error("Template data is required"); + } + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + // Generate slug from name and ensure it's unique within project + if (!data.name) { + throw new ForbiddenRequestError({ message: "Template name is required" }); + } + + const slug = generateTemplateSlug(data.name); + const uniqueSlug = await ensureUniqueSlug(projectId, slug); + + const template = await certificateTemplateV2DAL.create({ + ...consolidatedData, + name: uniqueSlug, + projectId + }); + + return template; + }; + + const updateTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + data: TCertificateTemplateV2Update; + }): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findById(templateId); + if (!existingTemplate) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + const updateData = { ...consolidatedData }; + if (data.name && typeof data.name === "string") { + const newSlug = generateTemplateSlug(data.name); + if (newSlug !== existingTemplate.name) { + const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId); + updateData.name = uniqueSlug; + } + } + + const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData); + if (!updatedTemplate) { + throw new NotFoundError({ message: "Failed to update certificate template" }); + } + return updatedTemplate; + }; + + const getTemplateV2ById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + return template; + }; + + const getTemplateV2BySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return template; + }; + + const listTemplatesV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + }): Promise<{ + templates: TCertificateTemplateV2[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const templates = await certificateTemplateV2DAL.findByProjectId(projectId, { + offset, + limit, + search + }); + + const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search }); + + return { + templates, + totalCount + }; + }; + + const deleteTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionSub.CertificateTemplates + ); + + const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); + if (isInUse) { + const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId); + const profileNames = profilesUsingTemplate + .map((profile: { slug?: string; id: string }) => profile.slug || profile.id) + .join(", "); + + throw new ForbiddenRequestError({ + message: + profilesUsingTemplate.length > 0 + ? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.` + : `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.` + }); + } + + const deletedTemplate = await certificateTemplateV2DAL.deleteById(templateId); + if (!deletedTemplate) { + throw new NotFoundError({ message: "Failed to delete certificate template" }); + } + return deletedTemplate as TCertificateTemplateV2; + }; + + const validateCertificateRequest = async ( + templateId: string, + request: TCertificateRequest + ): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return validateRequestAgainstPolicy(template, request); + }; + + return { + createTemplateV2, + updateTemplateV2, + getTemplateV2ById, + getTemplateV2BySlug, + listTemplatesV2, + deleteTemplateV2, + validateCertificateRequest + }; +}; + +export type TCertificateTemplateV2ServiceFactory = ReturnType; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts new file mode 100644 index 000000000..de2691d9a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts @@ -0,0 +1,98 @@ +import { + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert +} from "@app/db/schemas/pki-certificate-templates-v2"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +export interface TTemplateV2Policy { + subject?: Array<{ + type: CertSubjectAttributeType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: CertSubjectAlternativeNameType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: CertKeyUsageType[]; + required?: CertKeyUsageType[]; + denied?: CertKeyUsageType[]; + }; + extendedKeyUsages?: { + allowed?: CertExtendedKeyUsageType[]; + required?: CertExtendedKeyUsageType[]; + denied?: CertExtendedKeyUsageType[]; + }; + algorithms?: { + signature?: string[]; + keyAlgorithm?: string[]; + }; + validity?: { + max?: string; + }; +} + +export type TCertificateTemplateV2 = TPkiCertificateTemplatesV2 & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Insert = TPkiCertificateTemplatesV2Insert & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Update = Partial< + Pick< + TCertificateTemplateV2, + "name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity" + > +>; + +export interface TCertificateRequest { + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + subjectAlternativeNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity?: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +export interface TTemplateValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 20c061bf7..20c0ffd88 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -420,7 +420,7 @@ export const certificateTemplateServiceFactory = ({ }; const getEstConfiguration = async (dto: TGetEstConfigurationDTO) => { - const { certificateTemplateId } = dto; + const { certificateTemplateId, isInternal } = dto; const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); if (!certTemplate) { @@ -429,7 +429,7 @@ export const certificateTemplateServiceFactory = ({ }); } - if (!dto.isInternal) { + if (!isInternal) { const { permission } = await permissionService.getProjectPermission({ actor: dto.actor, actorId: dto.actorId, @@ -440,7 +440,7 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Read, subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts index 60694b598..e33ddb047 100644 --- a/backend/src/services/certificate-template/certificate-template-validators.ts +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -3,26 +3,40 @@ import z from "zod"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; -export const validateTemplateRegexField = z - .string() - .min(1) - .max(100) - .refine( - (val) => - characterValidator([ - CharacterType.AlphaNumeric, - CharacterType.Spaces, // (space) - CharacterType.Asterisk, // * - CharacterType.At, // @ - CharacterType.Hyphen, // - - CharacterType.Period, // . - CharacterType.Backslash // \ - ])(val), - { - message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - } - ) - // we ensure that the inputted pattern is computationally safe by limiting star height to 1 - .refine((v) => safe(v), { - message: "Unsafe REGEX pattern" - }); +export const createTemplateFieldValidator = (options?: { + minLength?: number; + maxLength?: number; + allowedCharacters?: CharacterType[]; + customMessage?: string; +}) => { + const { + minLength = 1, + maxLength = 100, + allowedCharacters = [ + CharacterType.AlphaNumeric, + CharacterType.Spaces, // (space) + CharacterType.Asterisk, // * + CharacterType.At, // @ + CharacterType.Hyphen, // - + CharacterType.Period, // . + CharacterType.Backslash // \ + ], + customMessage = "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + } = options || {}; + + return ( + z + .string() + .min(minLength) + .max(maxLength) + .refine((val) => characterValidator(allowedCharacters)(val), { + message: customMessage + }) + // we ensure that the inputted pattern is computationally safe by limiting star height to 1 + .refine((v) => safe(v), { + message: "Unsafe REGEX pattern" + }) + ); +}; + +export const validateTemplateRegexField = createTemplateFieldValidator(); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts new file mode 100644 index 000000000..95f9a5077 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -0,0 +1,1463 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; + +describe("CertificateV3Service", () => { + let service: TCertificateV3ServiceFactory; + + const mockCertificateDAL: Pick = { + findOne: vi.fn(), + updateById: vi.fn() + }; + + const mockCertificateAuthorityDAL: Pick = { + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn() + }; + + const mockCertificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + > = { + validateCertificateRequest: vi.fn(), + getTemplateV2ById: vi.fn() + }; + + const mockInternalCaService: Pick = + { + signCertFromCa: vi.fn(), + issueCertFromCa: vi.fn() + }; + + const mockPermissionService: Pick = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [] + } + }) + }; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Mock ForbiddenError.from static method + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Ensure the permission service mock is properly set up + (mockPermissionService.getProjectPermission as any).mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [], + detectSubjectType: vi.fn() + } + }); + + service = certificateV3ServiceFactory({ + certificateDAL: mockCertificateDAL, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + internalCaService: mockInternalCaService, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); // Ensure static method mocks are properly restored + }); + + describe("issueCertificateFromProfile", () => { + const mockCertificateRequest = { + commonName: "test.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should issue certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile", + description: "Test profile" + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + revokedBy: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("privateKey"); + expect(result).toHaveProperty("serialNumber", "123456"); + expect(result).toHaveProperty("certificateId", "cert-123"); + }); + + it("should correctly map camelCase key usages to snake_case before validation", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-camel", + description: "Test camelCase profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template for CamelCase", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for camelCase validation", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const camelCaseRequest = { + commonName: "test.example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ], + validity: { ttl: "10d" } + }; + + const mockCertificateResultWithCa = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + await service.issueCertificateFromProfile({ + profileId, + certificateRequest: camelCaseRequest, + ...mockActor + }); + + // Verify that the template validation service was called with mapped snake_case values + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-123", + expect.objectContaining({ + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ] + }) + ); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est", + description: "Test EST profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(undefined); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("signCertificateFromProfile", () => { + const mockCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC..."; + const mockValidity = { ttl: "30d" }; + + it("should sign certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-sign", + description: "Test signing profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockSignResult = { + certificate: "signed-cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + serialNumber: "789012", + commonName: "test.example.com", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-456", + serialNumber: "789012", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Signing Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const mockTemplate = { + id: "template-123", + name: "Test Signing Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for signing certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("serialNumber", "789012"); + expect(result).toHaveProperty("certificateId", "cert-456"); + expect(result).not.toHaveProperty("privateKey"); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-sign", + description: "Test EST signing profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("orderCertificateFromProfile", () => { + const mockCertificateOrder = { + altNames: [{ type: ACMESANType.DNS, value: "example.com" }], + validity: { ttl: "30d" }, + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should create order successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-order", + description: "Test order profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Order Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for ordering certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "example.com", + friendlyName: "Test Order Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]), + caCertId: null, + keyUsages: ["DIGITAL_SIGNATURE"], + extendedKeyUsages: ["SERVER_AUTH"], + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }); + + expect(result).toHaveProperty("orderId"); + expect(result).toHaveProperty("status", "valid"); + expect(result).toHaveProperty("certificate"); + expect(result.subjectAlternativeNames).toHaveLength(1); + expect(result.subjectAlternativeNames[0]).toEqual({ + type: ACMESANType.DNS, + value: "example.com", + status: CertificateOrderStatus.VALID + }); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-order", + description: "Test EST order profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("algorithm compatibility (integration tests)", () => { + const mockProfile = { + id: "profile-1", + slug: "test-profile", + projectId: "project-1", + caId: "ca-1", + certificateTemplateId: "template-1", + enrollmentType: EnrollmentType.API, + createdAt: new Date(), + updatedAt: new Date(), + description: "Test profile for algorithm compatibility", + estConfigId: null, + apiConfigId: null + }; + + const mockCertificateRequest = { + commonName: "test.example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully process RSA algorithms with RSA CAs", async () => { + const rsaCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA Test CA", + dn: "CN=RSA Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA", "SHA384-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsaCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsaCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - RSA CA is compatible with RSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should successfully process ECDSA algorithms with EC CAs", async () => { + const ecCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC Test CA", + dn: "CN=EC Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_prime256v1", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-ECDSA", "SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(ecCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: ecCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - EC CA is compatible with ECDSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new RSA key sizes", async () => { + const rsa8192Ca = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA 8192 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA 8192 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA 8192 Test CA", + dn: "CN=RSA 8192 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_8192", // Future RSA key size + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA 8192 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA 8192 template for future key sizes", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsa8192Ca); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsa8192Ca as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new RSA key sizes + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new EC curve types", async () => { + const newEcCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC secp521r1 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC secp521r1 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC secp521r1 Test CA", + dn: "CN=EC secp521r1 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_secp521r1", // Future EC curve + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA secp521r1 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA secp521r1 template for future EC curves", + signatureAlgorithm: { + allowedAlgorithms: ["SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(newEcCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: newEcCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new EC curves + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA384" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + }); +}); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts new file mode 100644 index 000000000..1c11b0a00 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -0,0 +1,487 @@ +import { ForbiddenError } from "@casl/ability"; +import { randomUUID } from "crypto"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { + TCertificateAuthorityDALFactory, + TCertificateAuthorityWithAssociatedCa +} from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + bufferToString, + buildCertificateSubjectFromTemplate, + buildSubjectAlternativeNamesFromTemplate, + convertExtendedKeyUsageArrayToLegacy, + convertKeyUsageArrayToLegacy, + mapEnumsForValidation, + normalizeDateForApi +} from "../certificate-common/certificate-utils"; +import { + TCertificateFromProfileResponse, + TCertificateOrderResponse, + TIssueCertificateFromProfileDTO, + TOrderCertificateFromProfileDTO, + TSignCertificateFromProfileDTO +} from "./certificate-v3-types"; + +type TCertificateV3ServiceFactoryDep = { + certificateDAL: Pick; + certificateAuthorityDAL: Pick; + certificateProfileDAL: Pick; + certificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + >; + internalCaService: Pick; + permissionService: Pick; +}; + +export type TCertificateV3ServiceFactory = ReturnType; + +const validateProfileAndPermissions = async ( + profileId: string, + actor: ActorType, + actorId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string, + certificateProfileDAL: Pick, + permissionService: Pick, + requiredEnrollmentType: EnrollmentType +) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== requiredEnrollmentType) { + throw new ForbiddenRequestError({ + message: `Profile is not configured for ${requiredEnrollmentType} enrollment` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + + return profile; +}; + +const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => { + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType !== CaType.INTERNAL) { + throw new BadRequestError({ message: `Only internal CAs support ${operation}` }); + } + return caType; +}; + +const validateAlgorithmCompatibility = ( + ca: TCertificateAuthorityWithAssociatedCa, + template: { + algorithms?: { + signature?: string[]; + }; + } +) => { + if (!template.algorithms?.signature || template.algorithms.signature.length === 0) { + return; + } + + const caKeyAlgorithm = ca.internalCa?.keyAlgorithm; + if (!caKeyAlgorithm) { + throw new BadRequestError({ message: "CA key algorithm not found" }); + } + + const compatibleAlgorithms = + template.algorithms?.signature?.filter((sigAlg: string) => { + const parts = sigAlg.split("-"); + if (parts.length === 0) { + return false; + } + const keyType = parts[parts.length - 1]; + + if (caKeyAlgorithm.startsWith("RSA")) { + return keyType === "RSA"; + } + + if (caKeyAlgorithm.startsWith("EC")) { + return keyType === "ECDSA"; + } + + return false; + }) || []; + + if (compatibleAlgorithms.length === 0) { + throw new BadRequestError({ + message: `Template signature algorithms (${template.algorithms?.signature?.join(", ") || "none"}) are not compatible with CA key algorithm (${caKeyAlgorithm})` + }); + } +}; + +const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => { + if (typeof certData === "string") return certData; + if (Buffer.isBuffer(certData)) return bufferToString(certData); + if (certData && typeof certData === "object" && "rawData" in certData && Buffer.isBuffer(certData.rawData)) { + return bufferToString(certData.rawData); + } + return bufferToString(certData as unknown as Buffer); +}; + +export const certificateV3ServiceFactory = ({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService, + permissionService +}: TCertificateV3ServiceFactoryDep) => { + const issueCertificateFromProfile = async ({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + if (certificateRequest.commonName && Array.isArray(certificateRequest.commonName)) { + throw new BadRequestError({ + message: "Common Name must be a single value, not an array" + }); + } + + const mappedCertificateRequest = mapEnumsForValidation({ + ...certificateRequest, + subjectAlternativeNames: certificateRequest.altNames + }); + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; + const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template.sans + ); + + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = + await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: certificateSubject.common_name || "Certificate", + commonName: certificateSubject.common_name || "", + altNames: subjectAlternativeNames, + ttl: certificateRequest.validity.ttl, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + notBefore: normalizeDateForApi(certificateRequest.notBefore), + notAfter: normalizeDateForApi(certificateRequest.notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + return { + certificate: bufferToString(certificate), + issuingCaCertificate: bufferToString(issuingCaCertificate), + certificateChain: bufferToString(certificateChain), + privateKey: bufferToString(privateKey), + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const signCertificateFromProfile = async ({ + profileId, + csr, + validity, + notBefore, + notAfter, + signatureAlgorithm, + keyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignCertificateFromProfileDTO): Promise> => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "CSR signing"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate signing" }); + } + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = signatureAlgorithm; + const effectiveKeyAlgorithm = keyAlgorithm; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: undefined, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); + const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + + return { + certificate: certificateString, + issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer), + certificateChain: certificateChainString, + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const orderCertificateFromProfile = async ({ + profileId, + certificateOrder, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TOrderCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const certificateRequest = { + commonName: certificateOrder.commonName, + keyUsages: certificateOrder.keyUsages, + extendedKeyUsages: certificateOrder.extendedKeyUsages, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, + value: san.value + })), + validity: certificateOrder.validity, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + signatureAlgorithm: certificateOrder.signatureAlgorithm, + keyAlgorithm: certificateOrder.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate order validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + + if (caType === CaType.INTERNAL) { + const certificateResult = await issueCertificateFromProfile({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + const orderId = randomUUID(); + + return { + orderId, + status: CertificateOrderStatus.VALID, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type, + value: san.value, + status: CertificateOrderStatus.VALID + })), + authorizations: [], + finalize: `/api/v3/certificates/orders/${orderId}/completed`, + certificate: certificateResult.certificate, + projectId: certificateResult.projectId, + profileName: certificateResult.profileName + }; + } + + if (caType === CaType.ACME) { + throw new BadRequestError({ + message: "ACME certificate ordering via profiles is not yet implemented." + }); + } + + throw new BadRequestError({ + message: `Certificate ordering is not supported for CA type: ${caType}` + }); + }; + + return { + issueCertificateFromProfile, + signCertificateFromProfile, + orderCertificateFromProfile + }; +}; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts new file mode 100644 index 000000000..b54042c5c --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -0,0 +1,99 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "../certificate-common/certificate-constants"; + +export type TIssueCertificateFromProfileDTO = { + profileId: string; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TSignCertificateFromProfileDTO = { + profileId: string; + csr: string; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} & Omit; + +export type TOrderCertificateFromProfileDTO = { + profileId: string; + certificateOrder: { + altNames: Array<{ + type: ACMESANType; + value: string; + }>; + validity: { + ttl: string; + }; + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TCertificateFromProfileResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; + profileName: string; +}; + +export type TCertificateOrderResponse = { + orderId: string; + status: CertificateOrderStatus; + subjectAlternativeNames: Array<{ + type: ACMESANType; + value: string; + status: CertificateOrderStatus; + }>; + authorizations: Array<{ + identifier: { + type: ACMESANType; + value: string; + }; + status: CertificateOrderStatus; + expires?: string; + challenges: Array<{ + type: string; + status: CertificateOrderStatus; + url: string; + token: string; + }>; + }>; + finalize: string; + certificate?: string; + projectId: string; + profileName: string; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 527df2a39..9da331be8 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -8,14 +8,26 @@ import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; export enum CertStatus { ACTIVE = "active", + EXPIRED = "expired", REVOKED = "revoked" } export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", - ECDSA_P384 = "EC_secp384r1" + ECDSA_P384 = "EC_secp384r1", + ECDSA_P521 = "EC_secp521r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" } export enum CertKeyUsage { @@ -39,6 +51,11 @@ export enum CertExtendedKeyUsage { OCSP_SIGNING = "ocspSigning" } +export enum CertSignatureType { + RSA = "RSA", + ECDSA = "ECDSA" +} + export const CertExtendedKeyUsageOIDToName: Record = { [x509.ExtendedKeyUsage.clientAuth]: CertExtendedKeyUsage.CLIENT_AUTH, [x509.ExtendedKeyUsage.serverAuth]: CertExtendedKeyUsage.SERVER_AUTH, @@ -105,13 +122,48 @@ export type TGetCertificateCredentialsDTO = { kmsService: Pick; }; +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + export enum TAltNameType { EMAIL = "email", DNS = "dns", IP = "ip", URL = "url" } + +export const mapLegacyAltNameType = (legacyType: TAltNameType): CertSubjectAlternativeNameType => { + switch (legacyType) { + case TAltNameType.EMAIL: + return CertSubjectAlternativeNameType.EMAIL; + case TAltNameType.DNS: + return CertSubjectAlternativeNameType.DNS_NAME; + case TAltNameType.IP: + return CertSubjectAlternativeNameType.IP_ADDRESS; + case TAltNameType.URL: + return CertSubjectAlternativeNameType.URI; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown legacy alt name type: ${legacyType}`); + } +}; export type TAltNameMapping = { type: TAltNameType; value: string; }; + +export enum ACMESANType { + DNS = "dns", + IP = "ip" +} + +export enum CertificateOrderStatus { + PENDING = "pending", + PROCESSING = "processing", + VALID = "valid", + INVALID = "invalid" +} diff --git a/backend/src/services/convertor/convertor-service.ts b/backend/src/services/convertor/convertor-service.ts new file mode 100644 index 000000000..71c25e9c0 --- /dev/null +++ b/backend/src/services/convertor/convertor-service.ts @@ -0,0 +1,130 @@ +import { AccessScope } from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { NotFoundError } from "@app/lib/errors"; + +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; +import { TMembershipDALFactory } from "../membership/membership-dal"; +import { TProjectDALFactory } from "../project/project-dal"; + +type TConvertorServiceFactoryDep = { + projectDAL: Pick; + membershipDAL: Pick; + groupDAL: Pick; + additionalPrivilegeDAL: Pick; +}; + +export type TConvertorServiceFactory = ReturnType; + +export const convertorServiceFactory = ({ + projectDAL, + membershipDAL, + additionalPrivilegeDAL, + groupDAL +}: TConvertorServiceFactoryDep) => { + const projectSlugToId = async (dto: { slug: string; orgId: string }) => { + const project = await projectDAL.findOne({ + orgId: dto.orgId, + slug: dto.slug + }); + if (!project) throw new NotFoundError({ message: `Project with slug ${dto.slug} not found` }); + return project; + }; + + const userMembershipIdToUserId = async (membershipId: string, scope: AccessScope, orgId: string) => { + const membership = await membershipDAL.findOne({ + scope, + id: membershipId, + scopeOrgId: orgId + }); + if (!membership || !membership.actorUserId) { + throw new NotFoundError({ message: `Membership with id ${membershipId} not found` }); + } + return { userId: membership.actorUserId, membership }; + }; + + const groupMembershipIdToGroupId = async (membershipId: string, scope: AccessScope, orgId: string) => { + const membership = await membershipDAL.findOne({ + scope, + id: membershipId, + scopeOrgId: orgId + }); + if (!membership || !membership.actorGroupId) { + throw new NotFoundError({ message: `Membership with id ${membershipId} not found` }); + } + + return { groupId: membership.actorGroupId, membership }; + }; + + const identityMembershipIdToIdentityId = async (membershipId: string, scope: AccessScope, orgId: string) => { + const membership = await membershipDAL.findOne({ + scope, + id: membershipId, + scopeOrgId: orgId + }); + if (!membership || !membership.actorIdentityId) { + throw new NotFoundError({ message: `Membership with id ${membershipId} not found` }); + } + + return { identityId: membership.actorIdentityId, membership }; + }; + + const identityIdToMembershipId = async (identityId: string, scope: AccessScope, scopeId: string) => { + let fieldName = "scopeOrgId"; + if (scope === AccessScope.Project) { + fieldName = "scopeProjectId"; + } else if (scope === AccessScope.Namespace) { + fieldName = "scopeNamespaceId"; + } + + const membership = await membershipDAL.findOne({ + scope, + actorIdentityId: identityId, + [fieldName]: scopeId + }); + + if (!membership) { + throw new NotFoundError({ message: `Identity with id ${identityId} not found` }); + } + + return { membershipId: membership.id, membership }; + }; + + const additionalPrivilegeIdToDoc = async (privilegeId: string) => { + const doc = await additionalPrivilegeDAL.findOne({ + id: privilegeId + }); + if (!doc) { + throw new NotFoundError({ message: `Privilege with id ${privilegeId} not found` }); + } + + return { privilege: doc }; + }; + const additionalPrivilegeNameToDoc = async (privilegeName: string, projectId: string) => { + const privilege = await additionalPrivilegeDAL.findOne({ + name: privilegeName, + projectId + }); + if (!privilege) { + throw new NotFoundError({ message: `Privilege with name ${privilegeName} not found` }); + } + + return { privilegeId: privilege.id, privilege }; + }; + + const getGroupIdFromName = async (name: string, orgId: string) => { + const group = await groupDAL.findOne({ orgId, name }); + if (!group) throw new NotFoundError({ message: `Failed to find group with name ${name}` }); + return { groupId: group.id, group }; + }; + + return { + projectSlugToId, + userMembershipIdToUserId, + groupMembershipIdToGroupId, + identityMembershipIdToIdentityId, + additionalPrivilegeIdToDoc, + additionalPrivilegeNameToDoc, + identityIdToMembershipId, + getGroupIdFromName + }; +}; diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts new file mode 100644 index 000000000..1edfdae6c --- /dev/null +++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts @@ -0,0 +1,118 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TApiEnrollmentConfigDALFactory = ReturnType; + +export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { + const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig); + + const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create API enrollment config" }); + } + }; + + const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update API enrollment config" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Delete API enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first(); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Find API enrollment config by id" }); + } + }; + + const findProfilesForAutoRenewal = async (renewalThresholdDays: number = 30, projectId?: string, tx?: Knex) => { + try { + let query = (tx || db)(TableName.PkiCertificateProfile) + .join( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true); + + if (projectId) { + query = query.where(`${TableName.PkiCertificateProfile}.projectId`, projectId); + } + + const profiles = await query + .where((qb) => { + void qb + .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) + .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); + }) + .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile)); + + return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>; + } catch (error) { + throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); + } + }; + + const isConfigInUse = async (configId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.PkiCertificateProfile).where({ apiConfigId: configId }).count("*").first(); + + if (!doc || typeof doc !== "object") { + return 0; + } + + const countValue = (doc as Record).count; + if (typeof countValue === "number") { + return countValue; + } + if (typeof countValue === "string") { + const parsed = parseInt(countValue, 10); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; + } catch (error) { + throw new DatabaseError({ error, name: "Check if API enrollment config is in use" }); + } + }; + + return { + ...apiEnrollmentConfigOrm, + create, + updateById, + deleteById, + findById, + findProfilesForAutoRenewal, + isConfigInUse + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts new file mode 100644 index 000000000..516f135fd --- /dev/null +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -0,0 +1,29 @@ +import { + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-api-enrollment-configs"; +import { + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-est-enrollment-configs"; + +export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs; +export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert; +export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate; + +export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; +export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; +export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; + +export interface TEstConfigData { + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain?: string; +} + +export interface TApiConfigData { + autoRenew: boolean; + autoRenewDays?: number; +} diff --git a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts new file mode 100644 index 000000000..b7520f290 --- /dev/null +++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TEstEnrollmentConfigDALFactory = ReturnType; + +export const estEnrollmentConfigDALFactory = (db: TDbClient) => { + const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig); + + const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + throw new Error("Failed to create EST enrollment config"); + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create EST enrollment config" }); + } + }; + + const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + return null; + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update EST enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first(); + + return estConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find EST enrollment config by id" }); + } + }; + + return { + ...estEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts index 552eed307..21b0b3c1f 100644 --- a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-fns.ts @@ -1,19 +1,19 @@ -import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; +import { OrgMembershipRole, TRoles } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; import { isCustomOrgRole } from "@app/services/org/org-role-fns"; +import { TRoleDALFactory } from "../role/role-dal"; import { TExternalGroupOrgMembershipRoleMappingDTO } from "./external-group-org-role-mapping-types"; export const constructGroupOrgMembershipRoleMappings = async ({ mappingsDTO, orgId, - orgRoleDAL, + roleDAL, licenseService }: { mappingsDTO: TExternalGroupOrgMembershipRoleMappingDTO[]; - orgRoleDAL: TOrgRoleDALFactory; + roleDAL: TRoleDALFactory; licenseService: TLicenseServiceFactory; orgId: string; }) => { @@ -30,9 +30,9 @@ export const constructGroupOrgMembershipRoleMappings = async ({ .filter((mapping) => isCustomOrgRole(mapping.roleSlug)) .map((mapping) => mapping.roleSlug); - let customRolesMap: Map = new Map(); + let customRolesMap: Map = new Map(); if (customRoleSlugs.length > 0) { - const customRoles = await orgRoleDAL.find({ + const customRoles = await roleDAL.find({ orgId, $in: { slug: customRoleSlugs diff --git a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts index de293609b..e51d25ce3 100644 --- a/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts +++ b/backend/src/services/external-group-org-role-mapping/external-group-org-role-mapping-service.ts @@ -1,20 +1,21 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } 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-types"; import { OrgServiceActor } from "@app/lib/types"; import { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns"; import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types"; -import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; +import { TRoleDALFactory } from "../role/role-dal"; import { TExternalGroupOrgRoleMappingDALFactory } from "./external-group-org-role-mapping-dal"; type TExternalGroupOrgRoleMappingServiceFactoryDep = { externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory; permissionService: TPermissionServiceFactory; licenseService: TLicenseServiceFactory; - orgRoleDAL: TOrgRoleDALFactory; + roleDAL: TRoleDALFactory; }; export type TExternalGroupOrgRoleMappingServiceFactory = ReturnType; @@ -23,16 +24,17 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ externalGroupOrgRoleMappingDAL, licenseService, permissionService, - orgRoleDAL + roleDAL }: TExternalGroupOrgRoleMappingServiceFactoryDep) => { const listExternalGroupOrgRoleMappings = async (actor: OrgServiceActor) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.ParentOrganization + }); // TODO: will need to change if we add support for ldap, oidc, etc. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim); @@ -48,20 +50,21 @@ export const externalGroupOrgRoleMappingServiceFactory = ({ dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO, actor: OrgServiceActor ) => { - const { permission } = await permissionService.getOrgPermission( - actor.type, - actor.id, - actor.orgId, - actor.authMethod, - actor.orgId - ); + const { permission } = await permissionService.getOrgPermission({ + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + scope: OrganizationActionScope.ParentOrganization + }); // TODO: will need to change if we add support for ldap, oidc, etc. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); const mappings = await constructGroupOrgMembershipRoleMappings({ mappingsDTO: dto.mappings, - orgRoleDAL, + roleDAL, licenseService, orgId: actor.orgId }); diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index e801b607e..bc2a24c07 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,9 +1,34 @@ -import { OrgMembershipRole } from "@app/db/schemas"; +import { OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; +import { + AuditLogInfo, + EventType, + SecretApprovalEvent, + TAuditLogServiceFactory +} from "@app/ee/services/audit-log/audit-log-types"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "../app-connection/app-connection-enums"; +import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { + convertVaultValueToString, + getHCVaultAuthMounts, + getHCVaultKubernetesAuthRoles, + getHCVaultSecretsForPath, + HCVaultAuthType, + listHCVaultMounts, + listHCVaultPolicies, + listHCVaultSecretPaths, + THCVaultConnection +} from "../app-connection/hc-vault"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TSecretServiceFactory } from "../secret/secret-service"; +import { SecretProtectionType } from "../secret/secret-types"; import { TUserDALFactory } from "../user/user-dal"; import { decryptEnvKeyDataFn, @@ -15,16 +40,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { ExternalMigrationProviders, ExternalPlatforms, + TCreateVaultExternalMigrationDTO, + TDeleteVaultExternalMigrationDTO, THasCustomVaultMigrationDTO, TImportEnvKeyDataDTO, - TImportVaultDataDTO + TImportVaultDataDTO, + TUpdateVaultExternalMigrationDTO, + VaultImportStatus } from "./external-migration-types"; +import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; + secretService: TSecretServiceFactory; + auditLogService: Pick; externalMigrationQueue: TExternalMigrationQueueFactory; + appConnectionService: Pick; + vaultExternalMigrationConfigDAL: Pick< + TVaultExternalMigrationConfigDALFactory, + "create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById" + >; userDAL: Pick; gatewayService: Pick; + kmsService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -33,7 +71,12 @@ export const externalMigrationServiceFactory = ({ permissionService, externalMigrationQueue, userDAL, - gatewayService + gatewayService, + secretService, + auditLogService, + appConnectionService, + vaultExternalMigrationConfigDAL, + kmsService }: TExternalMigrationServiceFactoryDep) => { const importEnvKeyData = async ({ decryptionKey, @@ -47,14 +90,15 @@ export const externalMigrationServiceFactory = ({ throw new BadRequestError({ message: "EnvKey migration is not supported when running in FIPS mode." }); } - const { membership } = await permissionService.getOrgPermission( - actor, + const { hasRole } = await permissionService.getOrgPermission({ actorId, + actor, + orgId: actorOrgId, actorOrgId, actorAuthMethod, - actorOrgId - ); - if (membership.role !== OrgMembershipRole.Admin) { + scope: OrganizationActionScope.Any + }); + if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can import data" }); } @@ -94,15 +138,16 @@ export const externalMigrationServiceFactory = ({ actorOrgId, actorAuthMethod }: TImportVaultDataDTO) => { - const { membership } = await permissionService.getOrgPermission( - actor, + const { hasRole } = await permissionService.getOrgPermission({ actorId, + actor, + orgId: actorOrgId, actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.Any + }); - if (membership.role !== OrgMembershipRole.Admin) { + if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can import data" }); } @@ -150,15 +195,16 @@ export const externalMigrationServiceFactory = ({ actorAuthMethod, provider }: THasCustomVaultMigrationDTO) => { - const { membership } = await permissionService.getOrgPermission( - actor, + const { hasRole } = await permissionService.getOrgPermission({ actorId, + actor, + orgId: actorOrgId, actorOrgId, actorAuthMethod, - actorOrgId - ); + scope: OrganizationActionScope.Any + }); - if (membership.role !== OrgMembershipRole.Admin) { + if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" }); } @@ -171,9 +217,565 @@ export const externalMigrationServiceFactory = ({ return actorOrgId in vaultMigrationTransformMappings; }; + const validateVaultExternalMigrationConnection = async ({ + connection, + namespace + }: { + connection: THCVaultConnection; + namespace: string; + }) => { + // Allow root namespace access when no namespace is configured on the connection + const isRootAccess = namespace === "root" || namespace === "/"; + const hasNoNamespace = connection.credentials.namespace === undefined; + + if (hasNoNamespace && isRootAccess) { + // Skip validation for root access with no configured namespace + } else if (connection.credentials.namespace !== namespace) { + throw new BadRequestError({ message: "Namespace value does not match the namespace of the connection" }); + } + + try { + await listHCVaultPolicies(namespace, connection, gatewayService); + await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService); + + const mounts = await listHCVaultMounts(connection, gatewayService); + const sampleKvMount = mounts.find((mount) => mount.type === "kv"); + if (sampleKvMount) { + await listHCVaultSecretPaths(namespace, connection, gatewayService, sampleKvMount.path); + } + } catch (error) { + throw new BadRequestError({ + message: `Failed to establish namespace confiugration. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can configure vault external migration" }); + } + + const connection = await appConnectionService.connectAppConnectionById( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + + try { + const config = await vaultExternalMigrationConfigDAL.create({ + namespace, + connectionId, + orgId: actor.orgId + }); + + return config; + } catch (error) { + if ( + error instanceof DatabaseError && + (error.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ + message: `Vault external migration already exists for this namespace` + }); + } + + throw error; + } + }; + + const updateVaultExternalMigration = async ({ + id, + namespace, + connectionId, + actor + }: TUpdateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can update vault external migration" }); + } + + if (connectionId) { + const connection = await appConnectionService.connectAppConnectionById( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + } + + const config = await vaultExternalMigrationConfigDAL.updateById(id, { + namespace, + connectionId + }); + + return config; + }; + + const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault external migration configs" }); + } + + const configs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + return configs; + }; + + const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault namespaces" }); + } + + // Get all configured namespaces for this org + const vaultConfigs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + // Return the configured namespaces as an array of objects with id and name + // where both id and name are the namespace path + const namespaces = vaultConfigs.map((config) => ({ + id: config.namespace, + name: config.namespace + })); + + return namespaces; + }; + + const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault policies" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const policies = await listHCVaultPolicies(namespace, connection, gatewayService); + return policies; + }; + + const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + return mounts; + }; + + const getVaultSecretPaths = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath); + + return secretPaths; + }; + + const importVaultSecrets = async ({ + actor, + projectId, + environment, + secretPath, + vaultNamespace, + vaultSecretPath, + auditLogInfo + }: { + actor: OrgServiceActor; + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; + auditLogInfo: AuditLogInfo; + }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace: vaultNamespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService); + + try { + const secretOperation = await secretService.createManySecretsRaw({ + actorId: actor.id, + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + secretPath, + environment, + projectId, + secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue: convertVaultValueToString(secretValue) + })) + }); + + if (secretOperation.type === SecretProtectionType.Approval) { + await auditLogService.createAuditLog({ + projectId, + ...auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({ + secretKey + })), + eventType: SecretApprovalEvent.CreateMany + } + } + }); + + return { status: VaultImportStatus.ApprovalRequired }; + } + + return { status: VaultImportStatus.Imported }; + } catch (error) { + throw new BadRequestError({ + message: `Failed to import Vault secrets. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can delete vault external migration configs" }); + } + + const config = await vaultExternalMigrationConfigDAL.findById(id); + + if (!config) { + throw new NotFoundError({ message: "Vault migration config not found" }); + } + + if (config.orgId !== actor.orgId) { + throw new ForbiddenRequestError({ message: "Config does not belong to this organization" }); + } + + const deletedConfig = await vaultExternalMigrationConfigDAL.deleteById(id); + + return deletedConfig; + }; + + const getVaultAuthMounts = async ({ + actor, + namespace, + authType + }: { + actor: OrgServiceActor; + namespace: string; + authType?: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService); + + return authMounts; + }; + + const getVaultKubernetesAuthRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission({ + actorId: actor.id, + actor: actor.type, + orgId: actor.orgId, + actorOrgId: actor.orgId, + actorAuthMethod: actor.authMethod, + scope: OrganizationActionScope.Any + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + // Get roles for the specified mount path only + const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService); + + return roles; + }; + return { importEnvKeyData, importVaultData, - hasCustomVaultMigration + hasCustomVaultMigration, + createVaultExternalMigration, + getVaultExternalMigrationConfigs, + updateVaultExternalMigration, + deleteVaultExternalMigration, + getVaultNamespaces, + getVaultPolicies, + getVaultMounts, + getVaultAuthMounts, + getVaultSecretPaths, + importVaultSecrets, + getVaultKubernetesAuthRoles }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 804444172..49e5bfa88 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -1,4 +1,4 @@ -import { TOrgPermission } from "@app/lib/types"; +import { OrgServiceActor, TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -121,3 +121,26 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TCreateVaultExternalMigrationDTO = { + namespace: string; + connectionId: string; + actor: OrgServiceActor; +}; + +export type TUpdateVaultExternalMigrationDTO = { + id: string; + namespace: string; + connectionId: string | null; + actor: OrgServiceActor; +}; + +export type TDeleteVaultExternalMigrationDTO = { + id: string; + actor: OrgServiceActor; +}; diff --git a/backend/src/services/external-migration/vault-external-migration-config-dal.ts b/backend/src/services/external-migration/vault-external-migration-config-dal.ts new file mode 100644 index 000000000..34ff8757a --- /dev/null +++ b/backend/src/services/external-migration/vault-external-migration-config-dal.ts @@ -0,0 +1,67 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +export type TVaultExternalMigrationConfigDALFactory = ReturnType; + +export const vaultExternalMigrationConfigDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.VaultExternalMigrationConfig); + + const findOne = async (filter: { orgId: string; namespace: string }, tx?: Knex) => { + try { + const result = await (tx || db?.replicaNode?.() || db)(TableName.VaultExternalMigrationConfig) + .leftJoin( + TableName.AppConnection, + `${TableName.AppConnection}.id`, + `${TableName.VaultExternalMigrationConfig}.connectionId` + ) + /* eslint-disable @typescript-eslint/no-misused-promises */ + .where(buildFindFilter(prependTableNameToFindFilter(TableName.VaultExternalMigrationConfig, filter))) + .select(selectAllTableCols(TableName.VaultExternalMigrationConfig)) + .select( + db.ref("id").withSchema(TableName.AppConnection).as("appConnectionId"), + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt") + ) + .first(); + + if (!result) return undefined; + + return { + ...result, + connection: result.appConnectionId + ? { + id: result.appConnectionId, + name: result.appConnectionName, + app: result.appConnectionApp, + encryptedCredentials: result.appConnectionEncryptedCredentials, + orgId: result.appConnectionOrgId, + method: result.appConnectionMethod, + description: result.appConnectionDescription, + version: result.appConnectionVersion, + gatewayId: result.appConnectionGatewayId, + projectId: result.appConnectionProjectId, + createdAt: result.appConnectionCreatedAt, + updatedAt: result.appConnectionUpdatedAt + } + : undefined + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + return { ...orm, findOne }; +}; diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index 263838a9d..4d14758dc 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -1,53 +1,44 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { AccessScope, TableName, TMemberships, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, sqlNestRelationships } from "@app/lib/knex"; +import { sqlNestRelationships } from "@app/lib/knex"; export type TGroupProjectDALFactory = ReturnType; export const groupProjectDALFactory = (db: TDbClient) => { - const groupProjectOrm = ormify(db, TableName.GroupProjectMembership); - const findByProjectId = async (projectId: string, filter?: { groupId?: string }, tx?: Knex) => { try { - const docs = await (tx || db.replicaNode())(TableName.GroupProjectMembership) - .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + const docs = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorGroupId`) .where((qb) => { if (filter?.groupId) { void qb.where(`${TableName.Groups}.id`, "=", filter.groupId); } }) - .join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`) - .join( - TableName.GroupProjectMembershipRole, - `${TableName.GroupProjectMembershipRole}.projectMembershipId`, - `${TableName.GroupProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.GroupProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) + .join(TableName.Groups, `${TableName.Membership}.actorGroupId`, `${TableName.Groups}.id`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .select( - db.ref("id").withSchema(TableName.GroupProjectMembership), - db.ref("createdAt").withSchema(TableName.GroupProjectMembership), - db.ref("updatedAt").withSchema(TableName.GroupProjectMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("createdAt").withSchema(TableName.Membership), + db.ref("updatedAt").withSchema(TableName.Membership), db.ref("id").as("groupId").withSchema(TableName.Groups), db.ref("name").as("groupName").withSchema(TableName.Groups), db.ref("slug").as("groupSlug").withSchema(TableName.Groups), - db.ref("id").withSchema(TableName.GroupProjectMembership), - db.ref("role").withSchema(TableName.GroupProjectMembershipRole), - db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), - db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole) ); const members = sqlNestRelationships({ @@ -104,8 +95,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { try { const docs = await (tx || db.replicaNode())(TableName.UserGroupMembership) .where(`${TableName.UserGroupMembership}.userId`, userId) - .join(TableName.Groups, function () { - this.on(`${TableName.UserGroupMembership}.groupId`, "=", `${TableName.Groups}.id`).andOn( + .join(TableName.Groups, (qb) => { + qb.on(`${TableName.UserGroupMembership}.groupId`, "=", `${TableName.Groups}.id`).andOn( `${TableName.Groups}.orgId`, "=", db.raw("?", [orgId]) @@ -131,32 +122,26 @@ export const groupProjectDALFactory = (db: TDbClient) => { const docs = await db(TableName.UserGroupMembership) // Join the GroupProjectMembership table with the Groups table to get the group name and slug. .join( - TableName.GroupProjectMembership, + TableName.Membership, `${TableName.UserGroupMembership}.groupId`, - `${TableName.GroupProjectMembership}.groupId` // this gives us access to the project id in the group membership + `${TableName.Membership}.actorGroupId` // this gives us access to the project id in the group membership ) - - .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) - - .where(`${TableName.GroupProjectMembership}.projectId`, projectId) - + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .join( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) - .join( - TableName.GroupProjectMembershipRole, - `${TableName.GroupProjectMembershipRole}.projectMembershipId`, - `${TableName.GroupProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.GroupProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .join(db(TableName.Membership).as("orgMembership"), (qb) => { + qb.on(`${TableName.Users}.id`, `orgMembership.actorUserId`) + .andOn(`orgMembership.scope`, db.raw("?", [AccessScope.Organization])) + .andOn(`orgMembership.scopeOrgId`, `${TableName.Project}.orgId`); + }) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership), @@ -167,18 +152,18 @@ export const groupProjectDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("role").withSchema(TableName.GroupProjectMembershipRole), - db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), - db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project), - db.ref("isActive").withSchema(TableName.OrgMembership) + db.ref("isActive").withSchema("orgMembership") ) .where({ isGhost: false }); @@ -242,5 +227,5 @@ export const groupProjectDALFactory = (db: TDbClient) => { return members; }; - return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers }; + return { findByProjectId, findByUserId, findAllProjectGroupMembers }; }; diff --git a/backend/src/services/group-project/group-project-membership-role-dal.ts b/backend/src/services/group-project/group-project-membership-role-dal.ts deleted file mode 100644 index 5572ac6f5..000000000 --- a/backend/src/services/group-project/group-project-membership-role-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TGroupProjectMembershipRoleDALFactory = ReturnType; - -export const groupProjectMembershipRoleDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.GroupProjectMembershipRole); - return orm; -}; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 913aca2f3..4a4a61496 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -1,507 +1,27 @@ import { ForbiddenError } from "@casl/ability"; -import { ActionProjectType, ProjectMembershipRole, ProjectVersion, SecretKeyEncoding, TGroups } from "@app/db/schemas"; +import { ActionProjectType } from "@app/db/schemas"; import { TListProjectGroupUsersDTO } from "@app/ee/services/group/group-types"; -import { - constructPermissionErrorMessage, - validatePrivilegeChangeOperation -} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionGroupActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; -import { groupBy } from "@app/lib/fn"; -import { ms } from "@app/lib/ms"; -import { isUuidV4 } from "@app/lib/validator"; +import { NotFoundError } from "@app/lib/errors"; import { TGroupDALFactory } from "../../ee/services/group/group-dal"; -import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; import { TProjectDALFactory } from "../project/project-dal"; -import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; -import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; -import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; -import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; -import { TGroupProjectDALFactory } from "./group-project-dal"; -import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membership-role-dal"; -import { - TCreateProjectGroupDTO, - TDeleteProjectGroupDTO, - TGetGroupInProjectDTO, - TListProjectGroupDTO, - TUpdateProjectGroupDTO -} from "./group-project-types"; type TGroupProjectServiceFactoryDep = { - groupProjectDAL: Pick; - groupProjectMembershipRoleDAL: Pick< - TGroupProjectMembershipRoleDALFactory, - "create" | "transaction" | "insertMany" | "delete" - >; - userGroupMembershipDAL: Pick; - projectDAL: Pick; - projectKeyDAL: Pick; - projectRoleDAL: Pick; - projectBotDAL: TProjectBotDALFactory; groupDAL: Pick; - permissionService: Pick< - TPermissionServiceFactory, - "getProjectPermission" | "getProjectPermissionByRole" | "invalidateProjectPermissionCache" - >; + projectDAL: Pick; + permissionService: Pick; }; export type TGroupProjectServiceFactory = ReturnType; export const groupProjectServiceFactory = ({ groupDAL, - groupProjectDAL, - groupProjectMembershipRoleDAL, - userGroupMembershipDAL, projectDAL, - projectKeyDAL, - projectBotDAL, - projectRoleDAL, permissionService }: TGroupProjectServiceFactoryDep) => { - const addGroupToProject = async ({ - actor, - actorId, - actorOrgId, - actorAuthMethod, - roles, - projectId, - groupIdOrName - }: TCreateProjectGroupDTO) => { - const project = await projectDAL.findById(projectId); - - if (!project) throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); - if (project.version < 2) throw new BadRequestError({ message: `Failed to add group to E2EE project` }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Create, ProjectPermissionSub.Groups); - - let group: TGroups | null = null; - if (isUuidV4(groupIdOrName)) { - group = await groupDAL.findOne({ orgId: actorOrgId, id: groupIdOrName }); - } - if (!group) { - group = await groupDAL.findOne({ orgId: actorOrgId, name: groupIdOrName }); - } - - if (!group) throw new NotFoundError({ message: `Failed to find group with ID or name ${groupIdOrName}` }); - - const existingGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); - if (existingGroup) - throw new BadRequestError({ - message: `Group with ID ${group.id} already exists in project with id ${project.id}` - }); - - for await (const { role: requestedRoleChange } of roles) { - const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( - requestedRoleChange, - project.id - ); - - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionGroupActions.GrantPrivileges, - ProjectPermissionSub.Groups, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to assign group to role", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionGroupActions.GrantPrivileges, - ProjectPermissionSub.Groups - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - } - - // validate custom roles input - const customInputRoles = roles.filter( - ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) - ); - const hasCustomRole = Boolean(customInputRoles.length); - const customRoles = hasCustomRole - ? await projectRoleDAL.find({ - projectId: project.id, - $in: { slug: customInputRoles.map(({ role }) => role) } - }) - : []; - - if (customRoles.length !== customInputRoles.length) { - const customRoleSlugs = customRoles.map((customRole) => customRole.slug); - const missingInputRoles = customInputRoles - .filter((inputRole) => !customRoleSlugs.includes(inputRole.role)) - .map((role) => role.role); - - throw new NotFoundError({ - message: `Custom role/s not found: ${missingInputRoles.join(", ")}` - }); - } - const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - - const projectGroup = await groupProjectDAL.transaction(async (tx) => { - const groupProjectMembership = await groupProjectDAL.create( - { - groupId: group!.id, - projectId: project.id - }, - tx - ); - - const sanitizedProjectMembershipRoles = roles.map((inputRole) => { - const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); - if (!inputRole.isTemporary) { - return { - projectMembershipId: groupProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null - }; - } - - // check cron or relative here later for now its just relative - const relativeTimeInMs = ms(inputRole.temporaryRange); - return { - projectMembershipId: groupProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, - isTemporary: true, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: inputRole.temporaryRange, - temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) - }; - }); - - await groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - - // share project key with users in group that have not - // individually been added to the project and that are not part of - // other groups that are in the project - const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group!.id, project.id, tx); - - if (groupMembers.length && (project.version === ProjectVersion.V1 || project.version === ProjectVersion.V2)) { - const ghostUser = await projectDAL.findProjectGhostUser(project.id, tx); - - if (!ghostUser) { - throw new NotFoundError({ - message: `Failed to find project owner of project with name ${project.name}` - }); - } - - const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, project.id, tx); - - if (!ghostUserLatestKey) { - throw new NotFoundError({ - message: `Failed to find project owner's latest key in project with name ${project.name}` - }); - } - - if (!ghostUserLatestKey.sender.publicKey) { - throw new NotFoundError({ - message: `Failed to find project owner's latest key in project with name ${project.name}` - }); - } - - const bot = await projectBotDAL.findOne({ projectId: project.id }, tx); - - if (!bot) { - throw new NotFoundError({ - message: `Failed to find project bot in project with name ${project.name}` - }); - } - - const botPrivateKey = crypto - .encryption() - .symmetric() - .decryptWithRootEncryptionKey({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey - }); - - const plaintextProjectKey = crypto.encryption().asymmetric().decrypt({ - ciphertext: ghostUserLatestKey.encryptedKey, - nonce: ghostUserLatestKey.nonce, - publicKey: ghostUserLatestKey.sender.publicKey, - privateKey: botPrivateKey - }); - - const projectKeyData = groupMembers.map(({ user: { publicKey, id } }) => { - if (!publicKey) { - throw new NotFoundError({ - message: `Failed to find user's public key in project with name ${project.name}` - }); - } - - const { ciphertext: encryptedKey, nonce } = crypto - .encryption() - .asymmetric() - .encrypt(plaintextProjectKey, publicKey, botPrivateKey); - - return { - encryptedKey, - nonce, - senderId: ghostUser.id, - receiverId: id, - projectId: project.id - }; - }); - - await projectKeyDAL.insertMany(projectKeyData, tx); - } - - return groupProjectMembership; - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return projectGroup; - }; - - const updateGroupInProject = async ({ - projectId, - groupId, - roles, - actor, - actorId, - actorAuthMethod, - actorOrgId - }: TUpdateProjectGroupDTO) => { - const project = await projectDAL.findById(projectId); - - if (!project) throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); - - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Edit, ProjectPermissionSub.Groups); - - const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId }); - if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` }); - - const projectGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); - if (!projectGroup) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` }); - - for await (const { role: requestedRoleChange } of roles) { - const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( - requestedRoleChange, - project.id - ); - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionGroupActions.GrantPrivileges, - ProjectPermissionSub.Groups, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to assign group to role", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionGroupActions.GrantPrivileges, - ProjectPermissionSub.Groups - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - } - - // validate custom roles input - const customInputRoles = roles.filter( - ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) - ); - const hasCustomRole = Boolean(customInputRoles.length); - const customRoles = hasCustomRole - ? await projectRoleDAL.find({ - projectId: project.id, - $in: { slug: customInputRoles.map(({ role }) => role) } - }) - : []; - if (customRoles.length !== customInputRoles.length) { - const customRoleSlugs = customRoles.map((customRole) => customRole.slug); - const missingInputRoles = customInputRoles - .filter((inputRole) => !customRoleSlugs.includes(inputRole.role)) - .map((role) => role.role); - - throw new NotFoundError({ - message: `Custom role/s not found: ${missingInputRoles.join(", ")}` - }); - } - - const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - - const sanitizedProjectMembershipRoles = roles.map((inputRole) => { - const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); - if (!inputRole.isTemporary) { - return { - projectMembershipId: projectGroup.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null - }; - } - - // check cron or relative here later for now its just relative - const relativeTimeInMs = ms(inputRole.temporaryRange); - return { - projectMembershipId: projectGroup.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, - isTemporary: true, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: inputRole.temporaryRange, - temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) - }; - }); - - const updatedRoles = await groupProjectMembershipRoleDAL.transaction(async (tx) => { - await groupProjectMembershipRoleDAL.delete({ projectMembershipId: projectGroup.id }, tx); - return groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return updatedRoles; - }; - - const removeGroupFromProject = async ({ - projectId, - groupId, - actorId, - actor, - actorOrgId, - actorAuthMethod - }: TDeleteProjectGroupDTO) => { - const project = await projectDAL.findById(projectId); - - if (!project) throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); - - const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId }); - if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` }); - - const groupProjectMembership = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); - if (!groupProjectMembership) throw new NotFoundError({ message: `Failed to find group with ID ${groupId}` }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Delete, ProjectPermissionSub.Groups); - - const deletedProjectGroup = await groupProjectDAL.transaction(async (tx) => { - const groupMembersNotInProject = await userGroupMembershipDAL.findGroupMembersNotInProject( - group.id, - project.id, - tx - ); - - if (groupMembersNotInProject.length) { - await projectKeyDAL.delete( - { - projectId: project.id, - $in: { - receiverId: groupMembersNotInProject.map(({ user: { id } }) => id) - } - }, - tx - ); - } - - const [projectGroup] = await groupProjectDAL.delete({ groupId: group.id, projectId: project.id }, tx); - return projectGroup; - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return deletedProjectGroup; - }; - - const listGroupsInProject = async ({ - projectId, - actor, - actorId, - actorAuthMethod, - actorOrgId - }: TListProjectGroupDTO) => { - const project = await projectDAL.findById(projectId); - - if (!project) { - throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); - } - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); - - const groupMemberships = await groupProjectDAL.findByProjectId(project.id); - return groupMemberships; - }; - - const getGroupInProject = async ({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - groupId, - projectId - }: TGetGroupInProjectDTO) => { - const project = await projectDAL.findById(projectId); - - if (!project) { - throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); - } - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); - - const [groupMembership] = await groupProjectDAL.findByProjectId(project.id, { - groupId - }); - - if (!groupMembership) { - throw new NotFoundError({ - message: `Group membership with ID ${groupId} not found in project with ID ${projectId}` - }); - } - - return groupMembership; - }; - const listProjectGroupUsers = async ({ id, projectId, @@ -545,11 +65,6 @@ export const groupProjectServiceFactory = ({ }; return { - addGroupToProject, - updateGroupInProject, - removeGroupFromProject, - listGroupsInProject, - getGroupInProject, listProjectGroupUsers }; }; diff --git a/backend/src/services/health-alert/health-alert-queue.ts b/backend/src/services/health-alert/health-alert-queue.ts new file mode 100644 index 000000000..1d65d0f4d --- /dev/null +++ b/backend/src/services/health-alert/health-alert-queue.ts @@ -0,0 +1,65 @@ +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type THealthAlertServiceFactoryDep = { + queueService: TQueueServiceFactory; + gatewayV2Service: Pick; + relayService: Pick; +}; + +export type THealthAlertServiceFactory = ReturnType; + +export const healthAlertServiceFactory = ({ + queueService, + gatewayV2Service, + relayService +}: THealthAlertServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.HealthAlert, + QueueJobs.HealthAlert, + { pattern: "*/5 * * * *", utc: true }, + QueueName.HealthAlert // job id + ); + + await queueService.startPg( + QueueJobs.HealthAlert, + async () => { + try { + logger.info(`${QueueName.HealthAlert}: health check alert task started`); + await gatewayV2Service.healthcheckNotify(); + await relayService.healthcheckNotify(); + logger.info(`${QueueName.HealthAlert}: health check alert task completed`); + } catch (error) { + logger.error(error, `${QueueName.HealthAlert}: health check alert failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg( + QueueJobs.HealthAlert, + "*/5 * * * *", // Schedule to run every 5 minutes + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 19de362d8..ffdb78645 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -19,6 +19,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) .select(db.ref("name").withSchema(TableName.Identity)) + .select(db.ref("orgId").withSchema(TableName.Identity).as("identityScopeOrgId")) .first(); return doc; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 71f7ec365..02660a0ae 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -1,4 +1,4 @@ -import { IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; @@ -7,27 +7,30 @@ import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip"; import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue"; import { AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types"; type TIdentityAccessTokenServiceFactoryDep = { identityAccessTokenDAL: TIdentityAccessTokenDALFactory; identityDAL: Pick; - identityOrgMembershipDAL: TIdentityOrgDALFactory; accessTokenQueue: Pick< TAccessTokenQueueServiceFactory, "updateIdentityAccessTokenStatus" | "getIdentityTokenDetailsInCache" >; + membershipIdentityDAL: Pick; + orgDAL: Pick; }; export type TIdentityAccessTokenServiceFactory = ReturnType; export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, - identityOrgMembershipDAL, accessTokenQueue, - identityDAL + identityDAL, + membershipIdentityDAL, + orgDAL }: TIdentityAccessTokenServiceFactoryDep) => { const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { @@ -181,7 +184,11 @@ export const identityAccessTokenServiceFactory = ({ return { revokedToken }; }; - const fnValidateIdentityAccessToken = async (token: TIdentityAccessTokenJwtPayload, ipAddress?: string) => { + const fnValidateIdentityAccessToken = async ( + token: TIdentityAccessTokenJwtPayload, + subOrganizationSelector?: string, + ipAddress?: string + ) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, isAccessTokenRevoked: false @@ -202,13 +209,40 @@ export const identityAccessTokenServiceFactory = ({ trustedIps: trustedIps as TIp[] }); } + let orgId = ""; + let parentOrgId = ""; + const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId }); + const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id; - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ - identityId: identityAccessToken.identityId - }); + if (subOrganizationSelector) { + const subOrganization = await orgDAL.findOne({ rootOrgId, slug: subOrganizationSelector }); + if (!subOrganization) + throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); - if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: subOrganization.id + }); + + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to any organization" }); + } + orgId = subOrganization.id; + parentOrgId = subOrganization.parentOrgId as string; + } else { + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: rootOrgId + }); + + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to any organization" }); + } + + orgId = rootOrgId; + parentOrgId = rootOrgId; } let { accessTokenNumUses } = identityAccessToken; @@ -219,7 +253,7 @@ export const identityAccessTokenServiceFactory = ({ await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1); - return { ...identityAccessToken, orgId: identityOrgMembership.orgId }; + return { ...identityAccessToken, orgId, rootOrgId, parentOrgId }; }; return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index af94c79c9..c6f6f1376 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { AxiosError } from "axios"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,14 +13,22 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityAliCloudAuthDALFactory } from "./identity-alicloud-auth-dal"; import { @@ -33,24 +41,28 @@ import { } from "./identity-alicloud-auth-types"; type TIdentityAliCloudAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityAliCloudAuthDAL: Pick< TIdentityAliCloudAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; + orgDAL: Pick; }; export type TIdentityAliCloudAuthServiceFactory = ReturnType; export const identityAliCloudAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityAliCloudAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, licenseService, - permissionService + permissionService, + orgDAL }: TIdentityAliCloudAuthServiceFactoryDep) => { const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); @@ -60,11 +72,8 @@ export const identityAliCloudAuthServiceFactory = ({ }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ - identityId: identityAliCloudAuth.identityId - }); - - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityAliCloudAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const requestUrl = new URL("https://sts.aliyuncs.com"); @@ -89,8 +98,8 @@ export const identityAliCloudAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, lastLoginTime: new Date() @@ -131,7 +140,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityAliCloudAuth, accessToken, identityAccessToken, - identityMembershipOrg + identity }; }; @@ -150,8 +159,17 @@ export const identityAliCloudAuthServiceFactory = ({ }: TAttachAliCloudAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new BadRequestError({ @@ -163,16 +181,17 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -193,7 +212,7 @@ export const identityAliCloudAuthServiceFactory = ({ const identityAliCloudAuth = await identityAliCloudAuthDAL.transaction(async (tx) => { const doc = await identityAliCloudAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, type: "iam", allowedArns, accessTokenMaxTTL, @@ -205,7 +224,7 @@ export const identityAliCloudAuthServiceFactory = ({ ); return doc; }); - return { ...identityAliCloudAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityAliCloudAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateAliCloudAuth = async ({ @@ -220,8 +239,17 @@ export const identityAliCloudAuthServiceFactory = ({ actor, actorOrgId }: TUpdateAliCloudAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new NotFoundError({ @@ -239,16 +267,17 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -276,12 +305,21 @@ export const identityAliCloudAuthServiceFactory = ({ : undefined }); - return { ...updatedAliCloudAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedAliCloudAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const getAliCloudAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAliCloudAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new BadRequestError({ @@ -291,15 +329,16 @@ export const identityAliCloudAuthServiceFactory = ({ const alicloudIdentityAuth = await identityAliCloudAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...alicloudIdentityAuth, orgId: identityMembershipOrg.orgId }; + return { ...alicloudIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityAliCloudAuth = async ({ @@ -309,32 +348,44 @@ export const identityAliCloudAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeAliCloudAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { throw new BadRequestError({ message: "The identity does not have Alibaba Cloud auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -345,7 +396,7 @@ export const identityAliCloudAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke Alibaba Cloud auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -356,7 +407,7 @@ export const identityAliCloudAuthServiceFactory = ({ const deletedAliCloudAuth = await identityAliCloudAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.ALICLOUD_AUTH }, tx); - return { ...deletedAliCloudAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedAliCloudAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityAliCloudAuth; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts index d0fb4d323..a028bc25f 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts @@ -2,13 +2,13 @@ interface PrincipalArnEntity { Partition: string; Service: "iam" | "sts"; AccountNumber: string; - Type: "user" | "role" | "instance-profile"; + Type: "user" | "role" | "instance-profile" | "assumed-role"; Path: string; FriendlyName: string; SessionInfo: string; // Only populated for assumed-role } -export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { +export const extractPrincipalArnEntity = (arn: string, formatAsIamRole: boolean = false): PrincipalArnEntity => { // split the ARN into parts using ":" as the delimiter const fullParts = arn.split(":"); if (fullParts.length !== 6) { @@ -49,7 +49,7 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { } // assumed roles use a special format where the friendly name is the role name const [roleName, sessionId] = rest; - finalType = "role"; // treat assumed role case as role + finalType = formatAsIamRole ? "role" : "assumed-role"; friendlyName = roleName; sessionInfo = sessionId; break; @@ -83,9 +83,11 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { * Extracts the identity ARN from the GetCallerIdentity response to one of the following formats: * - arn:aws:iam::123456789012:user/MyUserName * - arn:aws:iam::123456789012:role/MyRoleName + * - arn:aws-us-gov:iam::123456789012:user/MyUserName (GovCloud) + * - arn:aws-us-gov:iam::123456789012:role/MyRoleName (GovCloud) */ -export const extractPrincipalArn = (arn: string) => { - const entity = extractPrincipalArnEntity(arn); +export const extractPrincipalArn = (arn: string, formatAsIamRole: boolean = false) => { + const entity = extractPrincipalArnEntity(arn, formatAsIamRole); - return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; + return `arn:${entity.Partition}:${formatAsIamRole ? "iam" : entity.Service}::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 3dff47403..e61e8296b 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import axios from "axios"; import RE2 from "re2"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,13 +13,22 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityAwsAuthDALFactory } from "./identity-aws-auth-dal"; import { extractPrincipalArn, extractPrincipalArnEntity } from "./identity-aws-auth-fns"; @@ -34,11 +43,13 @@ import { } from "./identity-aws-auth-types"; type TIdentityAwsAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityAwsAuthDAL: Pick; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; + orgDAL: Pick; }; export type TIdentityAwsAuthServiceFactory = ReturnType; @@ -78,11 +89,13 @@ function isValidAwsRegion(region: string | null): boolean { } export const identityAwsAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityAwsAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, licenseService, - permissionService + permissionService, + orgDAL }: TIdentityAwsAuthServiceFactoryDep) => { const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); @@ -90,8 +103,8 @@ export const identityAwsAuthServiceFactory = ({ throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAwsAuth.identityId }); - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityAwsAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); @@ -135,6 +148,8 @@ export const identityAwsAuthServiceFactory = ({ if (identityAwsAuth.allowedPrincipalArns) { // validate if Arn is in the list of allowed Principal ARNs + const formattedArn = extractPrincipalArn(Arn); + const isArnAllowed = identityAwsAuth.allowedPrincipalArns .split(",") .map((principalArn) => principalArn.trim()) @@ -143,18 +158,23 @@ export const identityAwsAuthServiceFactory = ({ // considers exact matches + wildcard matches // heavily validated in router const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`); - return regex.test(extractPrincipalArn(Arn)); + return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true)); }); - if (!isArnAllowed) + if (!isArnAllowed) { + logger.error( + `AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]` + ); + throw new UnauthorizedError({ - message: "Access denied: AWS principal ARN not allowed." + message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` }); + } } const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, lastLoginTime: new Date() @@ -206,7 +226,7 @@ export const identityAwsAuthServiceFactory = ({ } ); - return { accessToken, identityAwsAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityAwsAuth, identityAccessToken, identity }; }; const attachAwsAuth = async ({ @@ -226,8 +246,17 @@ export const identityAwsAuthServiceFactory = ({ }: TAttachAwsAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new BadRequestError({ @@ -239,16 +268,17 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -269,7 +299,7 @@ export const identityAwsAuthServiceFactory = ({ const identityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { const doc = await identityAwsAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, type: "iam", stsEndpoint, allowedPrincipalArns, @@ -283,7 +313,7 @@ export const identityAwsAuthServiceFactory = ({ ); return doc; }); - return { ...identityAwsAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityAwsAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateAwsAuth = async ({ @@ -300,8 +330,17 @@ export const identityAwsAuthServiceFactory = ({ actor, actorOrgId }: TUpdateAwsAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new NotFoundError({ @@ -318,16 +357,17 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -357,12 +397,21 @@ export const identityAwsAuthServiceFactory = ({ : undefined }); - return { ...updatedAwsAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedAwsAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const getAwsAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAwsAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new BadRequestError({ @@ -372,15 +421,16 @@ export const identityAwsAuthServiceFactory = ({ const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...awsIdentityAuth, orgId: identityMembershipOrg.orgId }; + return { ...awsIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityAwsAuth = async ({ @@ -390,32 +440,44 @@ export const identityAwsAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeAwsAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { throw new BadRequestError({ message: "The identity does not have aws auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -426,7 +488,7 @@ export const identityAwsAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke aws auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -437,7 +499,7 @@ export const identityAwsAuthServiceFactory = ({ const deletedAwsAuth = await identityAwsAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.AWS_AUTH }, tx); - return { ...deletedAwsAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedAwsAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityAwsAuth; }; diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts index 098bdcf9a..ed7f6696a 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -4,7 +4,10 @@ import { z } from "zod"; const twelveDigitRegex = new RE2(/^\d{12}$/); // akhilmhdh: change this to a normal function later. Checked no redosable at the moment -const arnRegex = new RE2(/^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/); + +const arnRegex = new RE2( + /^arn:aws(?:-us-gov)?:(iam|sts)::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|assumed-role\/[a-zA-Z0-9_.@+*/-]+|\*)$/ +); export const validateAccountIds = z .string() @@ -52,7 +55,7 @@ export const validatePrincipalArns = z }, { message: - "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'." + "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*', 'arn:aws:sts::123456789012:assumed-role/RoleName'. GovCloud ARNs (arn:aws-us-gov:...) are also supported." } ) // Transform to normalize the spaces around commas diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 9a2426a7c..f75aeba4f 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -10,13 +10,21 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal"; import { validateAzureIdentity } from "./identity-azure-auth-fns"; @@ -29,24 +37,28 @@ import { } from "./identity-azure-auth-types"; type TIdentityAzureAuthServiceFactoryDep = { + identityDAL: Pick; identityAzureAuthDAL: Pick< TIdentityAzureAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; + orgDAL: Pick; }; export type TIdentityAzureAuthServiceFactory = ReturnType; export const identityAzureAuthServiceFactory = ({ + identityDAL, identityAzureAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, identityAccessTokenDAL, permissionService, - licenseService + licenseService, + orgDAL }: TIdentityAzureAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); @@ -54,8 +66,8 @@ export const identityAzureAuthServiceFactory = ({ throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAzureAuth.identityId }); - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityAzureAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const azureIdentity = await validateAzureIdentity({ tenantId: identityAzureAuth.tenantId, @@ -80,8 +92,8 @@ export const identityAzureAuthServiceFactory = ({ } const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, lastLoginTime: new Date() @@ -119,7 +131,7 @@ export const identityAzureAuthServiceFactory = ({ } ); - return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityAzureAuth, identityAccessToken, identity }; }; const attachAzureAuth = async ({ @@ -139,8 +151,17 @@ export const identityAzureAuthServiceFactory = ({ }: TAttachAzureAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ @@ -151,16 +172,17 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -181,7 +203,7 @@ export const identityAzureAuthServiceFactory = ({ const identityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { const doc = await identityAzureAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, tenantId, resource, allowedServicePrincipalIds, @@ -195,7 +217,7 @@ export const identityAzureAuthServiceFactory = ({ return doc; }); - return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityAzureAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateAzureAuth = async ({ @@ -212,8 +234,17 @@ export const identityAzureAuthServiceFactory = ({ actor, actorOrgId }: TUpdateAzureAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ message: "Failed to update Azure Auth" @@ -229,16 +260,17 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -270,13 +302,22 @@ export const identityAzureAuthServiceFactory = ({ return { ...updatedAzureAuth, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }; }; const getAzureAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAzureAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ message: "The identity does not have Azure Auth attached" @@ -285,16 +326,17 @@ export const identityAzureAuthServiceFactory = ({ const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityAzureAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityAzureAuth = async ({ @@ -304,31 +346,43 @@ export const identityAzureAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeAzureAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { throw new BadRequestError({ message: "The identity does not have azure auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -338,7 +392,7 @@ export const identityAzureAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke azure auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -349,7 +403,7 @@ export const identityAzureAuthServiceFactory = ({ const deletedAzureAuth = await identityAzureAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.AZURE_AUTH }, tx); - return { ...deletedAzureAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedAzureAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityAzureAuth; }; diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 388c24d48..67adb6c1e 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -10,13 +10,21 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityGcpAuthDALFactory } from "./identity-gcp-auth-dal"; import { validateIamIdentity, validateIdTokenIdentity } from "./identity-gcp-auth-fns"; @@ -30,21 +38,25 @@ import { } from "./identity-gcp-auth-types"; type TIdentityGcpAuthServiceFactoryDep = { + identityDAL: Pick; identityGcpAuthDAL: Pick; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; + orgDAL: Pick; }; export type TIdentityGcpAuthServiceFactory = ReturnType; export const identityGcpAuthServiceFactory = ({ + identityDAL, identityGcpAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, identityAccessTokenDAL, permissionService, - licenseService + licenseService, + orgDAL }: TIdentityGcpAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); @@ -52,10 +64,8 @@ export const identityGcpAuthServiceFactory = ({ throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityGcpAuth.identityId }); - if (!identityMembershipOrg) { - throw new UnauthorizedError({ message: "Identity does not belong to any organization" }); - } + const identity = await identityDAL.findById(identityGcpAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); let gcpIdentityDetails: TGcpIdentityDetails; switch (identityGcpAuth.type) { @@ -119,8 +129,8 @@ export const identityGcpAuthServiceFactory = ({ } const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, lastLoginTime: new Date() @@ -158,7 +168,7 @@ export const identityGcpAuthServiceFactory = ({ } ); - return { accessToken, identityGcpAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityGcpAuth, identityAccessToken, identity }; }; const attachGcpAuth = async ({ @@ -179,8 +189,17 @@ export const identityGcpAuthServiceFactory = ({ }: TAttachGcpAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ @@ -192,16 +211,17 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -222,7 +242,7 @@ export const identityGcpAuthServiceFactory = ({ const identityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { const doc = await identityGcpAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, type, allowedServiceAccounts, allowedProjects, @@ -236,7 +256,7 @@ export const identityGcpAuthServiceFactory = ({ ); return doc; }); - return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityGcpAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateGcpAuth = async ({ @@ -254,8 +274,17 @@ export const identityGcpAuthServiceFactory = ({ actor, actorOrgId }: TUpdateGcpAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ @@ -272,16 +301,17 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -314,13 +344,22 @@ export const identityGcpAuthServiceFactory = ({ return { ...updatedGcpAuth, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }; }; const getGcpAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetGcpAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ @@ -330,16 +369,17 @@ export const identityGcpAuthServiceFactory = ({ const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...identityGcpAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityGcpAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityGcpAuth = async ({ @@ -349,32 +389,44 @@ export const identityGcpAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeGcpAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.GCP_AUTH)) { throw new BadRequestError({ message: "The identity does not have gcp auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -384,7 +436,7 @@ export const identityGcpAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke gcp auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -395,7 +447,7 @@ export const identityGcpAuthServiceFactory = ({ const deletedGcpAuth = await identityGcpAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.GCP_AUTH }, tx); - return { ...deletedGcpAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedGcpAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityGcpAuth; }; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 9cc851437..debd90933 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -3,7 +3,7 @@ import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -24,11 +24,13 @@ import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; import { doesFieldValueMatchJwtPolicy } from "./identity-jwt-auth-fns"; @@ -42,23 +44,27 @@ import { } from "./identity-jwt-auth-types"; type TIdentityJwtAuthServiceFactoryDep = { + identityDAL: Pick; identityJwtAuthDAL: TIdentityJwtAuthDALFactory; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; + orgDAL: Pick; }; export type TIdentityJwtAuthServiceFactory = ReturnType; export const identityJwtAuthServiceFactory = ({ + identityDAL, identityJwtAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, permissionService, licenseService, identityAccessTokenDAL, - kmsService + kmsService, + orgDAL }: TIdentityJwtAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); @@ -66,18 +72,12 @@ export const identityJwtAuthServiceFactory = ({ throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ - identityId: identityJwtAuth.identityId - }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityJwtAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityJwtAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identity.orgId }); const decodedToken = crypto.jwt().decode(jwtValue, { complete: true }); @@ -207,12 +207,9 @@ export const identityJwtAuthServiceFactory = ({ } const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -247,7 +244,7 @@ export const identityJwtAuthServiceFactory = ({ } ); - return { accessToken, identityJwtAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityJwtAuth, identityAccessToken, identity }; }; const attachJwtAuth = async ({ @@ -272,9 +269,16 @@ export const identityJwtAuthServiceFactory = ({ }: TAttachJwtAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); - if (!identityMembershipOrg) { - if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -286,17 +290,18 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -330,7 +335,7 @@ export const identityJwtAuthServiceFactory = ({ const identityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { const doc = await identityJwtAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, configurationType, jwksUrl, encryptedJwksCaCert, @@ -349,7 +354,7 @@ export const identityJwtAuthServiceFactory = ({ return doc; }); - return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys }; + return { ...identityJwtAuth, orgId: identityMembershipOrg.scopeOrgId, jwksCaCert, publicKeys }; }; const updateJwtAuth = async ({ @@ -371,8 +376,17 @@ export const identityJwtAuthServiceFactory = ({ actor, actorOrgId }: TUpdateJwtAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -389,17 +403,18 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -462,15 +477,24 @@ export const identityJwtAuthServiceFactory = ({ return { ...updatedJwtAuth, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, jwksCaCert: decryptedJwksCaCert, publicKeys: decryptedPublicKeys }; }; const getJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetJwtAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -478,13 +502,14 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); @@ -502,17 +527,26 @@ export const identityJwtAuthServiceFactory = ({ return { ...identityJwtAuth, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, jwksCaCert: decryptedJwksCaCert, publicKeys: decryptedPublicKeys }; }; const revokeJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TRevokeJwtAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { throw new BadRequestError({ @@ -520,26 +554,29 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -549,7 +586,7 @@ export const identityJwtAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke jwt auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -560,7 +597,7 @@ export const identityJwtAuthServiceFactory = ({ const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx); - return { ...deletedJwtAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedJwtAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityJwtAuth; diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index bc231c6d6..49fb597f5 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -3,7 +3,12 @@ import axios, { AxiosError } from "axios"; import https from "https"; import RE2 from "re2"; -import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; +import { + AccessScope, + IdentityAuthMethod, + OrganizationActionScope, + TIdentityKubernetesAuthsUpdate +} from "@app/db/schemas"; import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayV2DALFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; @@ -21,18 +26,26 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; @@ -47,12 +60,13 @@ import { } from "./identity-kubernetes-auth-types"; type TIdentityKubernetesAuthServiceFactoryDep = { + identityDAL: Pick; identityKubernetesAuthDAL: Pick< TIdentityKubernetesAuthDALFactory, "create" | "findOne" | "transaction" | "updateById" | "delete" >; identityAccessTokenDAL: Pick; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -60,6 +74,7 @@ type TIdentityKubernetesAuthServiceFactoryDep = { gatewayV2Service: TGatewayV2ServiceFactory; gatewayDAL: Pick; gatewayV2DAL: Pick; + orgDAL: Pick; }; export type TIdentityKubernetesAuthServiceFactory = ReturnType; @@ -67,8 +82,9 @@ export type TIdentityKubernetesAuthServiceFactory = ReturnType { const $gatewayProxyWrapper = async ( inputs: { @@ -172,18 +189,12 @@ export const identityKubernetesAuthServiceFactory = ({ }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ - identityId: identityKubernetesAuth.identityId - }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityKubernetesAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityKubernetesAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identity.orgId }); let caCert = ""; @@ -426,12 +437,9 @@ export const identityKubernetesAuthServiceFactory = ({ } const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -471,7 +479,7 @@ export const identityKubernetesAuthServiceFactory = ({ } ); - return { accessToken, identityKubernetesAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; }; const attachKubernetesAuth = async ({ @@ -496,8 +504,17 @@ export const identityKubernetesAuthServiceFactory = ({ }: TAttachKubernetesAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.KUBERNETES_AUTH)) { throw new BadRequestError({ @@ -509,16 +526,17 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -538,8 +556,8 @@ export const identityKubernetesAuthServiceFactory = ({ let isGatewayV1 = true; if (gatewayId) { - const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); - const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.scopeOrgId }); + const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: identityMembershipOrg.scopeOrgId }); if (!gateway && !gatewayV2) { throw new NotFoundError({ message: `Gateway with ID ${gatewayId} not found` @@ -550,13 +568,14 @@ export const identityKubernetesAuthServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway @@ -565,13 +584,13 @@ export const identityKubernetesAuthServiceFactory = ({ const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const identityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { const doc = await identityKubernetesAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, kubernetesHost, tokenReviewMode, allowedNamespaces, @@ -593,7 +612,7 @@ export const identityKubernetesAuthServiceFactory = ({ return doc; }); - return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.orgId }; + return { ...identityKubernetesAuth, caCert, tokenReviewerJwt, orgId: identityMembershipOrg.scopeOrgId }; }; const updateKubernetesAuth = async ({ @@ -615,8 +634,17 @@ export const identityKubernetesAuthServiceFactory = ({ actor, actorOrgId }: TUpdateKubernetesAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.KUBERNETES_AUTH)) { throw new BadRequestError({ @@ -634,16 +662,17 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -663,8 +692,8 @@ export const identityKubernetesAuthServiceFactory = ({ let isGatewayV1 = true; if (gatewayId) { - const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); - const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: identityMembershipOrg.orgId }); + const [gateway] = await gatewayDAL.find({ id: gatewayId, orgId: identityMembershipOrg.scopeOrgId }); + const [gatewayV2] = await gatewayV2DAL.find({ id: gatewayId, orgId: identityMembershipOrg.scopeOrgId }); if (!gateway && !gatewayV2) { throw new NotFoundError({ @@ -676,13 +705,14 @@ export const identityKubernetesAuthServiceFactory = ({ isGatewayV1 = false; } - const { permission: orgPermission } = await permissionService.getOrgPermission( + const { permission: orgPermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway @@ -711,7 +741,7 @@ export const identityKubernetesAuthServiceFactory = ({ const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); if (caCert !== undefined) { @@ -742,7 +772,7 @@ export const identityKubernetesAuthServiceFactory = ({ return { ...updatedKubernetesAuth, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, caCert: updatedCACert, tokenReviewerJwt: updatedTokenReviewerJwt }; @@ -755,8 +785,17 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetKubernetesAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { @@ -769,18 +808,19 @@ export const identityKubernetesAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); let caCert = ""; @@ -799,7 +839,7 @@ export const identityKubernetesAuthServiceFactory = ({ ...identityKubernetesAuth, caCert, tokenReviewerJwt, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, gatewayId: identityKubernetesAuth.gatewayId ?? identityKubernetesAuth.gatewayV2Id }; }; @@ -811,32 +851,44 @@ export const identityKubernetesAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeKubernetesAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.KUBERNETES_AUTH)) { throw new BadRequestError({ message: "The identity does not have kubernetes auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -846,7 +898,7 @@ export const identityKubernetesAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke kubernetes auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -856,7 +908,7 @@ export const identityKubernetesAuthServiceFactory = ({ const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { const deletedKubernetesAuth = await identityKubernetesAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.KUBERNETES_AUTH }, tx); - return { ...deletedKubernetesAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedKubernetesAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityKubernetesAuth; }; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 46ec2f98a..272e45c4e 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -21,6 +21,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, + ForbiddenRequestError, NotFoundError, PermissionBoundaryError, RateLimitError, @@ -31,11 +32,12 @@ import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal"; import { @@ -55,16 +57,17 @@ type TIdentityLdapAuthServiceFactoryDep = { TIdentityLdapAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; kmsService: TKmsServiceFactory; - identityDAL: TIdentityDALFactory; + identityDAL: Pick; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; keyStore: Pick< TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" >; + orgDAL: Pick; }; export type TIdentityLdapAuthServiceFactory = ReturnType; @@ -78,18 +81,22 @@ export const identityLdapAuthServiceFactory = ({ identityAccessTokenDAL, identityDAL, identityLdapAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, licenseService, permissionService, kmsService, identityAuthTemplateDAL, - keyStore + keyStore, + orgDAL }: TIdentityLdapAuthServiceFactoryDep) => { const getLdapConfig = async (identityId: string) => { const identity = await identityDAL.findOne({ id: identityId }); if (!identity) throw new NotFoundError({ message: `Identity with ID '${identityId}' not found` }); - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: identity.id }); + const identityOrgMembership = await membershipIdentityDAL.findOne({ + actorIdentityId: identity.id, + scope: AccessScope.Organization + }); if (!identityOrgMembership) throw new NotFoundError({ message: `Identity with ID '${identityId}' not found` }); const ldapAuth = await identityLdapAuthDAL.findOne({ identityId: identity.id }); @@ -101,7 +108,7 @@ export const identityLdapAuthServiceFactory = ({ const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityOrgMembership.orgId + orgId: identityOrgMembership.scopeOrgId }); const bindDN = decryptor({ cipherTextBlob: ldapAuth.encryptedBindDN }).toString(); @@ -112,7 +119,7 @@ export const identityLdapAuthServiceFactory = ({ const ldapConfig = { id: ldapAuth.id, - organization: identityOrgMembership.orgId, + organization: identityOrgMembership.scopeOrgId, url: ldapAuth.url, bindDN, bindPass, @@ -144,14 +151,6 @@ export const identityLdapAuthServiceFactory = ({ }; const login = async ({ identityId }: TLoginLdapAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); - - if (!identityMembershipOrg) { - throw new UnauthorizedError({ - message: "Invalid credentials" - }); - } - const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { @@ -160,7 +159,10 @@ export const identityLdapAuthServiceFactory = ({ }); } - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const identity = await identityDAL.findById(identityLdapAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + + const plan = await licenseService.getPlan(identity.orgId); if (!plan.ldap) { throw new BadRequestError({ message: @@ -169,12 +171,9 @@ export const identityLdapAuthServiceFactory = ({ } const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -208,7 +207,7 @@ export const identityLdapAuthServiceFactory = ({ } ); - return { accessToken, identityLdapAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityLdapAuth, identityAccessToken, identity }; }; const attachLdapAuth = async ({ @@ -237,8 +236,17 @@ export const identityLdapAuthServiceFactory = ({ }: TAttachLdapAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new BadRequestError({ @@ -250,13 +258,14 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); if (templateId) { @@ -266,7 +275,7 @@ export const identityLdapAuthServiceFactory = ({ ); } - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); if (!plan.ldap) { throw new BadRequestError({ @@ -296,11 +305,11 @@ export const identityLdapAuthServiceFactory = ({ const identityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const template = templateId - ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId) + ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.scopeOrgId) : undefined; let ldapConfig: { bindDN: string; bindPass: string; searchBase: string; url: string; ldapCaCertificate?: string }; @@ -354,7 +363,7 @@ export const identityLdapAuthServiceFactory = ({ const doc = await identityLdapAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, encryptedBindDN, encryptedBindPass, searchBase: ldapConfig.searchBase, @@ -376,7 +385,7 @@ export const identityLdapAuthServiceFactory = ({ ); return doc; }); - return { ...identityLdapAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityLdapAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateLdapAuth = async ({ @@ -402,8 +411,17 @@ export const identityLdapAuthServiceFactory = ({ lockoutDurationSeconds, lockoutCounterResetSeconds }: TUpdateLdapAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new NotFoundError({ @@ -420,13 +438,14 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); if (templateId) { @@ -436,7 +455,7 @@ export const identityLdapAuthServiceFactory = ({ ); } - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); if (!plan.ldap) { throw new BadRequestError({ @@ -465,11 +484,11 @@ export const identityLdapAuthServiceFactory = ({ const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const template = templateId - ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.orgId) + ? await identityAuthTemplateDAL.findByIdAndOrgId(templateId, identityMembershipOrg.scopeOrgId) : undefined; let config: { bindDN?: string; @@ -555,12 +574,21 @@ export const identityLdapAuthServiceFactory = ({ lockoutCounterResetSeconds }); - return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedLdapAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const getLdapAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetLdapAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new BadRequestError({ @@ -570,17 +598,18 @@ export const identityLdapAuthServiceFactory = ({ const ldapIdentityAuth = await identityLdapAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const bindDN = decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedBindDN }).toString(); @@ -590,7 +619,7 @@ export const identityLdapAuthServiceFactory = ({ : undefined; ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...ldapIdentityAuth, orgId: identityMembershipOrg.orgId, bindDN, bindPass, ldapCaCertificate }; + return { ...ldapIdentityAuth, orgId: identityMembershipOrg.scopeOrgId, bindDN, bindPass, ldapCaCertificate }; }; const revokeIdentityLdapAuth = async ({ @@ -600,32 +629,44 @@ export const identityLdapAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeLdapAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { throw new BadRequestError({ message: "The identity does not have LDAP Auth attached" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -636,7 +677,7 @@ export const identityLdapAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke LDAP auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -647,7 +688,7 @@ export const identityLdapAuthServiceFactory = ({ const [deletedLdapAuth] = await identityLdapAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.LDAP_AUTH }, tx); - return { ...deletedLdapAuth, orgId: identityMembershipOrg.orgId }; + return { ...deletedLdapAuth, orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityLdapAuth; }; @@ -736,7 +777,13 @@ export const identityLdapAuthServiceFactory = ({ actorOrgId, actorAuthMethod }: TClearLdapAuthLockoutsDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { @@ -745,20 +792,21 @@ export const identityLdapAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const deleted = await keyStore.deleteItems({ pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*` }); - return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + return { deleted, identityId, orgId: identityMembershipOrg.scopeOrgId }; }; return { diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index a4294250c..6d7f0c4d3 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -3,7 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { AxiosError } from "axios"; import RE2 from "re2"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -14,14 +14,22 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityOciAuthDALFactory } from "./identity-oci-auth-dal"; import { @@ -34,21 +42,25 @@ import { } from "./identity-oci-auth-types"; type TIdentityOciAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityOciAuthDAL: Pick; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; + orgDAL: Pick; }; export type TIdentityOciAuthServiceFactory = ReturnType; export const identityOciAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityOciAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, licenseService, - permissionService + permissionService, + orgDAL }: TIdentityOciAuthServiceFactoryDep) => { const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); @@ -56,8 +68,8 @@ export const identityOciAuthServiceFactory = ({ throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityOciAuth.identityId }); - if (!identityMembershipOrg) throw new UnauthorizedError({ message: "Identity not attached to a organization" }); + const identity = await identityDAL.findById(identityOciAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { @@ -92,12 +104,9 @@ export const identityOciAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -134,7 +143,7 @@ export const identityOciAuthServiceFactory = ({ identityOciAuth, accessToken, identityAccessToken, - identityMembershipOrg + identity }; }; @@ -154,8 +163,17 @@ export const identityOciAuthServiceFactory = ({ }: TAttachOciAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new BadRequestError({ @@ -167,16 +185,17 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -197,7 +216,7 @@ export const identityOciAuthServiceFactory = ({ const identityOciAuth = await identityOciAuthDAL.transaction(async (tx) => { const doc = await identityOciAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, type: "iam", tenancyOcid, allowedUsernames, @@ -210,7 +229,7 @@ export const identityOciAuthServiceFactory = ({ ); return doc; }); - return { ...identityOciAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityOciAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateOciAuth = async ({ @@ -226,8 +245,17 @@ export const identityOciAuthServiceFactory = ({ actor, actorOrgId }: TUpdateOciAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new NotFoundError({ @@ -244,16 +272,17 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -282,12 +311,21 @@ export const identityOciAuthServiceFactory = ({ : undefined }); - return { ...updatedOciAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedOciAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const getOciAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetOciAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new BadRequestError({ @@ -297,15 +335,16 @@ export const identityOciAuthServiceFactory = ({ const ociIdentityAuth = await identityOciAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...ociIdentityAuth, orgId: identityMembershipOrg.orgId }; + return { ...ociIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityOciAuth = async ({ @@ -315,32 +354,44 @@ export const identityOciAuthServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeOciAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { throw new BadRequestError({ message: "The identity does not have OCI auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -351,7 +402,7 @@ export const identityOciAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke OCI auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -362,7 +413,7 @@ export const identityOciAuthServiceFactory = ({ const deletedOciAuth = await identityOciAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OCI_AUTH }, tx); - return { ...deletedOciAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedOciAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityOciAuth; }; diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 6585e61f3..628b69f14 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -4,7 +4,7 @@ import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { IdentityAuthMethod, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -25,11 +25,13 @@ import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityOidcAuthDALFactory } from "./identity-oidc-auth-dal"; import { doesAudValueMatchOidcPolicy, doesFieldValueMatchOidcPolicy } from "./identity-oidc-auth-fns"; @@ -42,23 +44,27 @@ import { } from "./identity-oidc-auth-types"; type TIdentityOidcAuthServiceFactoryDep = { + identityDAL: Pick; identityOidcAuthDAL: TIdentityOidcAuthDALFactory; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; + orgDAL: Pick; }; export type TIdentityOidcAuthServiceFactory = ReturnType; export const identityOidcAuthServiceFactory = ({ + identityDAL, identityOidcAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, permissionService, licenseService, identityAccessTokenDAL, - kmsService + kmsService, + orgDAL }: TIdentityOidcAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); @@ -66,18 +72,12 @@ export const identityOidcAuthServiceFactory = ({ throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ - identityId: identityOidcAuth.identityId - }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityOidcAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityOidcAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identity.orgId }); let caCert = ""; @@ -178,12 +178,9 @@ export const identityOidcAuthServiceFactory = ({ } const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -222,7 +219,7 @@ export const identityOidcAuthServiceFactory = ({ } ); - return { accessToken, identityOidcAuth, identityAccessToken, identityMembershipOrg, oidcTokenData: tokenData }; + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; }; const attachOidcAuth = async ({ @@ -245,9 +242,18 @@ export const identityOidcAuthServiceFactory = ({ isActorSuperAdmin }: TAttachOidcAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) { - if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -259,17 +265,18 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -289,13 +296,13 @@ export const identityOidcAuthServiceFactory = ({ const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const identityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { const doc = await identityOidcAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, oidcDiscoveryUrl, encryptedCaCertificate: encryptor({ plainText: Buffer.from(caCert) }).cipherTextBlob, boundIssuer, @@ -312,7 +319,7 @@ export const identityOidcAuthServiceFactory = ({ ); return doc; }); - return { ...identityOidcAuth, orgId: identityMembershipOrg.orgId, caCert }; + return { ...identityOidcAuth, orgId: identityMembershipOrg.scopeOrgId, caCert }; }; const updateOidcAuth = async ({ @@ -333,8 +340,17 @@ export const identityOidcAuthServiceFactory = ({ actor, actorOrgId }: TUpdateOidcAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -351,17 +367,18 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -396,7 +413,7 @@ export const identityOidcAuthServiceFactory = ({ const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); if (caCert !== undefined) { @@ -410,14 +427,23 @@ export const identityOidcAuthServiceFactory = ({ return { ...updatedOidcAuth, - orgId: identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, caCert: updatedCACert }; }; const getOidcAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetOidcAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -425,34 +451,44 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const caCert = identityOidcAuth.encryptedCaCertificate ? decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString() : ""; - return { ...identityOidcAuth, orgId: identityMembershipOrg.orgId, caCert }; + return { ...identityOidcAuth, orgId: identityMembershipOrg.scopeOrgId, caCert }; }; const revokeOidcAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TRevokeOidcAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { throw new BadRequestError({ @@ -460,26 +496,29 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -490,7 +529,7 @@ export const identityOidcAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke oidc auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -501,7 +540,7 @@ export const identityOidcAuthServiceFactory = ({ const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OIDC_AUTH }, tx); - return { ...deletedOidcAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedOidcAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityOidcAuth; diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index e5e59607d..adcdd8be8 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + AccessScope, TableName, TIdentities, TIdentityAlicloudAuths, @@ -15,7 +16,7 @@ import { TIdentityUniversalAuths } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; import { ProjectIdentityOrderBy, TListProjectIdentityDTO } from "@app/services/identity-project/identity-project-types"; @@ -24,96 +25,83 @@ import { buildAuthMethods } from "../identity/identity-fns"; export type TIdentityProjectDALFactory = ReturnType; export const identityProjectDALFactory = (db: TDbClient) => { - const identityProjectOrm = ormify(db, TableName.IdentityProjectMembership); - - const findByIdentityId = async (identityId: string, tx?: Knex) => { + const findByIdentityId = async (identityId: string, orgId: string, tx?: Knex) => { try { - const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership) - .where(`${TableName.IdentityProjectMembership}.identityId`, identityId) - .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) - .join( - TableName.IdentityProjectMembershipRole, - `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, - `${TableName.IdentityProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.IdentityProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin( - TableName.IdentityProjectAdditionalPrivilege, - `${TableName.IdentityProjectMembership}.id`, - `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId` - ) - + const docs = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.actorIdentityId`, identityId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin( TableName.IdentityUniversalAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityUniversalAuth}.identityId` ) .leftJoin( TableName.IdentityGcpAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityGcpAuth}.identityId` ) .leftJoin( TableName.IdentityAliCloudAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAliCloudAuth}.identityId` ) .leftJoin( TableName.IdentityAwsAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAwsAuth}.identityId` ) .leftJoin( TableName.IdentityKubernetesAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityKubernetesAuth}.identityId` ) .leftJoin( TableName.IdentityOciAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityOciAuth}.identityId` ) .leftJoin( TableName.IdentityOidcAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityOidcAuth}.identityId` ) .leftJoin( TableName.IdentityAzureAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAzureAuth}.identityId` ) .leftJoin( TableName.IdentityTokenAuth, - `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityTokenAuth}.identityId` ) .select( - db.ref("id").withSchema(TableName.IdentityProjectMembership), - db.ref("createdAt").withSchema(TableName.IdentityProjectMembership), - db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("createdAt").withSchema(TableName.Membership), + db.ref("updatedAt").withSchema(TableName.Membership), db.ref("id").as("identityId").withSchema(TableName.Identity), db.ref("name").as("identityName").withSchema(TableName.Identity), db.ref("hasDeleteProtection").withSchema(TableName.Identity), - db.ref("id").withSchema(TableName.IdentityProjectMembership), - db.ref("role").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("projectId").withSchema(TableName.IdentityProjectMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole), + db.ref("scopeProjectId").withSchema(TableName.Membership).as("projectId"), db.ref("name").as("projectName").withSchema(TableName.Project), db.ref("type").as("projectType").withSchema(TableName.Project), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), @@ -165,7 +153,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { }) }, project: { - id: projectId, + id: projectId as string, name: projectName, type: projectType } @@ -223,12 +211,10 @@ export const identityProjectDALFactory = (db: TDbClient) => { void qb.whereILike(`${TableName.Identity}.name`, `%${filter.search}%`); } }) - .join( - TableName.IdentityProjectMembership, - `${TableName.IdentityProjectMembership}.identityId`, - `${TableName.Identity}.id` - ) - .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) + .join(TableName.Membership, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) .orderBy( `${TableName.Identity}.${filter.orderBy ?? ProjectIdentityOrderBy.Name}`, filter.orderDirection ?? OrderByDirection.ASC @@ -240,33 +226,20 @@ export const identityProjectDALFactory = (db: TDbClient) => { void fetchIdentitySubquery.offset(filter.offset ?? 0).limit(filter.limit); } - const query = (tx || db.replicaNode())(TableName.IdentityProjectMembership) - .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) + const query = (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) .join(fetchIdentitySubquery, (bd) => { - bd.on(`${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`); + bd.on(`${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`); }) .where((qb) => { if (filter.identityId) { - void qb.where(`${TableName.IdentityProjectMembership}.identityId`, filter.identityId); + void qb.where(`${TableName.Membership}.actorIdentityId`, filter.identityId); } }) - .join( - TableName.IdentityProjectMembershipRole, - `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, - `${TableName.IdentityProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.IdentityProjectMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) - .leftJoin( - TableName.IdentityProjectAdditionalPrivilege, - `${TableName.IdentityProjectMembership}.id`, - `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId` - ) - + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin( TableName.IdentityUniversalAuth, `${TableName.Identity}.id`, @@ -314,23 +287,23 @@ export const identityProjectDALFactory = (db: TDbClient) => { ) .select( - db.ref("id").withSchema(TableName.IdentityProjectMembership), - db.ref("createdAt").withSchema(TableName.IdentityProjectMembership), - db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("createdAt").withSchema(TableName.Membership), + db.ref("updatedAt").withSchema(TableName.Membership), db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity), db.ref("id").as("identityId").withSchema(TableName.Identity), db.ref("name").as("identityName").withSchema(TableName.Identity), - db.ref("id").withSchema(TableName.IdentityProjectMembership), - db.ref("role").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("id").withSchema(TableName.Membership), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -450,13 +423,14 @@ export const identityProjectDALFactory = (db: TDbClient) => { tx?: Knex ) => { try { - const identities = await (tx || db.replicaNode())(TableName.IdentityProjectMembership) - .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) - .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) + const identities = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) .where((qb) => { if (filter.identityId) { - void qb.where("identityId", filter.identityId); + void qb.where(`${TableName.Membership}.actorIdentityId`, filter.identityId); } if (filter.search) { @@ -472,7 +446,6 @@ export const identityProjectDALFactory = (db: TDbClient) => { }; return { - ...identityProjectOrm, findByIdentityId, findByProjectId, getCountByProjectId diff --git a/backend/src/services/identity-project/identity-project-membership-role-dal.ts b/backend/src/services/identity-project/identity-project-membership-role-dal.ts deleted file mode 100644 index 3f6c6b589..000000000 --- a/backend/src/services/identity-project/identity-project-membership-role-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TIdentityProjectMembershipRoleDALFactory = ReturnType; - -export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.IdentityProjectMembershipRole); - return orm; -}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 08fd2cf0b..abe0446da 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -1,44 +1,22 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; -import { - constructPermissionErrorMessage, - validatePrivilegeChangeOperation -} from "@app/ee/services/permission/permission-fns"; +import { AccessScope, ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; -import { groupBy } from "@app/lib/fn"; -import { ms } from "@app/lib/ms"; +import { NotFoundError } from "@app/lib/errors"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; -import { TProjectDALFactory } from "../project/project-dal"; -import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; -import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { TIdentityProjectDALFactory } from "./identity-project-dal"; -import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-membership-role-dal"; import { - TCreateProjectIdentityDTO, - TDeleteProjectIdentityDTO, TGetProjectIdentityByIdentityIdDTO, TGetProjectIdentityByMembershipIdDTO, - TListProjectIdentityDTO, - TUpdateProjectIdentityDTO + TListProjectIdentityDTO } from "./identity-project-types"; type TIdentityProjectServiceFactoryDep = { identityProjectDAL: TIdentityProjectDALFactory; - identityProjectMembershipRoleDAL: Pick< - TIdentityProjectMembershipRoleDALFactory, - "create" | "transaction" | "insertMany" | "delete" - >; - projectDAL: Pick; - projectRoleDAL: Pick; - identityOrgMembershipDAL: Pick; - permissionService: Pick< - TPermissionServiceFactory, - "getProjectPermission" | "getProjectPermissionByRole" | "invalidateProjectPermissionCache" - >; + permissionService: Pick; + membershipIdentityDAL: TMembershipIdentityDALFactory; }; export type TIdentityProjectServiceFactory = ReturnType; @@ -46,276 +24,8 @@ export type TIdentityProjectServiceFactory = ReturnType { - const createProjectIdentity = async ({ - identityId, - actor, - actorId, - actorOrgId, - actorAuthMethod, - projectId, - roles - }: TCreateProjectIdentityDTO) => { - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Create, - subject(ProjectPermissionSub.Identity, { - identityId - }) - ); - - const existingIdentity = await identityProjectDAL.findOne({ identityId, projectId }); - if (existingIdentity) - throw new BadRequestError({ - message: `Identity with ID ${identityId} already exists in project with ID ${projectId}` - }); - - const project = await projectDAL.findById(projectId); - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ - identityId, - orgId: project.orgId - }); - if (!identityOrgMembership) - throw new NotFoundError({ - message: `Failed to find identity with ID ${identityId}` - }); - - for await (const { role: requestedRoleChange } of roles) { - const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( - requestedRoleChange, - projectId - ); - - if (requestedRoleChange !== ProjectMembershipRole.NoAccess) { - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to assign to role", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - } - } - - // validate custom roles input - const customInputRoles = roles.filter( - ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) - ); - const hasCustomRole = Boolean(customInputRoles.length); - const customRoles = hasCustomRole - ? await projectRoleDAL.find({ - projectId, - $in: { slug: customInputRoles.map(({ role }) => role) } - }) - : []; - if (customRoles.length !== customInputRoles.length) - throw new NotFoundError({ message: "One or more custom project roles not found" }); - - const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - const projectIdentity = await identityProjectDAL.transaction(async (tx) => { - const identityProjectMembership = await identityProjectDAL.create( - { - identityId, - projectId: project.id - }, - tx - ); - const sanitizedProjectMembershipRoles = roles.map((inputRole) => { - const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); - if (!inputRole.isTemporary) { - return { - projectMembershipId: identityProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null - }; - } - - // check cron or relative here later for now its just relative - const relativeTimeInMs = ms(inputRole.temporaryRange); - return { - projectMembershipId: identityProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, - isTemporary: true, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: inputRole.temporaryRange, - temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) - }; - }); - - const identityRoles = await identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - return { ...identityProjectMembership, roles: identityRoles }; - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return projectIdentity; - }; - - const updateProjectIdentity = async ({ - projectId, - identityId, - roles, - actor, - actorId, - actorAuthMethod, - actorOrgId - }: TUpdateProjectIdentityDTO) => { - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Edit, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const projectIdentity = await identityProjectDAL.findOne({ identityId, projectId }); - if (!projectIdentity) - throw new NotFoundError({ - message: `Identity with ID ${identityId} doesn't exists in project with ID ${projectId}` - }); - - for await (const { role: requestedRoleChange } of roles) { - const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( - requestedRoleChange, - projectId - ); - - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to change role", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionSub.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - } - - // validate custom roles input - const customInputRoles = roles.filter( - ({ role }) => - !Object.values(ProjectMembershipRole) - // we don't want to include custom in this check; - // this unintentionally enables setting slug to custom which is reserved - .filter((r) => r !== ProjectMembershipRole.Custom) - .includes(role as ProjectMembershipRole) - ); - const hasCustomRole = Boolean(customInputRoles.length); - const customRoles = hasCustomRole - ? await projectRoleDAL.find({ - projectId, - $in: { slug: customInputRoles.map(({ role }) => role) } - }) - : []; - if (customRoles.length !== customInputRoles.length) - throw new NotFoundError({ message: "One or more custom project roles not found" }); - - const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - - const sanitizedProjectMembershipRoles = roles.map((inputRole) => { - const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); - if (!inputRole.isTemporary) { - return { - projectMembershipId: projectIdentity.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null - }; - } - - // check cron or relative here later for now its just relative - const relativeTimeInMs = ms(inputRole.temporaryRange); - return { - projectMembershipId: projectIdentity.id, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, - isTemporary: true, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: inputRole.temporaryRange, - temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) - }; - }); - - const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => { - await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx); - return identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return updatedRoles; - }; - - const deleteProjectIdentity = async ({ - identityId, - actorId, - actor, - actorOrgId, - actorAuthMethod, - projectId - }: TDeleteProjectIdentityDTO) => { - const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); - if (!identityProjectMembership) { - throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - } - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionIdentityActions.Delete, - subject(ProjectPermissionSub.Identity, { identityId }) - ); - - const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return deletedIdentity; - }; - const listProjectIdentities = async ({ projectId, actor, @@ -391,9 +101,13 @@ export const identityProjectServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetProjectIdentityByMembershipIdDTO) => { - const membership = await identityProjectDAL.findOne({ id: identityMembershipId }); + const membership = await membershipIdentityDAL.findOne({ + id: identityMembershipId, + scope: AccessScope.Project, + scopeOrgId: actorOrgId + }); - if (!membership) { + if (!membership || !membership.scopeProjectId || !membership.actorIdentityId) { throw new NotFoundError({ message: `Project membership with ID '${identityMembershipId}' not found` }); @@ -402,7 +116,7 @@ export const identityProjectServiceFactory = ({ const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: membership.projectId, + projectId: membership.scopeProjectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.Any @@ -410,20 +124,17 @@ export const identityProjectServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Read, - subject(ProjectPermissionSub.Identity, { identityId: membership.identityId }) + subject(ProjectPermissionSub.Identity, { identityId: membership.actorIdentityId }) ); - const [identityMembership] = await identityProjectDAL.findByProjectId(membership.projectId, { - identityId: membership.identityId + const [identityMembership] = await identityProjectDAL.findByProjectId(membership.scopeProjectId, { + identityId: membership.actorIdentityId }); return identityMembership; }; return { - createProjectIdentity, - updateProjectIdentity, - deleteProjectIdentity, listProjectIdentities, getProjectIdentityByIdentityId, getProjectIdentityByMembershipId diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index ef2463eec..24c82ccac 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -11,26 +11,34 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; type TIdentityTlsCertAuthServiceFactoryDep = { + identityDAL: Pick; identityAccessTokenDAL: Pick; identityTlsCertAuthDAL: Pick< TIdentityTlsCertAuthDALFactory, "findOne" | "transaction" | "create" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; kmsService: Pick; @@ -46,9 +54,10 @@ const parseSubjectDetails = (data: string) => { }; export const identityTlsCertAuthServiceFactory = ({ + identityDAL, identityAccessTokenDAL, identityTlsCertAuthDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, licenseService, permissionService, kmsService @@ -61,19 +70,12 @@ export const identityTlsCertAuthServiceFactory = ({ }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ - identityId: identityTlsCertAuth.identityId - }); - - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: `Identity organization membership for identity with ID '${identityTlsCertAuth.identityId}' not found` - }); - } + const identity = await identityDAL.findById(identityTlsCertAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identity.orgId }); const caCertificate = decryptor({ @@ -118,12 +120,9 @@ export const identityTlsCertAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -160,7 +159,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityTlsCertAuth, accessToken, identityAccessToken, - identityMembershipOrg + identity }; }; @@ -180,8 +179,17 @@ export const identityTlsCertAuthServiceFactory = ({ }) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new BadRequestError({ @@ -193,16 +201,17 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -222,13 +231,13 @@ export const identityTlsCertAuthServiceFactory = ({ const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const identityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => { const doc = await identityTlsCertAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, accessTokenMaxTTL, allowedCommonNames, accessTokenTTL, @@ -240,7 +249,7 @@ export const identityTlsCertAuthServiceFactory = ({ ); return doc; }); - return { ...identityTlsCertAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityTlsCertAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateTlsCertAuth: TIdentityTlsCertAuthServiceFactory["updateTlsCertAuth"] = async ({ @@ -256,8 +265,17 @@ export const identityTlsCertAuthServiceFactory = ({ actor, actorOrgId }) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new NotFoundError({ @@ -275,16 +293,17 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -303,7 +322,7 @@ export const identityTlsCertAuthServiceFactory = ({ }); const { encryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); const updatedTlsCertAuth = await identityTlsCertAuthDAL.updateById(identityTlsCertAuth.id, { @@ -319,7 +338,7 @@ export const identityTlsCertAuthServiceFactory = ({ : undefined }); - return { ...updatedTlsCertAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedTlsCertAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const getTlsCertAuth: TIdentityTlsCertAuthServiceFactory["getTlsCertAuth"] = async ({ @@ -329,8 +348,17 @@ export const identityTlsCertAuthServiceFactory = ({ actorAuthMethod, actorOrgId }) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new BadRequestError({ @@ -340,24 +368,25 @@ export const identityTlsCertAuthServiceFactory = ({ const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }); let caCertificate = ""; if (identityAuth.encryptedCaCertificate) { caCertificate = decryptor({ cipherTextBlob: identityAuth.encryptedCaCertificate }).toString(); } - return { ...identityAuth, caCertificate, orgId: identityMembershipOrg.orgId }; + return { ...identityAuth, caCertificate, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeTlsCertAuth: TIdentityTlsCertAuthServiceFactory["revokeTlsCertAuth"] = async ({ @@ -367,32 +396,43 @@ export const identityTlsCertAuthServiceFactory = ({ actorAuthMethod, actorOrgId }) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { throw new BadRequestError({ message: "The identity does not have TLS Certificate auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission, memberships } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); - + actorOrgId, + scope: OrganizationActionScope.Any + }); + const shouldUseNewPrivilegeSystem = Boolean(memberships?.[0]?.shouldUseNewPrivilegeSystem); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -403,7 +443,7 @@ export const identityTlsCertAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke TLS Certificate auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -414,7 +454,7 @@ export const identityTlsCertAuthServiceFactory = ({ const deletedTlsCertAuth = await identityTlsCertAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.TLS_CERT_AUTH }, tx); - return { ...deletedTlsCertAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedTlsCertAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityTlsCertAuth; }; diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts index b7a08276b..cf35bb5ee 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts @@ -1,4 +1,4 @@ -import { TIdentityAccessTokens, TIdentityOrgMemberships, TIdentityTlsCertAuths } from "@app/db/schemas"; +import { TIdentities, TIdentityAccessTokens, TIdentityTlsCertAuths } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; export type TLoginTlsCertAuthDTO = { @@ -40,7 +40,7 @@ export type TIdentityTlsCertAuthServiceFactory = { identityTlsCertAuth: TIdentityTlsCertAuths; accessToken: string; identityAccessToken: TIdentityAccessTokens; - identityMembershipOrg: TIdentityOrgMemberships; + identity: TIdentities; }>; attachTlsCertAuth: (dto: TAttachTlsCertAuthDTO) => Promise; updateTlsCertAuth: (dto: TUpdateTlsCertAuthDTO) => Promise; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index d3743bd96..2d3e11cd8 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { IdentityAuthMethod, TableName } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -10,13 +10,21 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; -import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { + BadRequestError, + ForbiddenRequestError, + NotFoundError, + PermissionBoundaryError, + UnauthorizedError +} from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityTokenAuthDALFactory } from "./identity-token-auth-dal"; import { @@ -31,28 +39,31 @@ import { } from "./identity-token-auth-types"; type TIdentityTokenAuthServiceFactoryDep = { + identityDAL: Pick; identityTokenAuthDAL: Pick< TIdentityTokenAuthDALFactory, "transaction" | "create" | "findOne" | "updateById" | "delete" >; - identityOrgMembershipDAL: Pick; + membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick< TIdentityAccessTokenDALFactory, "create" | "find" | "update" | "findById" | "findOne" | "updateById" | "delete" >; permissionService: Pick; licenseService: Pick; + orgDAL: Pick; }; export type TIdentityTokenAuthServiceFactory = ReturnType; export const identityTokenAuthServiceFactory = ({ + identityDAL, identityTokenAuthDAL, - // identityDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, identityAccessTokenDAL, permissionService, - licenseService + licenseService, + orgDAL }: TIdentityTokenAuthServiceFactoryDep) => { const attachTokenAuth = async ({ identityId, @@ -68,8 +79,17 @@ export const identityTokenAuthServiceFactory = ({ }: TAttachTokenAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ @@ -81,16 +101,17 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -111,7 +132,7 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { const doc = await identityTokenAuthDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, accessTokenMaxTTL, accessTokenTTL, accessTokenNumUsesLimit, @@ -121,7 +142,7 @@ export const identityTokenAuthServiceFactory = ({ ); return doc; }); - return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityTokenAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const updateTokenAuth = async ({ @@ -138,8 +159,17 @@ export const identityTokenAuthServiceFactory = ({ }: TUpdateTokenAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ @@ -157,16 +187,17 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( !plan.ipAllowlisting && @@ -195,13 +226,22 @@ export const identityTokenAuthServiceFactory = ({ return { ...updatedTokenAuth, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }; }; const getTokenAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetTokenAuthDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ @@ -211,16 +251,17 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId }; + return { ...identityTokenAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityTokenAuth = async ({ @@ -233,33 +274,45 @@ export const identityTokenAuthServiceFactory = ({ }: TRevokeTokenAuthDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { throw new BadRequestError({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, membership } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -269,7 +322,7 @@ export const identityTokenAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke token auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -283,7 +336,7 @@ export const identityTokenAuthServiceFactory = ({ authMethod: IdentityAuthMethod.TOKEN_AUTH }); - return { ...deletedTokenAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedTokenAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityTokenAuth; }; @@ -299,7 +352,13 @@ export const identityTokenAuthServiceFactory = ({ }: TCreateTokenAuthTokenDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { @@ -307,25 +366,30 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, membership } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity, permission, @@ -335,7 +399,7 @@ export const identityTokenAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to create token for identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity ), @@ -344,13 +408,13 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); + const identity = await identityDAL.findById(identityTokenAuth.identityId); + if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, - lastLoginTime: new Date() - }, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, lastLoginTime: new Date() }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -385,7 +449,7 @@ export const identityTokenAuthServiceFactory = ({ } ); - return { accessToken, identityTokenAuth, identityAccessToken, identityMembershipOrg }; + return { accessToken, identityTokenAuth, identityAccessToken, identity }; }; const getTokenAuthTokens = async ({ @@ -400,7 +464,13 @@ export const identityTokenAuthServiceFactory = ({ }: TGetTokenAuthTokensDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { @@ -408,13 +478,14 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const tokens = await identityAccessTokenDAL.find( @@ -443,7 +514,13 @@ export const identityTokenAuthServiceFactory = ({ }); if (!foundToken) throw new NotFoundError({ message: `Token with ID ${tokenId} not found` }); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: foundToken.identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId: foundToken.identityId + }); if (!identityMembershipOrg) { throw new NotFoundError({ message: `Failed to find identity with ID ${foundToken.identityId}` }); } @@ -454,24 +531,27 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, membership } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity, permission, @@ -481,7 +561,7 @@ export const identityTokenAuthServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to update token for identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity ), @@ -523,21 +603,23 @@ export const identityTokenAuthServiceFactory = ({ await validateIdentityUpdateForSuperAdminPrivileges(identityAccessToken.identityId, isActorSuperAdmin); - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ - identityId: identityAccessToken.identityId + const identityOrgMembership = await membershipIdentityDAL.findOne({ + actorIdentityId: identityAccessToken.identityId, + scope: AccessScope.Organization }); if (!identityOrgMembership) { throw new NotFoundError({ message: `Failed to find identity with ID ${identityAccessToken.identityId}` }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.orgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const [revokedToken] = await identityAccessTokenDAL.update( diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 979e6f25f..00ab1610d 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,6 +13,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, + ForbiddenRequestError, NotFoundError, PermissionBoundaryError, RateLimitError, @@ -22,9 +23,11 @@ import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-dal"; import { TIdentityUaDALFactory } from "./identity-ua-dal"; @@ -41,12 +44,14 @@ import { } from "./identity-ua-types"; type TIdentityUaServiceFactoryDep = { + identityDAL: Pick; identityUaDAL: TIdentityUaDALFactory; identityUaClientSecretDAL: TIdentityUaClientSecretDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; - identityOrgMembershipDAL: TIdentityOrgDALFactory; + membershipIdentityDAL: TMembershipIdentityDALFactory; permissionService: Pick; licenseService: Pick; + orgDAL: Pick; keyStore: Pick< TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" @@ -64,10 +69,12 @@ export const identityUaServiceFactory = ({ identityUaDAL, identityUaClientSecretDAL, identityAccessTokenDAL, - identityOrgMembershipDAL, + membershipIdentityDAL, permissionService, licenseService, - keyStore + orgDAL, + keyStore, + identityDAL }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); @@ -97,13 +104,6 @@ export const identityUaServiceFactory = ({ }); } - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); - if (!identityMembershipOrg) { - throw new UnauthorizedError({ - message: "Invalid credentials" - }); - } - const clientSecretPrefix = clientSecret.slice(0, 4); const clientSecretInfo = await identityUaClientSecretDAL.find({ identityUAId: identityUa.id, @@ -221,10 +221,11 @@ export const identityUaServiceFactory = ({ accessTokenMaxTTL: 1000000000 }; + const identity = await identityDAL.findById(identityUa.identityId); const identityAccessToken = await identityUaDAL.transaction(async (tx) => { const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, { lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, lastLoginTime: new Date() @@ -270,7 +271,7 @@ export const identityUaServiceFactory = ({ identityUa, validClientSecretInfo, identityAccessToken, - identityMembershipOrg, + identity, ...accessTokenTTLParams }; }; @@ -295,7 +296,13 @@ export const identityUaServiceFactory = ({ }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -303,21 +310,26 @@ export const identityUaServiceFactory = ({ message: "Failed to add universal auth to already configured identity" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { if ( !plan.ipAllowlisting && @@ -354,7 +366,7 @@ export const identityUaServiceFactory = ({ const identityUa = await identityUaDAL.transaction(async (tx) => { const doc = await identityUaDAL.create( { - identityId: identityMembershipOrg.identityId, + identityId: identityMembershipOrg.identity.id, clientId: crypto.nativeCrypto.randomUUID(), clientSecretTrustedIps: JSON.stringify(reformattedClientSecretTrustedIps), accessTokenMaxTTL, @@ -371,7 +383,7 @@ export const identityUaServiceFactory = ({ ); return doc; }); - return { ...identityUa, orgId: identityMembershipOrg.orgId }; + return { ...identityUa, orgId: identityMembershipOrg.scopeOrgId }; }; const updateUniversalAuth = async ({ @@ -391,7 +403,13 @@ export const identityUaServiceFactory = ({ lockoutDurationSeconds, lockoutCounterResetSeconds }: TUpdateUaDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); @@ -405,6 +423,10 @@ export const identityUaServiceFactory = ({ }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + if ( (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) > 0 && (accessTokenTTL || uaIdentityAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) @@ -412,16 +434,17 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map((clientSecretTrustedIp) => { if ( !plan.ipAllowlisting && @@ -471,11 +494,17 @@ export const identityUaServiceFactory = ({ lockoutDurationSeconds, lockoutCounterResetSeconds }); - return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; + return { ...updatedUaAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const getIdentityUniversalAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetUaDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); @@ -488,16 +517,20 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId }; + return { ...uaIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeIdentityUniversalAuth = async ({ @@ -507,7 +540,13 @@ export const identityUaServiceFactory = ({ actorAuthMethod, actorOrgId }: TRevokeUaDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -515,24 +554,30 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - const { permission } = await permissionService.getOrgPermission( + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, membership } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity, permission, @@ -542,7 +587,7 @@ export const identityUaServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke universal auth of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.RevokeAuth, OrgPermissionSubjects.Identity ), @@ -551,7 +596,7 @@ export const identityUaServiceFactory = ({ const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { const deletedUniversalAuth = await identityUaDAL.delete({ identityId }, tx); - return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.orgId }; + return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; }); return revokedIdentityUniversalAuth; }; @@ -566,7 +611,13 @@ export const identityUaServiceFactory = ({ description, numUsesLimit }: TCreateUaClientSecretDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -574,25 +625,31 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity, permission, @@ -602,7 +659,7 @@ export const identityUaServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to create client secret for identity.", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.CreateToken, OrgPermissionSubjects.Identity ), @@ -613,7 +670,7 @@ export const identityUaServiceFactory = ({ const clientSecret = crypto.randomBytes(32).toString("hex"); const clientSecretHash = await crypto.hashing().createHash(clientSecret, appCfg.SALT_ROUNDS); - const identityUaAuth = await identityUaDAL.findOne({ identityId: identityMembershipOrg.identityId }); + const identityUaAuth = await identityUaDAL.findOne({ identityId: identityMembershipOrg.identity.id }); if (!identityUaAuth) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); const identityUaClientSecret = await identityUaClientSecretDAL.create({ @@ -629,7 +686,7 @@ export const identityUaServiceFactory = ({ return { clientSecret, clientSecretData: identityUaClientSecret, - orgId: identityMembershipOrg.orgId + orgId: identityMembershipOrg.scopeOrgId }; }; @@ -640,7 +697,13 @@ export const identityUaServiceFactory = ({ actorAuthMethod, identityId }: TGetUaClientSecretsDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -648,25 +711,31 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - const { permission, membership } = await permissionService.getOrgPermission( + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); - + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GetToken, OrgPermissionSubjects.Identity, permission, @@ -676,7 +745,7 @@ export const identityUaServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to get identity client secret with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GetToken, OrgPermissionSubjects.Identity ), @@ -691,7 +760,7 @@ export const identityUaServiceFactory = ({ identityUAId: identityUniversalAuth.id, isClientSecretRevoked: false }); - return { clientSecrets, orgId: identityMembershipOrg.orgId }; + return { clientSecrets, orgId: identityMembershipOrg.scopeOrgId }; }; const getUniversalAuthClientSecretById = async ({ @@ -702,7 +771,13 @@ export const identityUaServiceFactory = ({ actorAuthMethod, clientSecretId }: TGetUniversalAuthClientSecretByIdDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -710,6 +785,9 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } const identityUa = await identityUaDAL.findOne({ identityId }); if (!identityUa) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -717,24 +795,27 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GetToken, OrgPermissionSubjects.Identity, permission, @@ -744,14 +825,14 @@ export const identityUaServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to read identity client secret of identity with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GetToken, OrgPermissionSubjects.Identity ), details: { missingPermissions: permissionBoundary.missingPermissions } }); - return { ...clientSecret, identityId, orgId: identityMembershipOrg.orgId }; + return { ...clientSecret, identityId, orgId: identityMembershipOrg.scopeOrgId }; }; const revokeUniversalAuthClientSecret = async ({ @@ -762,7 +843,13 @@ export const identityUaServiceFactory = ({ actorAuthMethod, clientSecretId }: TRevokeUaClientSecretDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -770,6 +857,9 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } const identityUa = await identityUaDAL.findOne({ identityId }); if (!identityUa) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -777,25 +867,28 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission( - ActorType.IDENTITY, - identityMembershipOrg.identityId, - identityMembershipOrg.orgId, + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.DeleteToken, OrgPermissionSubjects.Identity, permission, @@ -805,7 +898,7 @@ export const identityUaServiceFactory = ({ throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to revoke identity client secret with more privileged role", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.DeleteToken, OrgPermissionSubjects.Identity ), @@ -817,7 +910,7 @@ export const identityUaServiceFactory = ({ isClientSecretRevoked: true }); - return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId }; + return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.scopeOrgId }; }; const clearUniversalAuthLockouts = async ({ @@ -827,7 +920,13 @@ export const identityUaServiceFactory = ({ actorOrgId, actorAuthMethod }: TClearUaLockoutsDTO) => { - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId + }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { @@ -835,21 +934,25 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } + if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); + } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityMembershipOrg.orgId, + orgId: identityMembershipOrg.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); const deleted = await keyStore.deleteItems({ pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*` }); - return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + return { deleted, identityId, orgId: identityMembershipOrg.scopeOrgId }; }; return { diff --git a/backend/src/services/identity/identity-dal.ts b/backend/src/services/identity/identity-dal.ts index 7bc797600..a2e4995ca 100644 --- a/backend/src/services/identity/identity-dal.ts +++ b/backend/src/services/identity/identity-dal.ts @@ -50,11 +50,23 @@ export const identityDALFactory = (db: TDbClient) => { }); } + const countQuery = query.clone(); + if (sortBy) { query = query.orderBy(sortBy); } - return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity)); + const [identities, totalResult] = await Promise.all([ + query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity)), + countQuery.countDistinct(`${TableName.Identity}.id`, { as: "count" }).first() + ]); + + const total = Number(totalResult?.count || 0); + + return { + identities, + total + }; } catch (error) { throw new DatabaseError({ error, name: "Get identities by filter" }); } diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index c083df5aa..66556f5fa 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + AccessScope, TableName, TIdentityAlicloudAuths, TIdentityAwsAuths, @@ -11,15 +12,15 @@ import { TIdentityKubernetesAuths, TIdentityOciAuths, TIdentityOidcAuths, - TIdentityOrgMemberships, TIdentityTlsCertAuths, TIdentityTokenAuths, TIdentityUniversalAuths, - TOrgRoles + TMembershipRoles, + TMemberships } from "@app/db/schemas"; import { TIdentityLdapAuths } from "@app/db/schemas/identity-ldap-auths"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; import { OrderByDirection } from "@app/lib/types"; import { @@ -33,80 +34,80 @@ import { buildAuthMethods } from "./identity-fns"; export type TIdentityOrgDALFactory = ReturnType; export const identityOrgDALFactory = (db: TDbClient) => { - const identityOrgOrm = ormify(db, TableName.IdentityOrgMembership); - - const findOne = async (filter: Partial, tx?: Knex) => { + const findOne = async (filter: Partial, tx?: Knex) => { try { - const [data] = await (tx || db.replicaNode())(TableName.IdentityOrgMembership) + const [data] = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) .where((queryBuilder) => { Object.entries(filter).forEach(([key, value]) => { - void queryBuilder.where(`${TableName.IdentityOrgMembership}.${key}`, value); + void queryBuilder.where(`${TableName.Membership}.${key}`, value); }); }) - .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) .leftJoin( TableName.IdentityUniversalAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityUniversalAuth}.identityId` ) .leftJoin( TableName.IdentityGcpAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityGcpAuth}.identityId` ) .leftJoin( TableName.IdentityAliCloudAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAliCloudAuth}.identityId` ) .leftJoin( TableName.IdentityAwsAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAwsAuth}.identityId` ) .leftJoin( TableName.IdentityKubernetesAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityKubernetesAuth}.identityId` ) .leftJoin( TableName.IdentityOciAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityOciAuth}.identityId` ) .leftJoin( TableName.IdentityOidcAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityOidcAuth}.identityId` ) .leftJoin( TableName.IdentityAzureAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAzureAuth}.identityId` ) .leftJoin( TableName.IdentityTokenAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityTokenAuth}.identityId` ) .leftJoin( TableName.IdentityJwtAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityJwtAuth}.identityId` ) .leftJoin( TableName.IdentityLdapAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityLdapAuth}.identityId` ) .leftJoin( TableName.IdentityTlsCertAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityTlsCertAuth}.identityId` ) .select( - selectAllTableCols(TableName.IdentityOrgMembership), + selectAllTableCols(TableName.Membership), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -129,7 +130,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { return { ...data, identity: { - id: data.identityId, + id: data.actorIdentityId as string, name, hasDeleteProtection, authMethods: buildAuthMethods(data) @@ -149,22 +150,21 @@ export const identityOrgDALFactory = (db: TDbClient) => { orderDirection = OrderByDirection.ASC, search, ...filter - }: Partial & + }: Partial & Pick, tx?: Knex ) => { try { const paginatedIdentity = (tx || db.replicaNode())(TableName.Identity) - .join( - TableName.IdentityOrgMembership, - `${TableName.IdentityOrgMembership}.identityId`, - `${TableName.Identity}.id` - ) + .join(TableName.Membership, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) .select( - selectAllTableCols(TableName.IdentityOrgMembership), + selectAllTableCols(TableName.Membership), db.ref("name").withSchema(TableName.Identity).as("identityName"), - db.ref("hasDeleteProtection").withSchema(TableName.Identity) + db.ref("hasDeleteProtection").withSchema(TableName.Identity), + db.ref("orgId").withSchema(TableName.Identity) ) .where(filter) .as("paginatedIdentity"); @@ -181,84 +181,88 @@ export const identityOrgDALFactory = (db: TDbClient) => { type TSubquery = Awaited; const query = (tx || db.replicaNode()) .from(paginatedIdentity) - .leftJoin(TableName.OrgRoles, `paginatedIdentity.roleId`, `${TableName.OrgRoles}.id`) - + .join( + TableName.MembershipRole, + `${TableName.MembershipRole}.membershipId`, + "paginatedIdentity.id" + ) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder - .on(`paginatedIdentity.identityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`paginatedIdentity.orgId`, `${TableName.IdentityMetadata}.orgId`); + .on(`paginatedIdentity.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`paginatedIdentity.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); }) - .leftJoin( TableName.IdentityUniversalAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityUniversalAuth}.identityId` ) .leftJoin( TableName.IdentityGcpAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityGcpAuth}.identityId` ) .leftJoin( TableName.IdentityAliCloudAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityAliCloudAuth}.identityId` ) .leftJoin( TableName.IdentityAwsAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityAwsAuth}.identityId` ) .leftJoin( TableName.IdentityKubernetesAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityKubernetesAuth}.identityId` ) .leftJoin( TableName.IdentityOciAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityOciAuth}.identityId` ) .leftJoin( TableName.IdentityOidcAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityOidcAuth}.identityId` ) .leftJoin( TableName.IdentityAzureAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityAzureAuth}.identityId` ) .leftJoin( TableName.IdentityTokenAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityTokenAuth}.identityId` ) .leftJoin( TableName.IdentityJwtAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityJwtAuth}.identityId` ) .leftJoin( TableName.IdentityLdapAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityLdapAuth}.identityId` ) .leftJoin( TableName.IdentityTlsCertAuth, - "paginatedIdentity.identityId", + "paginatedIdentity.actorIdentityId", `${TableName.IdentityTlsCertAuth}.identityId` ) .select( db.ref("id").withSchema("paginatedIdentity"), - db.ref("role").withSchema("paginatedIdentity"), - db.ref("roleId").withSchema("paginatedIdentity"), - db.ref("orgId").withSchema("paginatedIdentity"), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), + db.ref("scopeOrgId").withSchema("paginatedIdentity").as("orgId"), db.ref("lastLoginAuthMethod").withSchema("paginatedIdentity"), + db.ref("orgId").withSchema("paginatedIdentity").as("identityOrgId"), db.ref("lastLoginTime").withSchema("paginatedIdentity"), db.ref("createdAt").withSchema("paginatedIdentity"), db.ref("updatedAt").withSchema("paginatedIdentity"), - db.ref("identityId").withSchema("paginatedIdentity").as("identityId"), + db.ref("actorIdentityId").withSchema("paginatedIdentity").as("identityId"), db.ref("identityName").withSchema("paginatedIdentity"), db.ref("hasDeleteProtection").withSchema("paginatedIdentity"), @@ -276,12 +280,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) ) // cr stands for custom role - .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) - .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) - .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) - .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select(db.ref("id").as("crId").withSchema(TableName.Role)) + .select(db.ref("name").as("crName").withSchema(TableName.Role)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.Role)) + .select(db.ref("description").as("crDescription").withSchema(TableName.Role)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.Role)) .select( db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), @@ -308,6 +311,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { roleId, id, orgId, + identityOrgId, uaId, alicloudId, awsId, @@ -327,7 +331,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { }) => ({ role, roleId, - identityId, + identityId: identityId as string, id, orgId, createdAt, @@ -344,9 +348,10 @@ export const identityOrgDALFactory = (db: TDbClient) => { } : undefined, identity: { - id: identityId, + id: identityId as string, name: identityName, hasDeleteProtection, + orgId: identityOrgId, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -394,20 +399,23 @@ export const identityOrgDALFactory = (db: TDbClient) => { tx?: Knex ) => { try { - const searchQuery = (tx || db.replicaNode())(TableName.IdentityOrgMembership) - .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`) - .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) - .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + const searchQuery = (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .orderBy( orderBy === OrgIdentityOrderBy.Role - ? `${TableName.IdentityOrgMembership}.${orderBy}` + ? `${TableName.MembershipRole}.${orderBy}` : `${TableName.Identity}.${orderBy}`, orderDirection ) - .select(`${TableName.IdentityOrgMembership}.id`) + .select(`${TableName.Membership}.id`) .select<{ id: string; total_count: string }>( db.raw( - `count(${TableName.IdentityOrgMembership}."identityId") OVER(PARTITION BY ${TableName.IdentityOrgMembership}."orgId") as total_count` + `count(${TableName.Membership}."actorIdentityId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total_count` ) ) .as("searchedIdentities"); @@ -416,7 +424,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { buildKnexFilterForSearchResource(searchQuery, searchFilter, (attr) => { switch (attr) { case "role": - return [`${TableName.OrgRoles}.slug`, `${TableName.IdentityOrgMembership}.role`]; + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; case "name": return `${TableName.Identity}.name`; default: @@ -430,84 +438,88 @@ export const identityOrgDALFactory = (db: TDbClient) => { } type TSubquery = Awaited; - const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) - .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) - .join(searchQuery, `${TableName.IdentityOrgMembership}.id`, "searchedIdentities.id") - .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) - .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + const query = (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .join(searchQuery, `${TableName.Membership}.id`, "searchedIdentities.id") + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder - .on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); }) .leftJoin( TableName.IdentityUniversalAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityUniversalAuth}.identityId` ) .leftJoin( TableName.IdentityGcpAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityGcpAuth}.identityId` ) .leftJoin( TableName.IdentityAliCloudAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAliCloudAuth}.identityId` ) .leftJoin( TableName.IdentityAwsAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAwsAuth}.identityId` ) .leftJoin( TableName.IdentityKubernetesAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityKubernetesAuth}.identityId` ) .leftJoin( TableName.IdentityOciAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityOciAuth}.identityId` ) .leftJoin( TableName.IdentityOidcAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityOidcAuth}.identityId` ) .leftJoin( TableName.IdentityAzureAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityAzureAuth}.identityId` ) .leftJoin( TableName.IdentityTokenAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityTokenAuth}.identityId` ) .leftJoin( TableName.IdentityJwtAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityJwtAuth}.identityId` ) .leftJoin( TableName.IdentityLdapAuth, - `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.Membership}.actorIdentityId`, `${TableName.IdentityLdapAuth}.identityId` ) .select( - db.ref("id").withSchema(TableName.IdentityOrgMembership), + db.ref("id").withSchema(TableName.Membership), db.ref("total_count").withSchema("searchedIdentities"), - db.ref("role").withSchema(TableName.IdentityOrgMembership), - db.ref("roleId").withSchema(TableName.IdentityOrgMembership), - db.ref("orgId").withSchema(TableName.IdentityOrgMembership), - db.ref("createdAt").withSchema(TableName.IdentityOrgMembership), - db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), - db.ref("lastLoginAuthMethod").withSchema(TableName.IdentityOrgMembership), - db.ref("lastLoginTime").withSchema(TableName.IdentityOrgMembership), - db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), + db.ref("scopeOrgId").withSchema(TableName.Membership).as("orgId"), + db.ref("createdAt").withSchema(TableName.Membership), + db.ref("updatedAt").withSchema(TableName.Membership), + db.ref("lastLoginAuthMethod").withSchema(TableName.Membership), + db.ref("lastLoginTime").withSchema(TableName.Membership), + db.ref("actorIdentityId").withSchema(TableName.Membership).as("identityId"), db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("hasDeleteProtection").withSchema(TableName.Identity), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), @@ -522,12 +534,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) ) // cr stands for custom role - .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) - .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) - .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) - .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) - .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select(db.ref("id").as("crId").withSchema(TableName.Role)) + .select(db.ref("name").as("crName").withSchema(TableName.Role)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.Role)) + .select(db.ref("description").as("crDescription").withSchema(TableName.Role)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.Role)) .select( db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), @@ -545,13 +556,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { ELSE ??.role END ? `, - [ - TableName.IdentityOrgMembership, - "custom", - TableName.OrgRoles, - TableName.IdentityOrgMembership, - db.raw(orderDirection) - ] + [TableName.MembershipRole, "custom", TableName.Role, TableName.MembershipRole, db.raw(orderDirection)] ); } @@ -566,6 +571,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { crPermission, crName, identityId, + identityOrgId, identityName, hasDeleteProtection, role, @@ -590,7 +596,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { }) => ({ role, roleId, - identityId, + identityId: identityId as string, id, total_count: total_count as string, orgId, @@ -608,9 +614,10 @@ export const identityOrgDALFactory = (db: TDbClient) => { } : undefined, identity: { - id: identityId, + id: identityId as string, name: identityName, hasDeleteProtection, + orgId: identityOrgId, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -646,13 +653,15 @@ export const identityOrgDALFactory = (db: TDbClient) => { }; const countAllOrgIdentities = async ( - { search, ...filter }: Partial & Pick, + { search, ...filter }: Partial & Pick, tx?: Knex ) => { try { - const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + const query = (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) .where(filter) - .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) .count(); if (search?.length) { @@ -667,5 +676,5 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; - return { ...identityOrgOrm, find, findOne, countAllOrgIdentities, searchIdentities }; + return { find, findOne, countAllOrgIdentities, searchIdentities }; }; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f216d6483..f6ec60e9e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipRole, TableName, TRoles } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +12,10 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityDALFactory } from "./identity-dal"; import { TIdentityMetadataDALFactory } from "./identity-metadata-dal"; @@ -30,10 +34,14 @@ type TIdentityServiceFactoryDep = { identityDAL: TIdentityDALFactory; identityMetadataDAL: TIdentityMetadataDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; + membershipIdentityDAL: TMembershipIdentityDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; identityProjectDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; keyStore: Pick; + orgDAL: Pick; + additionalPrivilegeDAL: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -45,7 +53,11 @@ export const identityServiceFactory = ({ identityProjectDAL, permissionService, licenseService, - keyStore + keyStore, + orgDAL, + membershipIdentityDAL, + membershipRoleDAL, + additionalPrivilegeDAL }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -58,33 +70,33 @@ export const identityServiceFactory = ({ actorOrgId, metadata }: TCreateIdentityDTO) => { - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( - role, - orgId - ); - const isCustomRole = Boolean(customRole); + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], orgId); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + const isCustomRole = Boolean(rolePermissionDetails?.role); if (role !== OrgMembershipRole.NoAccess) { const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GrantPrivileges, OrgPermissionSubjects.Identity, permission, - rolePermission + rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to create identity", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GrantPrivileges, OrgPermissionSubjects.Identity ), @@ -102,13 +114,21 @@ export const identityServiceFactory = ({ } const identity = await identityDAL.transaction(async (tx) => { - const newIdentity = await identityDAL.create({ name, hasDeleteProtection }, tx); - await identityOrgMembershipDAL.create( + const newIdentity = await identityDAL.create({ name, hasDeleteProtection, orgId }, tx); + const membership = await membershipIdentityDAL.create( { - identityId: newIdentity.id, - orgId, + scope: AccessScope.Organization, + actorIdentityId: newIdentity.id, + scopeOrgId: orgId + }, + tx + ); + + await membershipRoleDAL.create( + { + membershipId: membership.id, role: isCustomRole ? OrgMembershipRole.Custom : role, - roleId: customRole?.id + customRoleId: rolePermissionDetails?.role?.id }, tx ); @@ -155,59 +175,64 @@ export const identityServiceFactory = ({ }: TUpdateIdentityDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(id, isActorSuperAdmin); - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); + const identityOrgMembership = await membershipIdentityDAL.findOne({ + actorIdentityId: id, + scope: AccessScope.Organization, + scopeOrgId: actorOrgId + }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission, membership } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.orgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - let customRole: TOrgRoles | undefined; + let customRole: TRoles | undefined; if (role) { - const { permission: rolePermission, role: customOrgRole } = await permissionService.getOrgPermissionByRole( - role, - identityOrgMembership.orgId - ); + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); - const isCustomRole = Boolean(customOrgRole); + const isCustomRole = Boolean(rolePermissionDetails?.role); const appliedRolePermissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GrantPrivileges, OrgPermissionSubjects.Identity, permission, - rolePermission + rolePermissionDetails?.permission ); if (!appliedRolePermissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to update identity", - membership.shouldUseNewPrivilegeSystem, + shouldUseNewPrivilegeSystem, OrgPermissionIdentityActions.GrantPrivileges, OrgPermissionSubjects.Identity ), details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions } }); - if (isCustomRole) customRole = customOrgRole; + if (isCustomRole) customRole = rolePermissionDetails?.role; } + const identityDetails = await identityDAL.findById(id); const identity = await identityDAL.transaction(async (tx) => { const newIdentity = - name || hasDeleteProtection + identityDetails.orgId === actorOrgId && (name || hasDeleteProtection) ? await identityDAL.updateById(id, { name, hasDeleteProtection }, tx) - : await identityDAL.findById(id, tx); + : identityDetails; if (role) { - await identityOrgMembershipDAL.updateById( - identityOrgMembership.id, + await membershipRoleDAL.delete({ membershipId: identityOrgMembership.id }, tx); + await membershipRoleDAL.create( { + membershipId: identityOrgMembership.id, role: customRole ? OrgMembershipRole.Custom : role, - roleId: customRole?.id || null + customRoleId: customRole?.id || null }, tx ); @@ -219,12 +244,12 @@ export const identityServiceFactory = ({ }> = []; if (metadata) { - await identityMetadataDAL.delete({ orgId: identityOrgMembership.orgId, identityId: id }, tx); + await identityMetadataDAL.delete({ orgId: identityOrgMembership.scopeOrgId, identityId: id }, tx); if (metadata.length) { const rowsToInsert = metadata.map(({ key, value }) => ({ identityId: newIdentity.id, - orgId: identityOrgMembership.orgId, + orgId: identityOrgMembership.scopeOrgId, key, value })); @@ -239,23 +264,26 @@ export const identityServiceFactory = ({ }; }); - return { ...identity, orgId: identityOrgMembership.orgId }; + return { ...identity, orgId: identityOrgMembership.scopeOrgId }; }; const getIdentityById = async ({ id, actor, actorId, actorOrgId, actorAuthMethod }: TGetIdentityByIdDTO) => { const doc = await identityOrgMembershipDAL.find({ - [`${TableName.IdentityOrgMembership}.identityId` as "identityId"]: id + [`${TableName.Membership}.actorIdentityId` as "actorIdentityId"]: id, + scope: AccessScope.Organization, + scopeOrgId: actorOrgId }); const identity = doc[0]; if (!identity) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identity.orgId, + orgId: identity.orgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); @@ -289,28 +317,66 @@ export const identityServiceFactory = ({ isActorSuperAdmin }: TDeleteIdentityDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(id, isActorSuperAdmin); - - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id }); + const identityOrgMembership = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId: id + }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.orgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); if (identityOrgMembership.identity.hasDeleteProtection) throw new BadRequestError({ message: "Identity has delete protection" }); - const deletedIdentity = await identityDAL.deleteById(id); + if (identityOrgMembership.identity.identityOrgId === actorOrgId) { + const deletedIdentity = await identityDAL.deleteById(id); + await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.scopeOrgId); + return { ...deletedIdentity, orgId: identityOrgMembership.scopeOrgId }; + } - await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.orgId); + await membershipIdentityDAL.transaction(async (tx) => { + await identityMetadataDAL.delete( + { + identityId: id, + orgId: actorOrgId + }, + tx + ); + const identityProjectMembership = await membershipIdentityDAL.find( + { + actorIdentityId: id, + scope: AccessScope.Project, + scopeOrgId: actorOrgId + }, + { tx } + ); + await additionalPrivilegeDAL.delete( + { + actorIdentityId: id, + $in: { + projectId: identityProjectMembership.map((el) => el.scopeProjectId) + } + }, + tx + ); + const doc = await membershipIdentityDAL.delete({ actorIdentityId: id, scopeOrgId: actorOrgId }, tx); + return doc; + }); - return { ...deletedIdentity, orgId: identityOrgMembership.orgId }; + const deletedIdentity = await identityDAL.findById(id); + return { ...deletedIdentity, orgId: identityOrgMembership.scopeOrgId }; }; const listOrgIdentities = async ({ @@ -325,11 +391,19 @@ export const identityServiceFactory = ({ orderDirection, search }: TListOrgIdentitiesByOrgIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const identityMemberships = await identityOrgMembershipDAL.find({ - [`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + scope: AccessScope.Organization, limit, offset, orderBy, @@ -338,7 +412,7 @@ export const identityServiceFactory = ({ }); const totalCount = await identityOrgMembershipDAL.countAllOrgIdentities({ - [`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, search }); @@ -357,7 +431,14 @@ export const identityServiceFactory = ({ orderDirection, searchFilter = {} }: TSearchOrgIdentitiesByOrgIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); const { totalCount, docs } = await identityOrgMembershipDAL.searchIdentities({ @@ -379,19 +460,24 @@ export const identityServiceFactory = ({ actorAuthMethod, actorOrgId }: TListProjectIdentitiesByIdentityIdDTO) => { - const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId }); + const identityOrgMembership = await membershipIdentityDAL.findOne({ + actorIdentityId: identityId, + scope: AccessScope.Organization, + scopeOrgId: actorOrgId + }); if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - identityOrgMembership.orgId, + orgId: identityOrgMembership.scopeOrgId, actorAuthMethod, actorOrgId - ); + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const identityMemberships = await identityProjectDAL.findByIdentityId(identityId); + const identityMemberships = await identityProjectDAL.findByIdentityId(identityId, actorOrgId); return identityMemberships; }; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 248488e9f..1d1f2cbb6 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -112,7 +112,7 @@ export const integrationAuthServiceFactory = ({ }; const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { - const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); + const authorizations = await integrationAuthDAL.getByOrg(actorOrgId); const filteredAuthorizations = await Promise.all( authorizations.map(async (auth) => { diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 995919c55..b665df4ed 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -12,6 +12,7 @@ import { TExternalKmsProviderFns } from "@app/ee/services/external-kms/providers/model"; import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { THsmStatus } from "@app/ee/services/hsm/hsm-types"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { TEnvConfig } from "@app/lib/config/env"; import { symmetricCipherService, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; @@ -392,7 +393,7 @@ export const kmsServiceFactory = ({ }; const importKeyMaterial = async ( - { key, algorithm, name, isReserved, projectId, orgId, keyUsage }: TImportKeyMaterialDTO, + { key, algorithm, name, isReserved, projectId, orgId, keyUsage, kmipMetadata }: TImportKeyMaterialDTO, tx?: Knex ) => { // daniel: currently we only support imports for encrypt/decrypt keys @@ -416,7 +417,8 @@ export const kmsServiceFactory = ({ keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, orgId, isReserved, - projectId + projectId, + kmipMetadata }, db ); @@ -741,23 +743,27 @@ export const kmsServiceFactory = ({ if (!project.kmsSecretManagerEncryptedDataKey) { const lock = await keyStore .acquireLock([KeyStorePrefixes.KmsProjectDataKeyCreation, projectId], 3000, { retryCount: 0 }) - .catch(() => null); + .catch((err) => { + logger.error(err, "KMS. Failed to acquire lock."); + return null; + }); try { if (!lock) { await keyStore.waitTillReady({ key: `${KeyStorePrefixes.WaitUntilReadyKmsProjectDataKeyCreation}${projectId}`, keyCheckCb: (val) => val === "true", - waitingCb: () => logger.debug("KMS. Waiting for secret manager data key to be created"), + waitingCb: () => logger.info("KMS. Waiting for secret manager data key to be created"), delay: 500 }); project = await projectDAL.findById(projectId, trx); } else { + logger.info(`KMS. Generating KMS key for project ${projectId}`); const projectDataKey = await (trx || projectDAL).transaction(async (tx) => { project = await projectDAL.findById(projectId, tx); if (project.kmsSecretManagerEncryptedDataKey) { - return; + return project.kmsSecretManagerEncryptedDataKey; } const dataKey = crypto.randomBytes(32); @@ -1072,17 +1078,22 @@ export const kmsServiceFactory = ({ return { id, name, orgId, isExternal }; }; - const startService = async () => { + const startService = async (hsmStatus: THsmStatus) => { const kmsRootConfig = await kmsRootConfigDAL.transaction(async (tx) => { await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.KmsRootKeyInit]); // check if KMS root key was already generated and saved in DB const existingRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); if (existingRootConfig) return existingRootConfig; - logger.info("KMS: Generating new ROOT Key"); - const newRootKey = crypto.randomBytes(32); - const encryptedRootKey = await $encryptRootKey(newRootKey, RootKeyEncryptionStrategy.Software).catch((err) => { - logger.error({ hsmEnabled: hsmService.isActive() }, "KMS: Failed to encrypt ROOT Key"); + const isHsmActive = hsmStatus.isHsmConfigured; + + logger.info(`KMS: Generating new ROOT Key with ${isHsmActive ? "HSM" : "software"} encryption`); + const newRootKey = isHsmActive ? await hsmService.randomBytes(32) : crypto.randomBytes(32); + + const encryptionStrategy = isHsmActive ? RootKeyEncryptionStrategy.HSM : RootKeyEncryptionStrategy.Software; + + const encryptedRootKey = await $encryptRootKey(newRootKey, encryptionStrategy).catch((err) => { + logger.error({ hsmEnabled: isHsmActive, encryptionStrategy }, "KMS: Failed to encrypt ROOT Key"); throw err; }); @@ -1090,7 +1101,7 @@ export const kmsServiceFactory = ({ // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition id: KMS_ROOT_CONFIG_UUID, encryptedRootKey, - encryptionStrategy: RootKeyEncryptionStrategy.Software + encryptionStrategy }); return newRootConfig; }); @@ -1112,6 +1123,15 @@ export const kmsServiceFactory = ({ return; } + if (strategy === RootKeyEncryptionStrategy.Software) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment before trying to update the encryption strategy to software mode." + }); + } + } + const decryptedRootKey = await $decryptRootKey(kmsRootConfig); const encryptedRootKey = await $encryptRootKey(decryptedRootKey, strategy); diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index ca2401bb6..11badd7a5 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -99,4 +99,5 @@ export type TImportKeyMaterialDTO = { projectId: string; orgId: string; keyUsage: KmsKeyUsage; + kmipMetadata?: Record | null; }; diff --git a/backend/src/services/membership-group/membership-group-dal.ts b/backend/src/services/membership-group/membership-group-dal.ts new file mode 100644 index 000000000..b3c35e46f --- /dev/null +++ b/backend/src/services/membership-group/membership-group-dal.ts @@ -0,0 +1,272 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessScope, AccessScopeData, MembershipsSchema, TableName } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; + +export type TMembershipGroupDALFactory = ReturnType; + +type TFindGroupArg = { + scopeData: AccessScopeData; + tx?: Knex; + filter: Partial<{ + limit: number; + offset: number; + groupId: string; + name: Omit; + role: Omit; + }>; +}; + +type TGetGroupByIdArg = { + scopeData: AccessScopeData; + tx?: Knex; + groupId: string; +}; + +export const membershipGroupDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + + const getGroupById = async ({ scopeData, tx, groupId }: TGetGroupByIdArg) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.actorGroupId`, groupId) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("name").withSchema(TableName.Role).as("roleName"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt") + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { groupName, groupSlug } = el; + return { + ...MembershipsSchema.parse(el), + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }; + }, + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + roleName, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug: roleSlug, + customRoleName: roleName, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + } + ] + }); + + return data?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipGetByGroupId" }); + } + }; + + const findGroups = async ({ scopeData, tx, filter }: TFindGroupArg) => { + try { + const paginatedGroups = (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where((qb) => { + if (filter.groupId) { + void qb.where(`${TableName.Groups}.id`, filter.groupId); + } + + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }); + + if (filter.limit) void paginatedGroups.limit(filter.limit); + if (filter.offset) void paginatedGroups.offset(filter.offset); + + if (filter.name || filter.role) { + buildKnexFilterForSearchResource( + paginatedGroups, + { + name: filter.name!, + role: filter.role! + }, + (attr) => { + switch (attr) { + case "role": + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; + case "name": + return `${TableName.Groups}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + } + ); + } + + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .whereIn(`${TableName.Membership}.id`, paginatedGroups) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), + db.ref("id").withSchema(TableName.Groups).as("groupId"), + + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("name").withSchema(TableName.Role).as("roleName"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt") + ) + .select( + db.raw( + `count(${TableName.Membership}."actorGroupId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total` + ) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { groupId, groupName, groupSlug } = el; + return { + ...MembershipsSchema.parse(el), + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }; + }, + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + roleName, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug: roleSlug, + customRoleName: roleName, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + } + ] + }); + return { data, totalCount: Number((data?.[0] as unknown as { total: number })?.total ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipfindGroup" }); + } + }; + + return { ...orm, findGroups, getGroupById }; +}; diff --git a/backend/src/services/membership-group/membership-group-service.ts b/backend/src/services/membership-group/membership-group-service.ts new file mode 100644 index 000000000..18f6b3ad1 --- /dev/null +++ b/backend/src/services/membership-group/membership-group-service.ts @@ -0,0 +1,325 @@ +import { AccessScope, ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; +import { SearchResourceOperators } from "@app/lib/search-resource/search"; + +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TRoleDALFactory } from "../role/role-dal"; +import { TMembershipGroupDALFactory } from "./membership-group-dal"; +import { + TCreateMembershipGroupDTO, + TDeleteMembershipGroupDTO, + TGetMembershipGroupByGroupIdDTO, + TListMembershipGroupDTO, + TUpdateMembershipGroupDTO +} from "./membership-group-types"; +import { newNamespaceMembershipGroupFactory } from "./namespace/namespace-membership-group-factory"; +import { newOrgMembershipGroupFactory } from "./org/org-membership-group-factory"; +import { newProjectMembershipGroupFactory } from "./project/project-membership-group-factory"; + +type TMembershipGroupServiceFactoryDep = { + membershipGroupDAL: TMembershipGroupDALFactory; + membershipRoleDAL: Pick; + roleDAL: Pick; + permissionService: TPermissionServiceFactory; + orgDAL: TOrgDALFactory; +}; + +export type TMembershipGroupServiceFactory = ReturnType; + +export const membershipGroupServiceFactory = ({ + membershipGroupDAL, + roleDAL, + membershipRoleDAL, + orgDAL, + permissionService +}: TMembershipGroupServiceFactoryDep) => { + const scopeFactory = { + [AccessScope.Organization]: newOrgMembershipGroupFactory({ + orgDAL, + permissionService + }), + [AccessScope.Namespace]: newNamespaceMembershipGroupFactory({}), + [AccessScope.Project]: newProjectMembershipGroupFactory({ + membershipGroupDAL, + orgDAL, + permissionService + }) + }; + + const createMembership = async (dto: TCreateMembershipGroupDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); + if (hasNoPermanentRole) { + throw new BadRequestError({ + message: "Group must have at least one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + await factory.onCreateMembershipGroupGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membership = await membershipGroupDAL.transaction(async (tx) => { + const doc = await membershipGroupDAL.create( + { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorGroupId: dto.data.groupId + }, + tx + ); + + const roleDocs: TMembershipRolesInsert[] = []; + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + await membershipRoleDAL.insertMany(roleDocs, tx); + return doc; + }); + + return { membership }; + }; + + const updateMembership = async (dto: TUpdateMembershipGroupDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateMembershipGroupGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + + const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); + if (hasNoPermanentRole) { + throw new BadRequestError({ + message: "Group must have at least one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipGroupDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorGroupId: dto.selector.groupId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Group doesn't have membership" + }); + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membershipDoc = await membershipGroupDAL.transaction(async (tx) => { + const doc = + typeof data?.isActive === "undefined" + ? existingMembership + : await membershipGroupDAL.updateById( + existingMembership.id, + { + isActive: data.isActive + }, + tx + ); + + const roleDocs: TMembershipRolesInsert[] = []; + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + await membershipRoleDAL.delete( + { + membershipId: doc.id + }, + tx + ); + const roles = await membershipRoleDAL.insertMany(roleDocs, tx); + return { ...doc, roles }; + }); + + return { membership: membershipDoc }; + }; + + const deleteMembership = async (dto: TDeleteMembershipGroupDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteMembershipGroupGuard(dto); + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipGroupDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorGroupId: dto.selector.groupId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Group doesn't have membership" + }); + + if (existingMembership.actorGroupId === dto.permission.id) + throw new BadRequestError({ + message: "You can't delete your own membership" + }); + + const membershipDoc = await membershipGroupDAL.transaction(async (tx) => { + await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); + const doc = await membershipGroupDAL.deleteById(existingMembership.id, tx); + return doc; + }); + return { membership: membershipDoc }; + }; + + const listMemberships = async (dto: TListMembershipGroupDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipGroupGuard(dto); + const memberships = await membershipGroupDAL.findGroups({ + scopeData, + filter: { + limit: dto.data.limit, + offset: dto.data.offset, + name: dto.data.groupName + ? { + [SearchResourceOperators.$contains]: dto.data.groupName + } + : undefined, + role: dto.data.roles?.length + ? { + [SearchResourceOperators.$in]: dto.data.roles + } + : undefined + } + }); + return { memberships: memberships.data, totalCount: memberships.totalCount }; + }; + + const getMembershipByGroupId = async (dto: TGetMembershipGroupByGroupIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetMembershipGroupByGroupIdGuard(dto); + const membership = await membershipGroupDAL.getGroupById({ + scopeData, + groupId: selector.groupId + }); + if (!membership) throw new NotFoundError({ message: `Group membership not found` }); + + return { membership }; + }; + + return { + createMembership, + updateMembership, + deleteMembership, + listMemberships, + getMembershipByGroupId + }; +}; diff --git a/backend/src/services/membership-group/membership-group-types.ts b/backend/src/services/membership-group/membership-group-types.ts new file mode 100644 index 000000000..19c374458 --- /dev/null +++ b/backend/src/services/membership-group/membership-group-types.ts @@ -0,0 +1,79 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TMembershipGroupScopeFactory { + onCreateMembershipGroupGuard: (arg: TCreateMembershipGroupDTO) => Promise; + + onUpdateMembershipGroupGuard: (arg: TUpdateMembershipGroupDTO) => Promise; + onDeleteMembershipGroupGuard: (arg: TDeleteMembershipGroupDTO) => Promise; + onListMembershipGroupGuard: (arg: TListMembershipGroupDTO) => Promise; + onGetMembershipGroupByGroupIdGuard: (arg: TGetMembershipGroupByGroupIdDTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; + getScopeDatabaseFields: (scope: AccessScopeData) => { + scopeOrgId: string; + scopeNamespaceId?: string | null; + scopeProjectId?: string | null; + }; + isCustomRole: (role: string) => boolean; +} + +export type TCreateMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + groupId: string; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TUpdateMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; + data: { + isActive?: boolean; + metadata?: { key: string; value: string }[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TListMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + limit?: number; + offset?: number; + groupName?: string; + roles?: string[]; + }; +}; + +export type TDeleteMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; +}; + +export type TGetMembershipGroupByGroupIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; +}; diff --git a/backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts b/backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts new file mode 100644 index 000000000..5c8973b1b --- /dev/null +++ b/backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts @@ -0,0 +1,61 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipGroupScopeFactory } from "../membership-group-types"; + +type TNamespaceMembershipGroupScopeFactoryDep = Record; + +export const newNamespaceMembershipGroupFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceMembershipGroupScopeFactoryDep +): TMembershipGroupScopeFactory => { + const getScopeField: TMembershipGroupScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const getScopeDatabaseFields: TMembershipGroupScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { scopeOrgId: dto.orgId, scopeNamespaceId: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const isCustomRole: TMembershipGroupScopeFactory["isCustomRole"] = () => { + throw new InternalServerError({ message: "Namespace membership group isCustomRole not implemented" }); + }; + + const onCreateMembershipGroupGuard: TMembershipGroupScopeFactory["onCreateMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group create not implemented" }); + }; + + const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group update not implemented" }); + }; + + const onDeleteMembershipGroupGuard: TMembershipGroupScopeFactory["onDeleteMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group delete not implemented" }); + }; + + const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group list not implemented" }); + }; + + const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership group get by group id not implemented" }); + }; + + return { + onCreateMembershipGroupGuard, + onUpdateMembershipGroupGuard, + onDeleteMembershipGroupGuard, + onListMembershipGroupGuard, + onGetMembershipGroupByGroupIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-group/org/org-membership-group-factory.ts b/backend/src/services/membership-group/org/org-membership-group-factory.ts new file mode 100644 index 000000000..d69db9c08 --- /dev/null +++ b/backend/src/services/membership-group/org/org-membership-group-factory.ts @@ -0,0 +1,128 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; + +import { TMembershipGroupScopeFactory } from "../membership-group-types"; + +type TOrgMembershipGroupScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; +}; + +export const newOrgMembershipGroupFactory = ({ + permissionService, + orgDAL +}: TOrgMembershipGroupScopeFactoryDep): TMembershipGroupScopeFactory => { + const getScopeField: TMembershipGroupScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const getScopeDatabaseFields: TMembershipGroupScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { scopeOrgId: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const isCustomRole: TMembershipGroupScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateMembershipGroupGuard: TMembershipGroupScopeFactory["onCreateMembershipGroupGuard"] = async () => { + throw new BadRequestError({ + message: "Organization membership cannot be created for groups" + }); + }; + + const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + const permissionRoles = await permissionService.getOrgPermissionByRoles( + dto.data.roles.map((el) => el.role), + dto.permission.orgId + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create group org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipGroupGuard: TMembershipGroupScopeFactory["onDeleteMembershipGroupGuard"] = async () => { + throw new BadRequestError({ + message: "Organization membership cannot be deleted for organization scoped group" + }); + }; + + const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + }; + + const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + }; + + return { + onCreateMembershipGroupGuard, + onUpdateMembershipGroupGuard, + onDeleteMembershipGroupGuard, + onListMembershipGroupGuard, + onGetMembershipGroupByGroupIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-group/project/project-membership-group-factory.ts b/backend/src/services/membership-group/project/project-membership-group-factory.ts new file mode 100644 index 000000000..5a63eed1e --- /dev/null +++ b/backend/src/services/membership-group/project/project-membership-group-factory.ts @@ -0,0 +1,188 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + isCustomProjectRole, + ProjectPermissionGroupActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TMembershipGroupDALFactory } from "../membership-group-dal"; +import { TMembershipGroupScopeFactory } from "../membership-group-types"; + +type TProjectMembershipGroupScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + membershipGroupDAL: Pick; +}; + +export const newProjectMembershipGroupFactory = ({ + permissionService, + orgDAL, + membershipGroupDAL +}: TProjectMembershipGroupScopeFactoryDep): TMembershipGroupScopeFactory => { + const getScopeField: TMembershipGroupScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const getScopeDatabaseFields: TMembershipGroupScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { scopeOrgId: dto.orgId, scopeProjectId: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const isCustomRole: TMembershipGroupScopeFactory["isCustomRole"] = (role) => isCustomProjectRole(role); + + const onCreateMembershipGroupGuard: TMembershipGroupScopeFactory["onCreateMembershipGroupGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Create, ProjectPermissionSub.Groups); + const orgMembership = await membershipGroupDAL.findOne({ + actorGroupId: dto.data.groupId, + scopeOrgId: dto.permission.orgId, + scope: AccessScope.Organization + }); + if (!orgMembership) + throw new BadRequestError({ message: `Group ${dto.data.groupId} is missing organization membership` }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.map((el) => el.role), + scope.value + ); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create group project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Edit, ProjectPermissionSub.Groups); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.map((el) => el.role), + scope.value + ); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update group project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipGroupGuard: TMembershipGroupScopeFactory["onDeleteMembershipGroupGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Delete, ProjectPermissionSub.Groups); + }; + + const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + }; + + const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + }; + + return { + onCreateMembershipGroupGuard, + onUpdateMembershipGroupGuard, + onDeleteMembershipGroupGuard, + onListMembershipGroupGuard, + onGetMembershipGroupByGroupIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts new file mode 100644 index 000000000..682bfef3e --- /dev/null +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -0,0 +1,389 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessScope, AccessScopeData, MembershipsSchema, TableName } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; + +import { buildAuthMethods } from "../identity/identity-fns"; + +export type TMembershipIdentityDALFactory = ReturnType; + +type TFindIdentityArg = { + scopeData: AccessScopeData; + tx?: Knex; + filter: Partial<{ + limit: number; + offset: number; + identityId: string; + name: Omit; + role: Omit; + }>; +}; + +type TGetIdentityByIdArg = { + scopeData: AccessScopeData; + tx?: Knex; + identityId: string; +}; + +export const membershipIdentityDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + + const getIdentityById = async ({ scopeData, tx, identityId }: TGetIdentityByIdArg) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.actorIdentityId`, identityId) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) + .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`) + .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) + .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) + .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) + .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) + .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), + + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { + identityId: actorIdentityId, + identityOrgId, + identityHasDeleteProtection, + identityName, + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + alicloudId, + tokenId, + jwtId, + ociId, + ldapId, + tlsCertId + } = el; + return { + ...MembershipsSchema.parse(el), + identity: { + name: identityName, + id: actorIdentityId, + hasDeleteProtection: identityHasDeleteProtection, + identityOrgId, + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + alicloudId, + jwtId, + ldapId, + ociId, + tlsCertId + }) + } + }; + }, + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug: roleSlug, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return data?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipGetByIdentityId" }); + } + }; + + const findIdentities = async ({ scopeData, tx, filter }: TFindIdentityArg) => { + try { + const paginatedIdentitys = (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where((qb) => { + if (filter.identityId) { + void qb.where(`${TableName.Identity}.id`, filter.identityId); + } + + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }); + + if (filter.limit) void paginatedIdentitys.limit(filter.limit); + if (filter.offset) void paginatedIdentitys.offset(filter.offset); + + if (filter.name || filter.role) { + buildKnexFilterForSearchResource( + paginatedIdentitys, + { + name: filter.name!, + role: filter.role! + }, + (attr) => { + switch (attr) { + case "role": + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; + case "name": + return `${TableName.Identity}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + } + ); + } + + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .whereIn(`${TableName.Membership}.id`, paginatedIdentitys) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), + + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt") + ) + .select( + db.raw( + `count(${TableName.Membership}."actorIdentityId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total` + ) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { identityId: actorIdentityId, identityHasDeleteProtection, identityName } = el; + return { + ...MembershipsSchema.parse(el), + identity: { + name: identityName, + id: actorIdentityId, + hasDeleteProtection: identityHasDeleteProtection + } + }; + }, + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug: roleSlug, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + } + ] + }); + return { data, totalCount: Number((data?.[0] as unknown as { total: number })?.total ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipfindIdentity" }); + } + }; + + // this right now only support sub organization + const listAvailableIdentities = async (orgId: string, rootOrgId: string) => { + try { + const usersConnectedToOrg = db + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .select("actorIdentityId"); + + const docs = await db + .replicaNode()(TableName.Membership) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) + .whereNotIn(`${TableName.Membership}.actorIdentityId`, usersConnectedToOrg) + .select( + db.ref("id").withSchema(TableName.Identity), + db.ref("name").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "ListAvailableIdentities" }); + } + }; + + return { ...orm, findIdentities, getIdentityById, listAvailableIdentities }; +}; diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts new file mode 100644 index 000000000..16292ea82 --- /dev/null +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -0,0 +1,361 @@ +import { AccessScope, ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; +import { SearchResourceOperators } from "@app/lib/search-resource/search"; + +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { TRoleDALFactory } from "../role/role-dal"; +import { TMembershipIdentityDALFactory } from "./membership-identity-dal"; +import { + TCreateMembershipIdentityDTO, + TDeleteMembershipIdentityDTO, + TGetMembershipIdentityByIdentityIdDTO, + TListMembershipIdentityDTO, + TUpdateMembershipIdentityDTO +} from "./membership-identity-types"; +import { newNamespaceMembershipIdentityFactory } from "./namespace/namespace-membership-identity-factory"; +import { newOrgMembershipIdentityFactory } from "./org/org-membership-identity-factory"; +import { newProjectMembershipIdentityFactory } from "./project/project-membership-identity-factory"; + +type TMembershipIdentityServiceFactoryDep = { + membershipIdentityDAL: TMembershipIdentityDALFactory; + membershipRoleDAL: Pick; + roleDAL: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getOrgPermission" | "getProjectPermission" | "getProjectPermissionByRoles" | "getOrgPermissionByRoles" + >; + orgDAL: Pick; + additionalPrivilegeDAL: Pick; + identityDAL: Pick; +}; + +export type TMembershipIdentityServiceFactory = ReturnType; + +export const membershipIdentityServiceFactory = ({ + membershipIdentityDAL, + roleDAL, + membershipRoleDAL, + permissionService, + orgDAL, + additionalPrivilegeDAL, + identityDAL +}: TMembershipIdentityServiceFactoryDep) => { + const scopeFactory = { + [AccessScope.Organization]: newOrgMembershipIdentityFactory({ + orgDAL, + permissionService, + identityDAL + }), + [AccessScope.Project]: newProjectMembershipIdentityFactory({ + membershipIdentityDAL, + orgDAL, + permissionService + }), + [AccessScope.Namespace]: newNamespaceMembershipIdentityFactory({}) + }; + + const createMembership = async (dto: TCreateMembershipIdentityDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); + if (hasNoPermanentRole) { + throw new BadRequestError({ + message: "Identity must have at least one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + await factory.onCreateMembershipIdentityGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membership = await membershipIdentityDAL.transaction(async (tx) => { + const doc = await membershipIdentityDAL.create( + { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.data.identityId + }, + tx + ); + + const roleDocs: TMembershipRolesInsert[] = []; + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + await membershipRoleDAL.insertMany(roleDocs, tx); + return doc; + }); + + return { membership }; + }; + + const updateMembership = async (dto: TUpdateMembershipIdentityDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateMembershipIdentityGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + + const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); + if (hasNoPermanentRole) { + throw new BadRequestError({ + message: "Identity must have at least one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipIdentityDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.selector.identityId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Identity doesn't have membership" + }); + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membershipDoc = await membershipIdentityDAL.transaction(async (tx) => { + const doc = + typeof data.isActive === "undefined" + ? existingMembership + : await membershipIdentityDAL.updateById( + existingMembership.id, + { + isActive: data.isActive + }, + tx + ); + + const roleDocs: TMembershipRolesInsert[] = []; + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + await membershipRoleDAL.delete( + { + membershipId: doc.id + }, + tx + ); + const insertedRoleDocs = await membershipRoleDAL.insertMany(roleDocs, tx); + return { ...doc, roles: insertedRoleDocs }; + }); + + return { membership: membershipDoc }; + }; + + const deleteMembership = async (dto: TDeleteMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteMembershipIdentityGuard(dto); + + const scopeField = factory.getScopeField(scopeData); + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipIdentityDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.selector.identityId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Identity doesn't have membership" + }); + + if (existingMembership.actorIdentityId === dto.permission.id) + throw new BadRequestError({ + message: "You can't delete your own membership" + }); + + const membershipDoc = await membershipIdentityDAL.transaction(async (tx) => { + await additionalPrivilegeDAL.delete( + { + actorIdentityId: dto.selector.identityId, + [scopeField.key]: scopeField.value + }, + tx + ); + await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); + const doc = await membershipIdentityDAL.deleteById(existingMembership.id, tx); + return doc; + }); + return { membership: membershipDoc }; + }; + + const listMemberships = async (dto: TListMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipIdentityGuard(dto); + const memberships = await membershipIdentityDAL.findIdentities({ + scopeData, + filter: { + limit: dto.data.limit, + offset: dto.data.offset, + name: dto.data.identityName + ? { + [SearchResourceOperators.$contains]: dto.data.identityName + } + : undefined, + role: dto.data?.roles?.length + ? { + [SearchResourceOperators.$in]: dto.data.roles + } + : undefined + } + }); + return memberships; + }; + + const getMembershipByIdentityId = async (dto: TGetMembershipIdentityByIdentityIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetMembershipIdentityByIdentityIdGuard(dto); + const membership = await membershipIdentityDAL.getIdentityById({ + scopeData, + identityId: selector.identityId + }); + if (!membership) throw new NotFoundError({ message: `Identity membership not found` }); + + return membership; + }; + + const listAvailableIdentities = async (dto: TListMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipIdentityGuard(dto); + + const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); + if (!organizationDetails.rootOrgId) return { identities: [] }; + + const identities = await membershipIdentityDAL.listAvailableIdentities( + organizationDetails.id, + organizationDetails.rootOrgId + ); + + return { identities }; + }; + + return { + createMembership, + updateMembership, + deleteMembership, + listMemberships, + getMembershipByIdentityId, + listAvailableIdentities + }; +}; diff --git a/backend/src/services/membership-identity/membership-identity-types.ts b/backend/src/services/membership-identity/membership-identity-types.ts new file mode 100644 index 000000000..78923cb14 --- /dev/null +++ b/backend/src/services/membership-identity/membership-identity-types.ts @@ -0,0 +1,79 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TMembershipIdentityScopeFactory { + onCreateMembershipIdentityGuard: (arg: TCreateMembershipIdentityDTO) => Promise; + + onUpdateMembershipIdentityGuard: (arg: TUpdateMembershipIdentityDTO) => Promise; + onDeleteMembershipIdentityGuard: (arg: TDeleteMembershipIdentityDTO) => Promise; + onListMembershipIdentityGuard: (arg: TListMembershipIdentityDTO) => Promise; + onGetMembershipIdentityByIdentityIdGuard: (arg: TGetMembershipIdentityByIdentityIdDTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; + getScopeDatabaseFields: (scope: AccessScopeData) => { + scopeOrgId: string; + scopeNamespaceId?: string | null; + scopeProjectId?: string | null; + }; + isCustomRole: (role: string) => boolean; +} + +export type TCreateMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + identityId: string; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TUpdateMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; + data: { + isActive?: boolean; + metadata?: { key: string; value: string }[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TListMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + limit?: number; + offset?: number; + identityName?: string; + roles?: string[]; + }; +}; + +export type TDeleteMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; + +export type TGetMembershipIdentityByIdentityIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; diff --git a/backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts b/backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts new file mode 100644 index 000000000..be3ca6ea9 --- /dev/null +++ b/backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts @@ -0,0 +1,64 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; + +type TNamespaceMembershipIdentityScopeFactoryDep = Record; + +export const newNamespaceMembershipIdentityFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceMembershipIdentityScopeFactoryDep +): TMembershipIdentityScopeFactory => { + const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const getScopeDatabaseFields: TMembershipIdentityScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { scopeOrgId: dto.orgId, scopeNamespaceId: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const isCustomRole: TMembershipIdentityScopeFactory["isCustomRole"] = () => { + throw new InternalServerError({ message: "Namespace membership identity isCustomRole not implemented" }); + }; + + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership identity create not implemented" }); + }; + + const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership identity update not implemented" }); + }; + + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership identity delete not implemented" }); + }; + + const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership identity list not implemented" }); + }; + + const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership identity get by identity id not implemented" }); + }; + + return { + onCreateMembershipIdentityGuard, + onUpdateMembershipIdentityGuard, + onDeleteMembershipIdentityGuard, + onListMembershipIdentityGuard, + onGetMembershipIdentityByIdentityIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts new file mode 100644 index 000000000..8b3bdf6d5 --- /dev/null +++ b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts @@ -0,0 +1,195 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope, OrgMembershipRole } from "@app/db/schemas"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; + +import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; + +type TOrgMembershipIdentityScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + identityDAL: Pick; +}; + +export const newOrgMembershipIdentityFactory = ({ + permissionService, + orgDAL, + identityDAL +}: TOrgMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { + const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const getScopeDatabaseFields: TMembershipIdentityScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { scopeOrgId: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const isCustomRole: TMembershipIdentityScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const identityDetails = await identityDAL.findById(dto.data.identityId); + if (identityDetails.orgId !== dto.permission.rootOrgId) { + throw new BadRequestError({ message: "Only identities from parent organization can be invited" }); + } + + const permissionRoles = await permissionService.getOrgPermissionByRoles( + dto.data.roles.map((el) => el.role), + dto.permission.orgId + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update identity org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + const permissionRoles = await permissionService.getOrgPermissionByRoles( + dto.data.roles.map((el) => el.role), + dto.permission.orgId + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update identity org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.orgId !== dto.permission.rootOrgId) { + throw new BadRequestError({ message: "Only identities from parent organization can do this operation" }); + } + + if (identityDetails.orgId === dto.permission.orgId) { + throw new BadRequestError({ message: "Identity cannot exist as orphan" }); + } + }; + + const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + }; + + const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = + async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + }; + + return { + onCreateMembershipIdentityGuard, + onUpdateMembershipIdentityGuard, + onDeleteMembershipIdentityGuard, + onListMembershipIdentityGuard, + onGetMembershipIdentityByIdentityIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-identity/project/project-membership-identity-factory.ts b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts new file mode 100644 index 000000000..f2896047f --- /dev/null +++ b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts @@ -0,0 +1,208 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + isCustomProjectRole, + ProjectPermissionIdentityActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TMembershipIdentityDALFactory } from "../membership-identity-dal"; +import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; + +type TProjectMembershipIdentityScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + membershipIdentityDAL: Pick; +}; + +export const newProjectMembershipIdentityFactory = ({ + permissionService, + orgDAL, + membershipIdentityDAL +}: TProjectMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { + const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const getScopeDatabaseFields: TMembershipIdentityScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { scopeOrgId: dto.orgId, scopeProjectId: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const isCustomRole: TMembershipIdentityScopeFactory["isCustomRole"] = (role) => isCustomProjectRole(role); + + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + ProjectPermissionSub.Identity + ); + const orgMembership = await membershipIdentityDAL.findOne({ + actorIdentityId: dto.data.identityId, + scopeOrgId: dto.permission.orgId, + scope: AccessScope.Organization + }); + if (!orgMembership) + throw new BadRequestError({ message: `Identity ${dto.data.identityId} is missing organization membership` }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.map((el) => el.role), + scope.value + ); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create identity project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + ProjectPermissionSub.Identity + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.filter((el) => el.role !== ProjectMembershipRole.NoAccess).map((el) => el.role), + scope.value + ); + for (const permissionRole of permissionRoles) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update identity project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + }; + + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Delete, + ProjectPermissionSub.Identity + ); + }; + + const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); + }; + + const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = + async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); + }; + + return { + onCreateMembershipIdentityGuard, + onUpdateMembershipIdentityGuard, + onDeleteMembershipIdentityGuard, + onListMembershipIdentityGuard, + onGetMembershipIdentityByIdentityIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-user/membership-user-dal.ts b/backend/src/services/membership-user/membership-user-dal.ts new file mode 100644 index 000000000..17970f16f --- /dev/null +++ b/backend/src/services/membership-user/membership-user-dal.ts @@ -0,0 +1,327 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessScope, AccessScopeData, MembershipsSchema, TableName } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; + +export type TMembershipUserDALFactory = ReturnType; + +type TFindUserArg = { + scopeData: AccessScopeData; + tx?: Knex; + filter: Partial<{ + limit: number; + offset: number; + userId?: string; + username: Omit; + role: Omit; + }>; +}; + +type TGetUserByIdArg = { + scopeData: AccessScopeData; + tx?: Knex; + userId: string; +}; + +export const membershipUserDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + + const getUserById = async ({ scopeData, tx, userId }: TGetUserByIdArg) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.actorUserId`, userId) + .where(`${TableName.Users}.isGhost`, false) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("id").withSchema(TableName.Role).as("customRoleId"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), + db.ref("username").withSchema(TableName.Users).as("userUsername"), + db.ref("email").withSchema(TableName.Users).as("userEmail"), + db.ref("firstName").withSchema(TableName.Users).as("userFirstName"), + db.ref("lastName").withSchema(TableName.Users).as("userLastName"), + db.ref("id").withSchema(TableName.Users).as("userId") + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + ...MembershipsSchema.parse(el), + user: { + username: el.userUsername, + email: el.userEmail, + firstName: el.userFirstName, + lastName: el.userLastName, + id: el.userId + } + }), + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + customRoleSlug, + customRoleName, + customRoleId, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug, + customRoleName, + customRoleId, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return data?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipGetByUserId" }); + } + }; + + const findUsers = async ({ scopeData, tx, filter }: TFindUserArg) => { + try { + const paginatedUsers = (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Users}.isGhost`, false) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }); + + if (filter.limit) void paginatedUsers.limit(filter.limit); + if (filter.offset) void paginatedUsers.offset(filter.offset); + + if (filter.username || filter.role) { + buildKnexFilterForSearchResource( + paginatedUsers, + { + username: filter.username!, + role: filter.role! + }, + (attr) => { + switch (attr) { + case "role": + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; + case "username": + return `${TableName.Users}.username`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + } + ); + } + + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .whereIn(`${TableName.Membership}.id`, paginatedUsers) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("id").withSchema(TableName.Role).as("customRoleId"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt"), + db.ref("username").withSchema(TableName.Users).as("userUsername"), + db.ref("email").withSchema(TableName.Users).as("userEmail"), + db.ref("firstName").withSchema(TableName.Users).as("userFirstName"), + db.ref("lastName").withSchema(TableName.Users).as("userLastName"), + db.ref("id").withSchema(TableName.Users).as("userId") + ) + .select( + db.raw( + `count(${TableName.Membership}."actorUserId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total` + ) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ + ...MembershipsSchema.parse(el), + user: { + username: el.userUsername, + email: el.userEmail, + firstName: el.userFirstName, + lastName: el.userLastName, + id: el.userId + } + }), + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + customRoleSlug, + customRoleName, + customRoleId, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug, + customRoleName, + customRoleId, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + } + ] + }); + return { data, totalCount: Number((data?.[0] as unknown as { total: number })?.total ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipfindUser" }); + } + }; + + // this right now only support sub organization + const listAvailableUsers = async (orgId: string, rootOrgId: string) => { + try { + const usersConnectedToOrg = db + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .select("actorUserId"); + + const docs = await db + .replicaNode()(TableName.Membership) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Users}.isGhost`, false) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) + .whereNotIn(`${TableName.Membership}.actorUserId`, usersConnectedToOrg) + .select( + db.ref("id").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "ListAvailableUsers" }); + } + }; + + return { ...orm, findUsers, getUserById, listAvailableUsers }; +}; diff --git a/backend/src/services/membership-user/membership-user-service.ts b/backend/src/services/membership-user/membership-user-service.ts new file mode 100644 index 000000000..4b14ee771 --- /dev/null +++ b/backend/src/services/membership-user/membership-user-service.ts @@ -0,0 +1,497 @@ +import { + AccessScope, + OrgMembershipStatus, + ProjectMembershipRole, + TemporaryPermissionMode, + TMembershipRolesInsert +} from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; +import { SearchResourceOperators } from "@app/lib/search-resource/search"; + +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; +import { AuthMethod } from "../auth/auth-type"; +import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TOrgDALFactory } from "../org/org-dal"; +import { deleteOrgMembershipsFn } from "../org/org-fns"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TRoleDALFactory } from "../role/role-dal"; +import { TSmtpService } from "../smtp/smtp-service"; +import { TUserDALFactory } from "../user/user-dal"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; +import { TMembershipUserDALFactory } from "./membership-user-dal"; +import { + TCreateMembershipUserDTO, + TDeleteMembershipUserDTO, + TGetMembershipUserByUserIdDTO, + TListMembershipUserDTO, + TUpdateMembershipUserDTO +} from "./membership-user-types"; +import { newNamespaceMembershipUserFactory } from "./namespace/namespace-membership-user-factory"; +import { newOrgMembershipUserFactory } from "./org/org-membership-user-factory"; +import { newProjectMembershipUserFactory } from "./project/project-membership-user-factory"; + +type TMembershipUserServiceFactoryDep = { + membershipUserDAL: TMembershipUserDALFactory; + membershipRoleDAL: Pick; + orgDAL: Pick; + roleDAL: Pick; + userDAL: TUserDALFactory; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getProjectPermissionByRoles" | "getOrgPermission" + >; + licenseService: TLicenseServiceFactory; + projectKeyDAL: TProjectKeyDALFactory; + userAliasDAL: TUserAliasDALFactory; + smtpService: TSmtpService; + tokenService: TAuthTokenServiceFactory; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; + projectDAL: TProjectDALFactory; + additionalPrivilegeDAL: TAdditionalPrivilegeDALFactory; +}; + +export type TMembershipUserServiceFactory = ReturnType; + +export const membershipUserServiceFactory = ({ + membershipUserDAL, + roleDAL, + membershipRoleDAL, + userDAL, + permissionService, + orgDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + smtpService, + tokenService, + userGroupMembershipDAL, + projectDAL, + additionalPrivilegeDAL +}: TMembershipUserServiceFactoryDep) => { + const scopeFactory = { + [AccessScope.Organization]: newOrgMembershipUserFactory({ + permissionService, + licenseService, + smtpService, + orgDAL, + tokenService, + userDAL, + userGroupMembershipDAL, + membershipUserDAL + }), + [AccessScope.Namespace]: newNamespaceMembershipUserFactory({}), + [AccessScope.Project]: newProjectMembershipUserFactory({ + orgDAL, + permissionService, + membershipUserDAL, + projectDAL, + smtpService + }) + }; + + const $getUsers = async (usernames: string[]) => { + const existingUsers = await userDAL.find({ $in: { username: usernames } }); + if (existingUsers.length !== usernames.length) { + const newUserEmails = usernames.filter( + (inviteeEmail) => !existingUsers.find((el) => el.username === inviteeEmail) + ); + await userDAL.transaction(async (tx) => { + for await (const inviteeEmail of newUserEmails) { + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; + + // if the user doesn't exist we create the user with the email + if (!inviteeUser) { + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } + } + + existingUsers.push(inviteeUser); + const inviteeUserId = inviteeUser?.id; + const existingEncryptionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); + + // when user is missing the encrytion keys + // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp + // So what we do is we generate a random secure password and then encrypt it with a random pub-private key + // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password + if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncryptionKey)) { + await userDAL.createUserEncryption( + { + userId: inviteeUserId, + encryptionVersion: 2 + }, + tx + ); + } + } + }); + } + return existingUsers; + }; + + const createMembership = async (dto: TCreateMembershipUserDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); + if (hasNoPermanentRole) { + throw new BadRequestError({ + message: "User must have at least one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const users = await $getUsers(dto.data.usernames); + const existingMemberships = await membershipUserDAL.find({ + scope: scopeData.scope, + ...scopeDatabaseFields, + $in: { + actorUserId: users.map((el) => el.id) + } + }); + + if (existingMemberships.length === users.length) return { memberships: [] }; + + const newMembershipUsers = users.filter((user) => !existingMemberships?.find((el) => el.actorUserId === user.id)); + await factory.onCreateMembershipUserGuard(dto, newMembershipUsers); + const newMemberships = newMembershipUsers.map((user) => ({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: user.id, + status: scopeData.scope === AccessScope.Organization ? OrgMembershipStatus.Invited : undefined, + inviteEmail: scopeData.scope === AccessScope.Organization ? user.email : undefined + })); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + if (hasCustomRole) { + const plan = await licenseService.getPlan(scopeData.orgId); + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role." + }); + } + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membershipDoc = await membershipUserDAL.transaction(async (tx) => { + const docs = await membershipUserDAL.insertMany(newMemberships, tx); + + const roleDocs: TMembershipRolesInsert[] = []; + docs.forEach((membership) => { + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: membership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: membership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + }); + await membershipRoleDAL.insertMany(roleDocs, tx); + return docs; + }); + + const { signUpTokens } = await factory.onCreateMembershipComplete(dto, newMembershipUsers); + return { memberships: membershipDoc, signUpTokens }; + }; + + const updateMembership = async (dto: TUpdateMembershipUserDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateMembershipUserGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + if (hasCustomRole) { + const plan = await licenseService.getPlan(scopeData.orgId); + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role." + }); + } + + const hasNoPermanentRole = data.roles.every((el) => el.isTemporary); + if (hasNoPermanentRole) { + throw new BadRequestError({ + message: "User must have at least one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipUserDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: dto.selector.userId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "User doesn't have membership" + }); + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membershipDoc = await membershipUserDAL.transaction(async (tx) => { + const doc = + typeof data?.isActive === "undefined" + ? existingMembership + : await membershipUserDAL.updateById( + existingMembership.id, + { + isActive: data.isActive + }, + tx + ); + + const roleDocs: TMembershipRolesInsert[] = []; + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + await membershipRoleDAL.delete( + { + membershipId: doc.id + }, + tx + ); + const insertedRoles = await membershipRoleDAL.insertMany(roleDocs, tx); + return { ...doc, roles: insertedRoles }; + }); + + return { membership: membershipDoc }; + }; + + const deleteMembership = async (dto: TDeleteMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteMembershipUserGuard(dto); + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipUserDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: dto.selector.userId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "User doesn't have membership" + }); + + if (existingMembership.actorUserId === dto.permission.id) + throw new BadRequestError({ + message: "You can't delete your own membership" + }); + + const membershipDoc = await membershipUserDAL.transaction(async (tx) => { + if (dto.scopeData.scope === AccessScope.Organization) { + const [doc] = await deleteOrgMembershipsFn({ + orgMembershipIds: [existingMembership.id], + orgId: dto.permission.orgId, + orgDAL, + projectKeyDAL, + userAliasDAL, + licenseService, + userId: dto.permission.id, + membershipUserDAL, + userGroupMembershipDAL, + membershipRoleDAL, + additionalPrivilegeDAL + }); + return doc; + } + + if (dto.scopeData.scope === AccessScope.Project) { + await additionalPrivilegeDAL.delete( + { + actorUserId: dto.selector.userId, + projectId: dto.scopeData.projectId + }, + tx + ); + } + + await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); + const doc = await membershipUserDAL.deleteById(existingMembership.id, tx); + return doc; + }); + return { membership: membershipDoc }; + }; + + const listMemberships = async (dto: TListMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipUserGuard(dto); + const memberships = await membershipUserDAL.findUsers({ + scopeData, + filter: { + limit: dto.data.limit, + offset: dto.data.offset, + username: dto.data.username, + role: dto.data?.roles?.length + ? { + [SearchResourceOperators.$in]: dto.data.roles + } + : undefined + } + }); + return memberships; + }; + + const getMembershipByUserId = async (dto: TGetMembershipUserByUserIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetMembershipUserByUserIdGuard(dto); + const membership = await membershipUserDAL.getUserById({ + scopeData, + userId: selector.userId + }); + if (!membership) throw new NotFoundError({ message: `User membership not found` }); + + return membership; + }; + + // Should only be used for sub organization as of now + const listAvailableUsers = async (dto: TListMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipUserGuard(dto); + + const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); + if (!organizationDetails.rootOrgId) return { users: [] }; + + const users = await membershipUserDAL.listAvailableUsers(organizationDetails.id, organizationDetails.rootOrgId); + return { users }; + }; + + return { + createMembership, + updateMembership, + deleteMembership, + listMemberships, + getMembershipByUserId, + listAvailableUsers + }; +}; diff --git a/backend/src/services/membership-user/membership-user-types.ts b/backend/src/services/membership-user/membership-user-types.ts new file mode 100644 index 000000000..15982bb6a --- /dev/null +++ b/backend/src/services/membership-user/membership-user-types.ts @@ -0,0 +1,100 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TMembershipUserScopeFactory { + onCreateMembershipUserGuard: ( + arg: TCreateMembershipUserDTO, + newMembers: { id: string; email?: string | null }[] + ) => Promise; + onCreateMembershipComplete: ( + arg: TCreateMembershipUserDTO, + newMembers: { id: string; email?: string | null }[] + ) => Promise<{ signUpTokens: { email: string; link: string }[] }>; + + onUpdateMembershipUserGuard: (arg: TUpdateMembershipUserDTO) => Promise; + onDeleteMembershipUserGuard: (arg: TDeleteMembershipUserDTO | TBulkDeleteMembershipByUsernameDTO) => Promise; + + onListMembershipUserGuard: (arg: TListMembershipUserDTO) => Promise; + onGetMembershipUserByUserIdGuard: (arg: TGetMembershipUserByUserIdDTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; + getScopeDatabaseFields: (scope: AccessScopeData) => { + scopeOrgId: string; + scopeNamespaceId?: string | null; + scopeProjectId?: string | null; + }; + isCustomRole: (role: string) => boolean; +} + +export type TCreateMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + usernames: string[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TUpdateMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + userId: string; + }; + data: { + isActive?: boolean; + metadata?: { key: string; value: string }[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TListMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + limit?: number; + offset?: number; + username?: string; + roles?: string[]; + }; +}; + +export type TDeleteMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + userId: string; + }; +}; + +export type TBulkDeleteMembershipByUsernameDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + usernames: string[]; + }; +}; + +export type TGetMembershipUserByUserIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + userId: string; + }; +}; + +export type TListAvailableUsersDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; +}; diff --git a/backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts b/backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts new file mode 100644 index 000000000..fb2cfc463 --- /dev/null +++ b/backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts @@ -0,0 +1,66 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipUserScopeFactory } from "../membership-user-types"; + +type TNamespaceMembershipUserScopeFactoryDep = Record; + +export const newNamespaceMembershipUserFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceMembershipUserScopeFactoryDep +): TMembershipUserScopeFactory => { + const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const getScopeDatabaseFields: TMembershipUserScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { scopeOrgId: dto.orgId, scopeNamespaceId: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = () => { + throw new InternalServerError({ message: "Namespace membership user isCustomRole not implemented" }); + }; + + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user create not implemented" }); + }; + + const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async () => { + throw new InternalServerError({ message: "Namespace membership user create complete not implemented" }); + }; + + const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user update not implemented" }); + }; + + const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user delete not implemented" }); + }; + + const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user list not implemented" }); + }; + + const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership user get by user id not implemented" }); + }; + + return { + onCreateMembershipUserGuard, + onCreateMembershipComplete, + onUpdateMembershipUserGuard, + onDeleteMembershipUserGuard, + onListMembershipUserGuard, + onGetMembershipUserByUserIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-user/org/org-membership-user-factory.ts b/backend/src/services/membership-user/org/org-membership-user-factory.ts new file mode 100644 index 000000000..d21b27b69 --- /dev/null +++ b/backend/src/services/membership-user/org/org-membership-user-factory.ts @@ -0,0 +1,235 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; + +import { TMembershipUserDALFactory } from "../membership-user-dal"; +import { TMembershipUserScopeFactory } from "../membership-user-types"; + +type TOrgMembershipUserScopeFactoryDep = { + permissionService: Pick; + tokenService: Pick; + userDAL: Pick; + smtpService: Pick; + orgDAL: Pick; + userGroupMembershipDAL: Pick; + licenseService: Pick; + membershipUserDAL: Pick; +}; + +export const newOrgMembershipUserFactory = ({ + permissionService, + tokenService, + userDAL, + orgDAL, + smtpService, + licenseService, + membershipUserDAL +}: TOrgMembershipUserScopeFactoryDep): TMembershipUserScopeFactory => { + const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const getScopeDatabaseFields: TMembershipUserScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { scopeOrgId: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async ( + dto, + newMembers + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + + const plan = await licenseService.getPlan(dto.permission.orgId); + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + name: "InviteUser", + message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." + }); + } + + const org = await orgDAL.findById(dto.permission.orgId); + if (org?.authEnforced) { + throw new ForbiddenRequestError({ + name: "InviteUser", + message: "Failed to invite user due to org-level auth enforced for organization" + }); + } + + if (org.rootOrgId) { + const rootOrgMembership = await membershipUserDAL.find({ + scope: AccessScope.Organization, + $in: { + actorUserId: newMembers.map((el) => el.id) + }, + scopeOrgId: org.rootOrgId + }); + if (rootOrgMembership.length !== newMembers.length) { + const emails = newMembers + .filter((user) => !rootOrgMembership.find((i) => i.actorUserId === user.id)) + .map((el) => el.email) + .join(","); + throw new BadRequestError({ + message: `Users with email ${emails} doesn't have membership in root organization` + }); + } + } + }; + + const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async ( + dto, + newUsers + ) => { + const appCfg = getConfig(); + + const actorDetails = + dto.permission.type === ActorType.USER + ? await userDAL.findById(dto.permission.id) + : { + firstName: "Platform Identity", + email: "identity" + }; + + const signUpTokens: { email: string; link: string }[] = []; + const orgDetails = await orgDAL.findById(dto.permission.orgId); + if (orgDetails.rootOrgId) { + const emails = newUsers.map((el) => el.email).filter(Boolean); + await smtpService.sendMail({ + template: SmtpTemplates.SubOrgInvite, + subjectLine: "Infisical sub-organization invitation", + recipients: emails as string[], + substitutions: { + subOrganizationName: orgDetails.slug, + callback_url: `${appCfg.SITE_URL}/organization/projects?subOrganization=${orgDetails.slug}` + } + }); + } else { + await Promise.allSettled( + newUsers.map(async (el) => { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: el.id, + orgId: dto.permission.orgId + }); + + if (el.email) { + if (!appCfg.isSmtpConfigured) { + signUpTokens.push({ + email: el.email, + link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${dto.permission.orgId}` + }); + } + + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [el.email], + substitutions: { + inviterFirstName: actorDetails?.firstName, + inviterUsername: actorDetails?.email, + organizationName: orgDetails?.name, + email: el.email, + organizationId: orgDetails?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + } + }) + ); + } + + return { signUpTokens }; + }; + + const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + }; + + const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + }; + + const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + }; + + const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + }; + + return { + onCreateMembershipUserGuard, + onCreateMembershipComplete, + onUpdateMembershipUserGuard, + onDeleteMembershipUserGuard, + onListMembershipUserGuard, + onGetMembershipUserByUserIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-user/project/project-membership-user-factory.ts b/backend/src/services/membership-user/project/project-membership-user-factory.ts new file mode 100644 index 000000000..0a9bb955a --- /dev/null +++ b/backend/src/services/membership-user/project/project-membership-user-factory.ts @@ -0,0 +1,239 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType, OrgMembershipStatus, ProjectMembershipRole } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + isCustomProjectRole, + ProjectPermissionMemberActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TMembershipUserDALFactory } from "../membership-user-dal"; +import { TMembershipUserScopeFactory } from "../membership-user-types"; + +type TProjectMembershipUserScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + projectDAL: Pick; + membershipUserDAL: Pick; + smtpService: Pick; +}; + +export const newProjectMembershipUserFactory = ({ + permissionService, + orgDAL, + projectDAL, + membershipUserDAL, + smtpService +}: TProjectMembershipUserScopeFactoryDep): TMembershipUserScopeFactory => { + const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const getScopeDatabaseFields: TMembershipUserScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { scopeOrgId: dto.orgId, scopeProjectId: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = (role) => isCustomProjectRole(role); + + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async ( + dto, + newUsers + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member); + + // TODO(namespace): this becomes tricky in namespace due to group flow + const orgMemberships = await membershipUserDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: dto.permission.orgId, + $in: { + actorUserId: newUsers.map((el) => el.id) + } + }); + if (orgMemberships.length !== newUsers.length) { + const missingUsers = newUsers + .filter((el) => !orgMemberships.find((memb) => memb.actorUserId === el.id)) + .map((el) => el.email); + throw new BadRequestError({ message: `Users ${missingUsers.join(",")} not part of organization` }); + } + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.filter((el) => el.role !== ProjectMembershipRole.NoAccess).map((el) => el.role), + scope.value + ); + + for (const permissionRole of permissionRoles) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create user project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + }; + + const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async ( + dto, + newMembers + ) => { + const orgMembershipAccepted = await membershipUserDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: dto.permission.orgId, + status: OrgMembershipStatus.Accepted, + $in: { + actorUserId: newMembers.map((el) => el.id) + } + }); + + if (!orgMembershipAccepted.length) return { signUpTokens: [] }; + + const appCfg = getConfig(); + const scope = getScopeField(dto.scopeData); + const project = await projectDAL.findById(scope.value); + + const orgMembershipAcceptedUserIds = orgMembershipAccepted.map((el) => el.actorUserId as string); + const emails = newMembers + .filter((el) => Boolean(el?.email) && orgMembershipAcceptedUserIds.includes(el.id)) + .map((el) => el?.email as string); + if (emails.length) { + await smtpService.sendMail({ + template: SmtpTemplates.WorkspaceInvite, + subjectLine: "Infisical project invitation", + recipients: emails, + substitutions: { + workspaceName: project.name, + callback_url: `${appCfg.SITE_URL}/login` + } + }); + } + return { signUpTokens: [] }; + }; + + const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.filter((el) => el.role !== ProjectMembershipRole.NoAccess).map((el) => el.role), + scope.value + ); + + for (const permissionRole of permissionRoles) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update user project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionSub.Member + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + }; + + const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Delete, ProjectPermissionSub.Member); + }; + + const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); + }; + + const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); + }; + + return { + onCreateMembershipUserGuard, + onCreateMembershipComplete, + onUpdateMembershipUserGuard, + onDeleteMembershipUserGuard, + onListMembershipUserGuard, + onGetMembershipUserByUserIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership/membership-dal.ts b/backend/src/services/membership/membership-dal.ts new file mode 100644 index 000000000..03ee6af92 --- /dev/null +++ b/backend/src/services/membership/membership-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 TMembershipDALFactory = ReturnType; + +export const membershipDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + return orm; +}; diff --git a/backend/src/services/membership/membership-role-dal.ts b/backend/src/services/membership/membership-role-dal.ts new file mode 100644 index 000000000..bb7011221 --- /dev/null +++ b/backend/src/services/membership/membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TMembershipRoleDALFactory = ReturnType; + +export const membershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.MembershipRole); + return orm; +}; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts index 23ed61402..ff17daa75 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-service.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -9,6 +9,7 @@ import { import { CronJob } from "cron"; import { FastifyReply, FastifyRequest } from "fastify"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; @@ -208,13 +209,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -282,13 +284,14 @@ export const microsoftTeamsServiceFactory = ({ description, redirectUri }: TCreateMicrosoftTeamsIntegrationDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -393,13 +396,14 @@ export const microsoftTeamsServiceFactory = ({ }); }; const getClientId = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGetClientIdDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -427,13 +431,14 @@ export const microsoftTeamsServiceFactory = ({ actorOrgId, actorAuthMethod }: TGetMicrosoftTeamsIntegrationByOrgDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -463,13 +468,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -495,13 +501,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -549,13 +556,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); @@ -577,13 +585,14 @@ export const microsoftTeamsServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - microsoftTeamsIntegration.orgId, + orgId: microsoftTeamsIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts index a3657c680..84cf35a50 100644 --- a/backend/src/services/notification/notification-types.ts +++ b/backend/src/services/notification/notification-types.ts @@ -15,7 +15,9 @@ export enum NotificationType { DIRECT_PROJECT_ACCESS_ISSUED_TO_ADMIN = "direct-project-access-issued-to-admin", PROJECT_ACCESS_REQUEST = "project-access-request", PROJECT_INVITATION = "project-invitation", - SECRET_SYNC_FAILED = "secret-sync-failed" + SECRET_SYNC_FAILED = "secret-sync-failed", + GATEWAY_HEALTH_ALERT = "gateway-health-alert", + RELAY_HEALTH_ALERT = "relay-health-alert" } export interface TCreateUserNotificationDTO { diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 995afadc1..4f4a9b08c 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -1,26 +1,25 @@ import { ForbiddenError } from "@casl/ability"; -import { ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, ProjectMembershipRole, ProjectVersion } from "@app/db/schemas"; import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TNotificationServiceFactory } from "../notification/notification-service"; import { NotificationType } from "../notification/notification-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types"; type TOrgAdminServiceFactoryDep = { permissionService: Pick; projectDAL: Pick; - projectMembershipDAL: Pick< - TProjectMembershipDALFactory, - "findOne" | "create" | "transaction" | "delete" | "findAllProjectMembers" - >; - projectUserMembershipRoleDAL: Pick; + projectMembershipDAL: Pick; + membershipUserDAL: TMembershipUserDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; smtpService: Pick; notificationService: Pick; }; @@ -31,9 +30,10 @@ export const orgAdminServiceFactory = ({ permissionService, projectDAL, projectMembershipDAL, - projectUserMembershipRoleDAL, smtpService, - notificationService + notificationService, + membershipUserDAL, + membershipRoleDAL }: TOrgAdminServiceFactoryDep) => { const listOrgProjects = async ({ actor, @@ -44,13 +44,14 @@ export const orgAdminServiceFactory = ({ actorOrgId, actorAuthMethod }: TListOrgProjectsDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole @@ -76,13 +77,14 @@ export const orgAdminServiceFactory = ({ actorAuthMethod, projectId }: TAccessProjectDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan( OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole @@ -98,17 +100,18 @@ export const orgAdminServiceFactory = ({ } // check already there exist a membership if there return it - const projectMembership = await projectMembershipDAL.findOne({ - projectId, - userId: actorId + const projectMembership = await membershipUserDAL.findOne({ + scopeProjectId: projectId, + scope: AccessScope.Project, + actorUserId: actorId }); if (projectMembership) { // reset and make the user admin - await projectMembershipDAL.transaction(async (tx) => { - await projectUserMembershipRoleDAL.delete({ projectMembershipId: projectMembership.id }, tx); - await projectUserMembershipRoleDAL.create( + await membershipUserDAL.transaction(async (tx) => { + await membershipRoleDAL.delete({ membershipId: projectMembership.id }, tx); + await membershipRoleDAL.create( { - projectMembershipId: projectMembership.id, + membershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, tx @@ -117,18 +120,17 @@ export const orgAdminServiceFactory = ({ return { isExistingMember: true, membership: projectMembership }; } - const updatedMembership = await projectMembershipDAL.transaction(async (tx) => { - const newProjectMembership = await projectMembershipDAL.create( + const updatedMembership = await membershipUserDAL.transaction(async (tx) => { + const newProjectMembership = await membershipUserDAL.create( { - projectId, - userId: actorId + scopeProjectId: projectId, + actorUserId: actorId, + scope: AccessScope.Project, + scopeOrgId: actorOrgId }, tx ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: newProjectMembership.id, role: ProjectMembershipRole.Admin }, - tx - ); + await membershipRoleDAL.create({ membershipId: newProjectMembership.id, role: ProjectMembershipRole.Admin }, tx); return newProjectMembership; }); diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 2d7992d34..c52c2834a 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -1,19 +1,21 @@ import { TDbClient } from "@app/db"; -import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { AccessScope, TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, sqlNestRelationships } from "@app/lib/knex"; +import { sqlNestRelationships } from "@app/lib/knex"; export type TOrgMembershipDALFactory = ReturnType; export const orgMembershipDALFactory = (db: TDbClient) => { - const orgMembershipOrm = ormify(db, TableName.OrgMembership); - const findOrgMembershipById = async (membershipId: string) => { try { const member = await db - .replicaNode()(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.id`, membershipId) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.id`, membershipId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.Role}.id`, `${TableName.MembershipRole}.customRoleId`) .leftJoin( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, @@ -21,19 +23,20 @@ export const orgMembershipDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder - .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) - .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + .on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); }) .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("isActive").withSchema(TableName.OrgMembership), - db.ref("lastLoginAuthMethod").withSchema(TableName.OrgMembership), - db.ref("lastLoginTime").withSchema(TableName.OrgMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("inviteEmail").withSchema(TableName.Membership), + db.ref("scopeOrgId").withSchema(TableName.Membership).as("orgId"), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("status").withSchema(TableName.Membership), + db.ref("isActive").withSchema(TableName.Membership), + db.ref("lastLoginAuthMethod").withSchema(TableName.Membership), + db.ref("lastLoginTime").withSchema(TableName.Membership), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -55,6 +58,7 @@ export const orgMembershipDALFactory = (db: TDbClient) => { parentMapper: ({ email, isEmailVerified, + customRoleSlug, username, firstName, lastName, @@ -74,6 +78,7 @@ export const orgMembershipDALFactory = (db: TDbClient) => { orgId, id, role, + customRoleSlug, status, isActive, inviteEmail, @@ -117,18 +122,20 @@ export const orgMembershipDALFactory = (db: TDbClient) => { const twelveMonthsAgo = new Date(now.getTime() - 360 * 24 * 60 * 60 * 1000); const memberships = await db - .replicaNode()(TableName.OrgMembership) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) .where("status", "invited") .where((qb) => { // lastInvitedAt is null AND createdAt is between 1 week and 12 months ago void qb - .whereNull(`${TableName.OrgMembership}.lastInvitedAt`) - .whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); + .whereNull(`${TableName.Membership}.lastInvitedAt`) + .whereBetween(`${TableName.Membership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago void qb.orWhere((qbInner) => { void qbInner - .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) - .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + .where(`${TableName.Membership}.lastInvitedAt`, "<", oneWeekAgo) + .where(`${TableName.Membership}.createdAt`, ">", oneMonthAgo); }); }); @@ -144,7 +151,11 @@ export const orgMembershipDALFactory = (db: TDbClient) => { const updateLastInvitedAtByIds = async (membershipIds: string[]) => { try { if (membershipIds.length === 0) return; - await db(TableName.OrgMembership).whereIn("id", membershipIds).update({ lastInvitedAt: new Date() }); + await db(TableName.Membership) + .whereIn("id", membershipIds) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .update({ lastInvitedAt: new Date() }); } catch (error) { throw new DatabaseError({ error, @@ -156,9 +167,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => { const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => { try { const members = await db - .replicaNode()(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .leftJoin( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, @@ -166,17 +180,17 @@ export const orgMembershipDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { void queryBuilder - .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) - .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + .on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); }) .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("isActive").withSchema(TableName.OrgMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("inviteEmail").withSchema(TableName.Membership), + db.ref("scopeOrgId").withSchema(TableName.Membership).as("orgId"), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("customRoleId"), + db.ref("status").withSchema(TableName.Membership), + db.ref("isActive").withSchema(TableName.Membership), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -207,7 +221,6 @@ export const orgMembershipDALFactory = (db: TDbClient) => { }; return { - ...orgMembershipOrm, findOrgMembershipById, findRecentInvitedMemberships, updateLastInvitedAtByIds, diff --git a/backend/src/services/org/org-bot-dal.ts b/backend/src/services/org/org-bot-dal.ts deleted file mode 100644 index b2ee54758..000000000 --- a/backend/src/services/org/org-bot-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TOrgBotDALFactory = ReturnType; - -export const orgBotDALFactory = (db: TDbClient) => { - const orgBotOrm = ormify(db, TableName.OrgBot); - return orgBotOrm; -}; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 925784bff..c2565f36b 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -2,14 +2,16 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + AccessScope, OrganizationsSchema, OrgMembershipRole, + OrgMembershipStatus, TableName, + TMemberships, + TMembershipsInsert, + TMembershipsUpdate, TOrganizations, TOrganizationsInsert, - TOrgMemberships, - TOrgMembershipsInsert, - TOrgMembershipsUpdate, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -24,6 +26,7 @@ import { } from "@app/lib/knex"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; +import { ActorType } from "../auth/auth-type"; import { OrgAuthMethod } from "./org-types"; export type TOrgDALFactory = ReturnType; @@ -43,8 +46,6 @@ export const orgDALFactory = (db: TDbClient) => { sortBy?: keyof TOrganizations; }) => { try { - const query = db.replicaNode()(TableName.Organization); - // Build the subquery for limited organization IDs const orgSubquery = db.replicaNode().select("id").from(TableName.Organization); @@ -54,37 +55,49 @@ export const orgDALFactory = (db: TDbClient) => { }); } + const countQuery = orgSubquery.clone(); + if (sortBy) { void orgSubquery.orderBy(sortBy); } - void orgSubquery.limit(limit).offset(offset); - // Main query with joins, limited to the subquery results - const docs = await query - .whereIn(`${TableName.Organization}.id`, orgSubquery) - .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) - .leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) - .leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) - .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) - .where((qb) => { - void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`); - }) - .select(selectAllTableCols(TableName.Organization)) - .select(db.ref("name").withSchema(TableName.Project).as("projectName")) - .select(db.ref("id").withSchema(TableName.Project).as("projectId")) - .select(db.ref("slug").withSchema(TableName.Project).as("projectSlug")) - .select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt")) - .select(db.ref("email").withSchema(TableName.Users).as("userEmail")) - .select(db.ref("username").withSchema(TableName.Users).as("username")) - .select(db.ref("firstName").withSchema(TableName.Users).as("firstName")) - .select(db.ref("lastName").withSchema(TableName.Users).as("lastName")) - .select(db.ref("id").withSchema(TableName.Users).as("userId")) - .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) - .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) - .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) - .select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus")) - .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); + const buildBaseQuery = (orgIdSubquery: Knex.QueryBuilder) => { + return db + .replicaNode()(TableName.Organization) + .whereNull(`${TableName.Organization}.rootOrgId`) + .whereIn(`${TableName.Organization}.id`, orgIdSubquery) + .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + .leftJoin(TableName.Membership, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .leftJoin(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .leftJoin(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .where((qb) => { + void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`); + }); + }; + + const [docs, totalResult] = await Promise.all([ + buildBaseQuery(orgSubquery) + .select(selectAllTableCols(TableName.Organization)) + .select(db.ref("name").withSchema(TableName.Project).as("projectName")) + .select(db.ref("id").withSchema(TableName.Project).as("projectId")) + .select(db.ref("slug").withSchema(TableName.Project).as("projectSlug")) + .select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt")) + .select(db.ref("email").withSchema(TableName.Users).as("userEmail")) + .select(db.ref("username").withSchema(TableName.Users).as("username")) + .select(db.ref("firstName").withSchema(TableName.Users).as("firstName")) + .select(db.ref("lastName").withSchema(TableName.Users).as("lastName")) + .select(db.ref("id").withSchema(TableName.Users).as("userId")) + .select(db.ref("id").withSchema(TableName.Membership).as("orgMembershipId")) + .select(db.ref("status").withSchema(TableName.Membership).as("orgMembershipStatus")) + .select(db.ref("role").withSchema(TableName.MembershipRole).as("orgMembershipRole")) + .select(db.ref("customRoleId").withSchema(TableName.MembershipRole).as("orgMembershipRoleId")) + .select(db.ref("name").withSchema(TableName.Role).as("orgMembershipRoleName")), + buildBaseQuery(countQuery).countDistinct(`${TableName.Organization}.id`, { as: "count" }).first() + ]); const formattedDocs = sqlNestRelationships({ data: docs, @@ -132,17 +145,60 @@ export const orgDALFactory = (db: TDbClient) => { ] }); - return formattedDocs; + const total = Number(totalResult?.count || 0); + + return { + organizations: formattedDocs, + total + }; } catch (error) { throw new DatabaseError({ error, name: "Find organizations by filter" }); } }; + const listSubOrganizations = async (dto: { + actorId: string; + actorType: ActorType; + orgId: string; + isAccessible?: boolean; + limit?: number; + offset?: number; + }) => { + try { + // TODO(sub-org:group): check this when implement group support + const query = db + .replicaNode()(TableName.Organization) + .where(`${TableName.Organization}.rootOrgId`, dto.orgId) + .select(selectAllTableCols(TableName.Organization)); + + if (dto.isAccessible) { + void query + .leftJoin(`${TableName.Membership}`, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) + .where((qb) => { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + if (dto.actorType === ActorType.IDENTITY) { + void qb.andWhere(`${TableName.Membership}.actorIdentityId`, dto.actorId); + } else { + void qb.andWhere(`${TableName.Membership}.actorUserId`, dto.actorId); + } + }); + } + if (dto.limit) void query.limit(dto.limit); + if (dto.offset) void query.offset(dto.offset); + + const orgs = await query; + return orgs; + } catch (error) { + throw new DatabaseError({ error, name: "List sub organization" }); + } + }; + const findOrgById = async (orgId: string) => { try { const org = (await db .replicaNode()(TableName.Organization) .where({ [`${TableName.Organization}.id` as "id"]: orgId }) + .whereNull(`${TableName.Organization}.rootOrgId`) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( `${TableName.SamlConfig}.isActive`, @@ -179,6 +235,7 @@ export const orgDALFactory = (db: TDbClient) => { try { const org = (await db .replicaNode()(TableName.Organization) + .whereNull(`${TableName.Organization}.rootOrgId`) .where({ [`${TableName.Organization}.slug` as "slug"]: orgSlug }) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( @@ -218,9 +275,13 @@ export const orgDALFactory = (db: TDbClient) => { ): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[]> => { try { const org = (await db - .replicaNode()(TableName.OrgMembership) - .where({ userId }) - .join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.actorUserId`, userId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) + .whereNull(`${TableName.Organization}.rootOrgId`) .leftJoin(TableName.SamlConfig, (qb) => { qb.on(`${TableName.SamlConfig}.orgId`, "=", `${TableName.Organization}.id`).andOn( `${TableName.SamlConfig}.isActive`, @@ -236,8 +297,8 @@ export const orgDALFactory = (db: TDbClient) => { ); }) .select(selectAllTableCols(TableName.Organization)) - .select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole")) - .select(db.ref("status").withSchema(TableName.OrgMembership).as("userStatus")) + .select(db.ref("role").withSchema(TableName.MembershipRole).as("userRole")) + .select(db.ref("status").withSchema(TableName.Membership).as("userStatus")) .select( db.raw(` CASE @@ -272,24 +333,28 @@ export const orgDALFactory = (db: TDbClient) => { const findAllOrgMembers = async (orgId: string) => { try { const members = await db - .replicaNode()(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.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("isActive").withSchema(TableName.OrgMembership), - db.ref("lastLoginAuthMethod").withSchema(TableName.OrgMembership), - db.ref("lastLoginTime").withSchema(TableName.OrgMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("inviteEmail").withSchema(TableName.Membership), + db.ref("scopeOrgId").withSchema(TableName.Membership).as("orgId"), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), + db.ref("status").withSchema(TableName.Membership), + db.ref("isActive").withSchema(TableName.Membership), + db.ref("lastLoginAuthMethod").withSchema(TableName.Membership), + db.ref("lastLoginTime").withSchema(TableName.Membership), db.ref("email").withSchema(TableName.Users), db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), @@ -314,6 +379,7 @@ export const orgDALFactory = (db: TDbClient) => { } }; + // TODO(sub-org): updated this logic later const countAllOrgMembers = async (orgId: string) => { try { interface CountResult { @@ -321,11 +387,14 @@ export const orgDALFactory = (db: TDbClient) => { } const count = await db - .replicaNode()(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.status`, OrgMembershipStatus.Accepted) + .whereNotNull(`${TableName.Membership}.actorUserId`) .count("*") - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) - .where({ isGhost: false, [`${TableName.OrgMembership}.isActive` as "isActive"]: true }) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .where({ isGhost: false, [`${TableName.Membership}.isActive` as "isActive"]: true }) .first(); return parseInt((count as unknown as CountResult).count || "0", 10); @@ -337,21 +406,25 @@ export const orgDALFactory = (db: TDbClient) => { const findOrgMembersByUsername = async (orgId: string, usernames: string[], tx?: Knex) => { try { const conn = tx || db.replicaNode(); - const members = await conn(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + const members = await conn(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) .select( - conn.ref("id").withSchema(TableName.OrgMembership), - conn.ref("inviteEmail").withSchema(TableName.OrgMembership), - conn.ref("orgId").withSchema(TableName.OrgMembership), - conn.ref("role").withSchema(TableName.OrgMembership), - conn.ref("roleId").withSchema(TableName.OrgMembership), - conn.ref("status").withSchema(TableName.OrgMembership), + conn.ref("id").withSchema(TableName.Membership), + conn.ref("inviteEmail").withSchema(TableName.Membership), + conn.ref("scopeOrgId").withSchema(TableName.Membership).as("orgId"), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), + conn.ref("status").withSchema(TableName.Membership), conn.ref("username").withSchema(TableName.Users), conn.ref("email").withSchema(TableName.Users), conn.ref("firstName").withSchema(TableName.Users), @@ -373,22 +446,25 @@ export const orgDALFactory = (db: TDbClient) => { const findOrgMembersByRole = async (orgId: string, role: OrgMembershipRole, tx?: Knex) => { try { const conn = tx || db.replicaNode(); - const members = await conn(TableName.OrgMembership) - .where(`${TableName.OrgMembership}.orgId`, orgId) - .where(`${TableName.OrgMembership}.role`, role) - .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + const members = await conn(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .where(`${TableName.MembershipRole}.role`, role) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .leftJoin( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) .select( - conn.ref("id").withSchema(TableName.OrgMembership), - conn.ref("inviteEmail").withSchema(TableName.OrgMembership), - conn.ref("orgId").withSchema(TableName.OrgMembership), - conn.ref("role").withSchema(TableName.OrgMembership), - conn.ref("roleId").withSchema(TableName.OrgMembership), - conn.ref("status").withSchema(TableName.OrgMembership), + conn.ref("id").withSchema(TableName.Membership), + conn.ref("inviteEmail").withSchema(TableName.Membership), + conn.ref("scopeOrgId").withSchema(TableName.Membership).as("orgId"), + conn.ref("role").withSchema(TableName.MembershipRole), + conn.ref("customRoleId").withSchema(TableName.MembershipRole).as("roleId"), + conn.ref("status").withSchema(TableName.Membership), conn.ref("username").withSchema(TableName.Users), conn.ref("email").withSchema(TableName.Users), conn.ref("firstName").withSchema(TableName.Users), @@ -407,47 +483,6 @@ export const orgDALFactory = (db: TDbClient) => { } }; - const findOrgGhostUser = async (orgId: string) => { - try { - const member = await db - .replicaNode()(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 - .replicaNode()(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("*"); @@ -480,38 +515,37 @@ export const orgDALFactory = (db: TDbClient) => { // MEMBERSHIP OPERATIONS // -------------------- - // const orgMembershipOrm = ormify(db, TableName.OrgMembership); - const createMembership = async (data: TOrgMembershipsInsert, tx?: Knex) => { + const createMembership = async (data: TMembershipsInsert, tx?: Knex) => { try { - const [membership] = await (tx || db)(TableName.OrgMembership).insert(data).returning("*"); + const [membership] = await (tx || db)(TableName.Membership).insert(data).returning("*"); return membership; } catch (error) { throw new DatabaseError({ error, name: "Create org membership" }); } }; - const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => { + const bulkCreateMemberships = async (data: TMembershipsInsert[], tx?: Knex) => { try { - const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*"); + const memberships = await (tx || db)(TableName.Membership).insert(data).returning("*"); return memberships; } catch (error) { throw new DatabaseError({ error, name: "Create org memberships" }); } }; - const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => { + const updateMembershipById = async (id: string, data: TMembershipsUpdate, tx?: Knex) => { try { - const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*"); + const [membership] = await (tx || db)(TableName.Membership).where({ id }).update(data).returning("*"); return membership; } catch (error) { throw new DatabaseError({ error, name: "Update org membership" }); } }; - const updateMembership = async (filter: Partial, data: TOrgMembershipsUpdate, tx?: Knex) => { + const updateMembership = async (filter: Partial, data: TMembershipsUpdate, tx?: Knex) => { try { - const membership = await (tx || db)(TableName.OrgMembership).where(filter).update(data).returning("*"); + const membership = await (tx || db)(TableName.Membership).where(filter).update(data).returning("*"); return membership; } catch (error) { throw new DatabaseError({ error, name: "Update org memberships" }); @@ -520,7 +554,10 @@ export const orgDALFactory = (db: TDbClient) => { const deleteMembershipById = async (id: string, orgId: string, tx?: Knex) => { try { - const [membership] = await (tx || db)(TableName.OrgMembership).where({ id, orgId }).delete().returning("*"); + const [membership] = await (tx || db)(TableName.Membership) + .where({ id, scopeOrgId: orgId, scope: AccessScope.Organization }) + .delete() + .returning("*"); return membership; } catch (error) { throw new DatabaseError({ error, name: "Delete org membership" }); @@ -529,9 +566,10 @@ export const orgDALFactory = (db: TDbClient) => { const deleteMembershipsById = async (ids: string[], orgId: string, tx?: Knex) => { try { - const memberships = await (tx || db)(TableName.OrgMembership) + const memberships = await (tx || db)(TableName.Membership) .where({ - orgId + scopeOrgId: orgId, + scope: AccessScope.Organization }) .whereIn("id", ids) .delete() @@ -543,22 +581,23 @@ export const orgDALFactory = (db: TDbClient) => { }; const findMembership = async ( - filter: TFindFilter, - { offset, limit, sort, tx }: TFindOpt = {} + filter: TFindFilter, + { offset, limit, sort, tx }: TFindOpt = {} ) => { try { - const query = (tx || db.replicaNode())(TableName.OrgMembership) + const query = (tx || db.replicaNode())(TableName.Membership) // eslint-disable-next-line .where(buildFindFilter(filter)) - .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) - .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .where("scope", AccessScope.Organization) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) .leftJoin(TableName.UserAliases, function joinUserAlias() { - this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) - .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.Membership}.actorUserId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.Membership}.scopeOrgId`) .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); }) .select( - selectAllTableCols(TableName.OrgMembership), + selectAllTableCols(TableName.Membership), db.ref("email").withSchema(TableName.Users), db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), @@ -584,18 +623,20 @@ export const orgDALFactory = (db: TDbClient) => { const findMembershipWithScimFilter = async ( orgId: string, scimFilter: string | undefined, - { offset, limit, sort, tx }: TFindOpt = {} + { offset, limit, sort, tx }: TFindOpt = {} ) => { try { - const query = (tx || db.replicaNode())(TableName.OrgMembership) + const query = (tx || db.replicaNode())(TableName.Membership) // eslint-disable-next-line - .where(`${TableName.OrgMembership}.orgId`, orgId) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) .where((qb) => { if (scimFilter) { void generateKnexQueryFromScim(qb, scimFilter, (attrPath) => { switch (attrPath) { case "active": - return `${TableName.OrgMembership}.isActive`; + return `${TableName.Membership}.isActive`; case "userName": return `${TableName.UserAliases}.externalId`; case "name.givenName": @@ -610,15 +651,16 @@ export const orgDALFactory = (db: TDbClient) => { }); } }) - .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) - .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) + .whereNull(`${TableName.Organization}.rootOrgId`) .leftJoin(TableName.UserAliases, function joinUserAlias() { - this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) - .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.Membership}.actorUserId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.Membership}.scopeOrgId`) .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); }) .select( - selectAllTableCols(TableName.OrgMembership), + selectAllTableCols(TableName.Membership), db.ref("email").withSchema(TableName.Users), db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), @@ -647,13 +689,17 @@ export const orgDALFactory = (db: TDbClient) => { ): Promise<{ id: string; name: string; slug: string; role: string }> => { try { const org = await db - .replicaNode()(TableName.IdentityOrgMembership) - .where({ identityId }) - .join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`) + .replicaNode()(TableName.Membership) + .where({ actorIdentityId: identityId }) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNull(`${TableName.Organization}.rootOrgId`) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .join(TableName.Organization, `${TableName.Membership}.scopeOrgId`, `${TableName.Organization}.id`) .select(db.ref("id").withSchema(TableName.Organization).as("id")) .select(db.ref("name").withSchema(TableName.Organization).as("name")) .select(db.ref("slug").withSchema(TableName.Organization).as("slug")) - .select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role")); + .select(db.ref("role").withSchema(TableName.MembershipRole).as("role")); return org?.[0]; } catch (error) { @@ -661,19 +707,36 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findRootOrgDetails = async (orgId: string, tx?: Knex): Promise => { + try { + const org = await (tx ?? db.replicaNode())(TableName.Organization) + .select(selectAllTableCols(TableName.Organization)) + .where( + "id", + db(TableName.Organization) + .select(db.raw(`CASE WHEN "rootOrgId" IS NULL THEN id ELSE "rootOrgId" END`)) + .where("id", orgId) + ) + .first(); + + return org; + } catch (error) { + throw new DatabaseError({ error, name: "FindRootOrgDetails" }); + } + }; + return withTransaction(db, { ...orgOrm, findOrgByProjectId, findAllOrgMembers, countAllOrgMembers, + listSubOrganizations, findOrgById, findOrgBySlug, findAllOrgsByUserId, findOrganizationsByFilter, - ghostUserExists, findOrgMembersByUsername, findOrgMembersByRole, - findOrgGhostUser, create, updateById, deleteById, @@ -685,6 +748,7 @@ export const orgDALFactory = (db: TDbClient) => { deleteMembershipById, deleteMembershipsById, updateMembership, - findIdentityOrganization + findIdentityOrganization, + findRootOrgDetails }); }; diff --git a/backend/src/services/org/org-fns.ts b/backend/src/services/org/org-fns.ts index 30e67cca4..b887eeea1 100644 --- a/backend/src/services/org/org-fns.ts +++ b/backend/src/services/org/org-fns.ts @@ -1,129 +1,56 @@ +import { AccessScope } from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { BadRequestError } from "@app/lib/errors"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; -import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; -type TDeleteOrgMembership = { - orgMembershipId: string; - orgId: string; - orgDAL: Pick; - projectMembershipDAL: Pick; - projectKeyDAL: Pick; - userAliasDAL: Pick; - licenseService: Pick; - projectUserAdditionalPrivilegeDAL: Pick; - userId?: string; -}; +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; type TDeleteOrgMemberships = { orgMembershipIds: string[]; orgId: string; - orgDAL: Pick; - projectMembershipDAL: Pick; + orgDAL: Pick; + userGroupMembershipDAL: Pick; + membershipUserDAL: Pick; + membershipRoleDAL: Pick; projectKeyDAL: Pick; userAliasDAL: Pick; licenseService: Pick; - projectUserAdditionalPrivilegeDAL: Pick; userId?: string; -}; - -export const deleteOrgMembershipFn = async ({ - orgMembershipId, - orgId, - orgDAL, - projectMembershipDAL, - projectUserAdditionalPrivilegeDAL, - projectKeyDAL, - userAliasDAL, - licenseService, - userId -}: TDeleteOrgMembership) => { - const deletedMembership = await orgDAL.transaction(async (tx) => { - const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx); - - if (userId && orgMembership.userId === userId) { - // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken - throw new BadRequestError({ message: "You cannot remove yourself from an organization" }); - } - - if (!orgMembership.userId) { - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; - } - - await userAliasDAL.delete( - { - userId: orgMembership.userId, - orgId - }, - tx - ); - - await projectUserAdditionalPrivilegeDAL.delete( - { - userId: orgMembership.userId - }, - tx - ); - - // Get all the project memberships of the user in the organization - const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId); - - // Delete all the project memberships of the user in the organization - await projectMembershipDAL.delete( - { - $in: { - id: projectMemberships.map((membership) => membership.id) - } - }, - tx - ); - - // Get all the project keys of the user in the organization - const projectKeys = await projectKeyDAL.find({ - $in: { - projectId: projectMemberships.map((membership) => membership.projectId) - }, - receiverId: orgMembership.userId - }); - - // Delete all the project keys of the user in the organization - await projectKeyDAL.delete( - { - $in: { - id: projectKeys.map((key) => key.id) - } - }, - tx - ); - - await licenseService.updateSubscriptionOrgMemberCount(orgId); - return orgMembership; - }); - - return deletedMembership; + additionalPrivilegeDAL: Pick; }; export const deleteOrgMembershipsFn = async ({ orgMembershipIds, orgId, orgDAL, - projectMembershipDAL, - projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, licenseService, - userId + userId, + membershipUserDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL }: TDeleteOrgMemberships) => { const deletedMemberships = await orgDAL.transaction(async (tx) => { - const orgMemberships = await orgDAL.deleteMembershipsById(orgMembershipIds, orgId, tx); + const orgMemberships = await membershipUserDAL.delete( + { + scopeOrgId: orgId, + scope: AccessScope.Organization, + $in: { + id: orgMembershipIds + } + }, + tx + ); const membershipUserIds = orgMemberships - .filter((member) => Boolean(member.userId)) - .map((member) => member.userId) as string[]; + .filter((member) => Boolean(member.actorUserId)) + .map((member) => member.actorUserId) as string[]; if (userId && membershipUserIds.includes(userId)) { // scott: this is temporary, we will add a leave org endpoint with proper handling to ensure org isn't abandoned/broken @@ -145,41 +72,58 @@ export const deleteOrgMembershipsFn = async ({ tx ); - await projectUserAdditionalPrivilegeDAL.delete( - { - $in: { - userId: membershipUserIds - } - }, - tx - ); - // Get all the project memberships of the users in the organization - const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, membershipUserIds); + const childOrgs = await orgDAL.find({ rootOrgId: orgId }, { tx }); // Delete all the project memberships of the users in the organization - await projectMembershipDAL.delete( + const otherMemberships = await membershipUserDAL.delete( { $in: { - id: projectMemberships.map((membership) => membership.id) + scopeOrgId: [orgId].concat(childOrgs.map((el) => el.id)), + actorUserId: membershipUserIds } }, tx ); - // Get all the project keys of the user in the organization - const projectKeys = await projectKeyDAL.find({ + const orgGroups = await membershipUserDAL.find({ $in: { - projectId: projectMemberships.map((membership) => membership.projectId), - receiverId: membershipUserIds - } + scopeOrgId: [orgId].concat(childOrgs.map((el) => el.id)) + }, + $notNull: ["actorGroupId"] }); + const groupIds = orgGroups.filter((el) => el.actorGroupId).map((el) => el.actorGroupId as string); + + await userGroupMembershipDAL.delete( + { + $in: { + userId: membershipUserIds, + groupId: groupIds + } + }, + tx + ); + const projectIds = otherMemberships + .filter((el) => el.scope === AccessScope.Project && el.scopeProjectId) + .map((el) => el.scopeProjectId as string); + + await additionalPrivilegeDAL.delete( + { + $in: { + projectId: projectIds, + actorUserId: membershipUserIds + } + }, + tx + ); + // Delete all the project keys of the user in the organization await projectKeyDAL.delete( { $in: { - id: projectKeys.map((key) => key.id) + projectId: projectIds, + receiverId: membershipUserIds } }, tx diff --git a/backend/src/services/org/org-role-dal.ts b/backend/src/services/org/org-role-dal.ts deleted file mode 100644 index 2bc57001a..000000000 --- a/backend/src/services/org/org-role-dal.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TOrgRoleDALFactory = ReturnType; - -export const orgRoleDALFactory = (db: TDbClient) => ormify(db, TableName.OrgRoles); diff --git a/backend/src/services/org/org-role-fns.ts b/backend/src/services/org/org-role-fns.ts index 5bff1e324..96e01b405 100644 --- a/backend/src/services/org/org-role-fns.ts +++ b/backend/src/services/org/org-role-fns.ts @@ -1,7 +1,8 @@ import { OrgMembershipRole } from "@app/db/schemas"; import { TFeatureSet } from "@app/ee/services/license/license-types"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; + +import { TRoleDALFactory } from "../role/role-dal"; const RESERVED_ORG_ROLE_SLUGS = Object.values(OrgMembershipRole).filter((role) => role !== "custom"); @@ -10,13 +11,13 @@ export const isCustomOrgRole = (roleSlug: string) => !RESERVED_ORG_ROLE_SLUGS.fi // this is only for updating an org export const getDefaultOrgMembershipRoleForUpdateOrg = async ({ membershipRoleSlug, - orgRoleDAL, + roleDAL, plan, orgId }: { orgId: string; membershipRoleSlug: string; - orgRoleDAL: TOrgRoleDALFactory; + roleDAL: TRoleDALFactory; plan: TFeatureSet; }) => { if (isCustomOrgRole(membershipRoleSlug)) { @@ -26,7 +27,7 @@ export const getDefaultOrgMembershipRoleForUpdateOrg = async ({ "Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role." }); - const customRole = await orgRoleDAL.findOne({ slug: membershipRoleSlug, orgId }); + const customRole = await roleDAL.findOne({ slug: membershipRoleSlug, orgId }); if (!customRole) { throw new NotFoundError({ name: "UpdateOrg", diff --git a/backend/src/services/org/org-role-service.ts b/backend/src/services/org/org-role-service.ts deleted file mode 100644 index 6ce2b22cd..000000000 --- a/backend/src/services/org/org-role-service.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { ForbiddenError } from "@casl/ability"; -import { packRules } from "@casl/ability/extra"; - -import { TOrgRolesInsert, TOrgRolesUpdate } from "@app/db/schemas"; -import { - orgAdminPermissions, - orgMemberPermissions, - orgNoAccessPermissions, - OrgPermissionActions, - OrgPermissionSubjects -} from "@app/ee/services/permission/org-permission"; -import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { TExternalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; -import { TOrgDALFactory } from "@app/services/org/org-dal"; - -import { ActorAuthMethod } from "../auth/auth-type"; -import { TOrgRoleDALFactory } from "./org-role-dal"; - -type TOrgRoleServiceFactoryDep = { - orgRoleDAL: TOrgRoleDALFactory; - permissionService: TPermissionServiceFactory; - orgDAL: TOrgDALFactory; - externalGroupOrgRoleMappingDAL: TExternalGroupOrgRoleMappingDALFactory; -}; - -export type TOrgRoleServiceFactory = ReturnType; - -export const orgRoleServiceFactory = ({ - orgRoleDAL, - orgDAL, - permissionService, - externalGroupOrgRoleMappingDAL -}: TOrgRoleServiceFactoryDep) => { - const createRole = async ( - userId: string, - orgId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Role); - const existingRole = await orgRoleDAL.findOne({ slug: data.slug, orgId }); - if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); - const role = await orgRoleDAL.create({ - ...data, - orgId, - permissions: JSON.stringify(data.permissions) - }); - return role; - }; - - const getRole = async ( - userId: string, - orgId: string, - roleId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); - - switch (roleId) { - case "b11b49a9-09a9-4443-916a-4246f9ff2c69": { - return { - id: roleId, - orgId, - name: "Admin", - slug: "admin", - description: "Complete administration access over the organization", - permissions: packRules(orgAdminPermissions), - createdAt: new Date(), - updatedAt: new Date() - }; - } - case "b11b49a9-09a9-4443-916a-4246f9ff2c70": { - return { - id: roleId, - orgId, - name: "Member", - slug: "member", - description: "Non-administrative role in an organization", - permissions: packRules(orgMemberPermissions), - createdAt: new Date(), - updatedAt: new Date() - }; - } - case "b10d49a9-09a9-4443-916a-4246f9ff2c72": { - return { - id: "b10d49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - orgId, - name: "No Access", - slug: "no-access", - description: "No access to any resources in the organization", - permissions: packRules(orgNoAccessPermissions), - createdAt: new Date(), - updatedAt: new Date() - }; - } - default: { - const role = await orgRoleDAL.findOne({ id: roleId, orgId }); - if (!role) throw new NotFoundError({ message: `Organization role with ID '${roleId}' not found` }); - return role; - } - } - }; - - const updateRole = async ( - userId: string, - orgId: string, - roleId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); - if (data?.slug) { - const existingRole = await orgRoleDAL.findOne({ slug: data.slug, orgId }); - if (existingRole && existingRole.id !== roleId) - throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); - } - const [updatedRole] = await orgRoleDAL.update( - { id: roleId, orgId }, - { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } - ); - if (!updatedRole) throw new NotFoundError({ message: `Organization role with ID '${roleId}' not found` }); - return updatedRole; - }; - - const deleteRole = async ( - userId: string, - orgId: string, - roleId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); - - const org = await orgDAL.findOrgById(orgId); - - if (!org) - throw new NotFoundError({ - message: `Organization with ID '${orgId}' not found` - }); - - if (org.defaultMembershipRole === roleId) - throw new BadRequestError({ - message: "Cannot delete default org membership role. Please re-assign and try again." - }); - - const externalGroupMapping = await externalGroupOrgRoleMappingDAL.findOne({ - orgId, - roleId - }); - - if (externalGroupMapping) - throw new BadRequestError({ - message: - "Cannot delete role assigned to external group organization role mapping. Please re-assign external mapping and try again." - }); - - const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); - if (!deletedRole) - throw new NotFoundError({ message: `Organization role with ID '${roleId}' not found`, name: "UpdateRole" }); - - return deletedRole; - }; - - const listRoles = async ( - userId: string, - orgId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); - const customRoles = await orgRoleDAL.find({ orgId }); - const roles = [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - orgId, - name: "Admin", - slug: "admin", - description: "Complete administration access over the organization", - permissions: packRules(orgAdminPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - orgId, - name: "Member", - slug: "member", - description: "Non-administrative role in an organization", - permissions: packRules(orgMemberPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b10d49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - orgId, - name: "No Access", - slug: "no-access", - description: "No access to any resources in the organization", - permissions: packRules(orgNoAccessPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - ...(customRoles || []).map(({ permissions, ...data }) => ({ - ...data, - permissions - })) - ]; - - return roles; - }; - - const getUserPermission = async ( - userId: string, - orgId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission, membership } = await permissionService.getUserOrgPermission( - userId, - orgId, - actorAuthMethod, - actorOrgId - ); - return { permissions: packRules(permission.rules), membership }; - }; - - return { createRole, getRole, updateRole, deleteRole, listRoles, getUserPermission }; -}; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index ffcf4459e..51d907e05 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -3,19 +3,16 @@ import slugify from "@sindresorhus/slugify"; import { Knex } from "knex"; import { - ActionProjectType, + AccessScope, + OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, - ProjectMembershipRole, - ProjectVersion, TableName, TOidcConfigs, - TProjectMemberships, - TProjectUserMembershipRolesInsert, - TSamlConfigs, - TUsers + TSamlConfigs } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLdapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; @@ -25,47 +22,35 @@ import { OrgPermissionSecretShareAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; -import { - constructPermissionErrorMessage, - validatePrivilegeChangeOperation -} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { applyJitter } from "@app/lib/dates"; import { delay as delayMs } from "@app/lib/delay"; -import { - BadRequestError, - ForbiddenRequestError, - NotFoundError, - PermissionBoundaryError, - UnauthorizedError -} from "@app/lib/errors"; -import { groupBy } from "@app/lib/fn"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { isDisposableEmail } from "@app/lib/validator"; import { QueueName } from "@app/queue"; import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { ActorAuthMethod, ActorType, AuthMethod, AuthModeJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; -import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { TReminderServiceFactory } from "../reminder/reminder-types"; +import { TRoleDALFactory } from "../role/role-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -73,10 +58,8 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge- import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; -import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; -import { deleteOrgMembershipFn, deleteOrgMembershipsFn } from "./org-fns"; -import { TOrgRoleDALFactory } from "./org-role-dal"; +import { deleteOrgMembershipsFn } from "./org-fns"; import { TDeleteOrgMembershipDTO, TDeleteOrgMembershipsDTO, @@ -84,7 +67,6 @@ import { TFindOrgMembersByEmailDTO, TGetOrgGroupsDTO, TGetOrgMembershipDTO, - TInviteUserToOrgDTO, TListProjectMembershipsByOrgMembershipIdDTO, TResendOrgMemberInvitationDTO, TUpdateOrgDTO, @@ -99,32 +81,22 @@ type TOrgServiceFactoryDep = { secretV2BridgeDAL: Pick; folderDAL: Pick; orgDAL: TOrgDALFactory; - orgBotDAL: TOrgBotDALFactory; - orgRoleDAL: TOrgRoleDALFactory; + roleDAL: TRoleDALFactory; userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; identityMetadataDAL: Pick; + membershipUserDAL: TMembershipUserDALFactory; projectMembershipDAL: Pick< TProjectMembershipDALFactory, - | "findProjectMembershipsByUserId" - | "delete" - | "create" - | "find" - | "insertMany" - | "transaction" - | "findProjectMembershipsByUserIds" + "findProjectMembershipsByUserId" | "findProjectMembershipsByUserIds" >; projectKeyDAL: Pick; orgMembershipDAL: Pick< TOrgMembershipDALFactory, - | "findOrgMembershipById" - | "findOne" - | "findById" - | "findRecentInvitedMemberships" - | "updateById" - | "updateLastInvitedAtByIds" + "findOrgMembershipById" | "findRecentInvitedMemberships" | "updateLastInvitedAtByIds" >; + membershipRoleDAL: TMembershipRoleDALFactory; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; oidcConfigDAL: Pick; @@ -136,12 +108,11 @@ type TOrgServiceFactoryDep = { TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer" >; - projectUserAdditionalPrivilegeDAL: Pick; - projectRoleDAL: Pick; - projectUserMembershipRoleDAL: Pick; projectBotService: Pick; loginService: Pick; reminderService: Pick; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; + additionalPrivilegeDAL: TAdditionalPrivilegeDALFactory; }; export type TOrgServiceFactory = ReturnType; @@ -154,7 +125,7 @@ export const orgServiceFactory = ({ folderDAL, userDAL, groupDAL, - orgRoleDAL, + roleDAL, incidentContactDAL, permissionService, smtpService, @@ -162,19 +133,19 @@ export const orgServiceFactory = ({ projectMembershipDAL, projectKeyDAL, orgMembershipDAL, - projectUserAdditionalPrivilegeDAL, tokenService, - orgBotDAL, licenseService, - projectRoleDAL, samlConfigDAL, oidcConfigDAL, ldapConfigDAL, - projectUserMembershipRoleDAL, identityMetadataDAL, projectBotService, loginService, - reminderService + reminderService, + membershipRoleDAL, + membershipUserDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL }: TOrgServiceFactoryDep) => { /* * Get organization details by the organization id @@ -183,16 +154,31 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + rootOrgId: string, + actorOrgId: string ) => { - await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId: rootOrgId, + scope: OrganizationActionScope.Any + }); const appCfg = getConfig(); const org = await orgDAL.findOrgById(orgId); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); - if (!org.userTokenExpiration) { - return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; + + const hasSubOrg = actorOrgId !== rootOrgId; + let subOrg; + if (hasSubOrg) { + subOrg = await orgDAL.findOne({ rootOrgId, id: actorOrgId }); } - return org; + + if (!org.userTokenExpiration) { + return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME, subOrganization: subOrg }; + } + return { ...org, subOrganization: subOrg }; }; /* * Get all organization a user part of @@ -219,9 +205,16 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const members = await orgDAL.findAllOrgMembers(orgId); @@ -229,7 +222,14 @@ export const orgServiceFactory = ({ }; const getOrgGroups = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TGetOrgGroupsDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const groups = await groupDAL.findByOrgId(orgId); return groups; @@ -243,7 +243,14 @@ export const orgServiceFactory = ({ orgId, emails }: TFindOrgMembersByEmailDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const members = await orgDAL.findOrgMembersByUsername(orgId, emails); @@ -267,7 +274,7 @@ export const orgServiceFactory = ({ } if (actor === ActorType.IDENTITY) { - const workspaces = await projectDAL.findAllProjectsByIdentity(actorId); + const workspaces = await projectDAL.findIdentityProjects(actorId, orgId); return workspaces; } @@ -302,14 +309,21 @@ export const orgServiceFactory = ({ ); const createMembershipData = { - orgId, - userId: user.id, - role: OrgMembershipRole.Admin, + scopeOrgId: orgId, + scope: AccessScope.Organization, + actorUserId: user.id, status: OrgMembershipStatus.Accepted, isActive: true }; - await orgDAL.createMembership(createMembershipData, tx); + const membership = await orgDAL.createMembership(createMembershipData, tx); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); return { user, @@ -323,9 +337,16 @@ export const orgServiceFactory = ({ actorAuthMethod, orgId }: TUpgradePrivilegeSystemDTO) => { - const { membership } = await permissionService.getUserOrgPermission(actorId, orgId, actorAuthMethod, actorOrgId); + const { hasRole } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); - if (membership.role !== OrgMembershipRole.Admin) { + if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Insufficient privileges - only the organization admin can upgrade the privilege system." }); @@ -388,7 +409,14 @@ export const orgServiceFactory = ({ } }: TUpdateOrgDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); if (allowSecretSharingOutsideOrganization !== undefined) { @@ -485,6 +513,12 @@ export const orgServiceFactory = ({ } } + if (slug) { + const existingOrg = await orgDAL.findOne({ slug, rootOrgId: null }); + if (existingOrg && existingOrg?.id !== orgId) + throw new BadRequestError({ message: `Organization with slug ${slug} already exist` }); + } + if (googleSsoAuthEnforced) { if (googleSsoAuthEnforced && currentOrg.authEnforced) { throw new BadRequestError({ @@ -531,7 +565,7 @@ export const orgServiceFactory = ({ defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ membershipRoleSlug: defaultMembershipRoleSlug, orgId, - orgRoleDAL, + roleDAL, plan }); } @@ -575,23 +609,6 @@ export const orgServiceFactory = ({ }, trx?: Knex ) => { - const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); - const key = crypto.randomBytes(32).toString("base64"); - const { - ciphertext: encryptedPrivateKey, - iv: privateKeyIV, - tag: privateKeyTag, - encoding: privateKeyKeyEncoding, - algorithm: privateKeyAlgorithm - } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); - const { - ciphertext: encryptedSymmetricKey, - iv: symmetricKeyIV, - tag: symmetricKeyTag, - encoding: symmetricKeyKeyEncoding, - algorithm: symmetricKeyAlgorithm - } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); - const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); const createOrg = async (tx: Knex) => { @@ -601,41 +618,31 @@ export const orgServiceFactory = ({ tx ); if (userId) { - await orgDAL.createMembership( + const membership = await orgDAL.createMembership( { - userId, - orgId: org.id, - role: OrgMembershipRole.Admin, + scope: AccessScope.Organization, + actorUserId: userId, + scopeOrgId: org.id, status: OrgMembershipStatus.Accepted, isActive: true }, tx ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); } - await orgBotDAL.create( - { - name: org.name, - publicKey, - privateKeyIV, - encryptedPrivateKey, - symmetricKeyIV, - symmetricKeyTag, - encryptedSymmetricKey, - symmetricKeyAlgorithm, - orgId: org.id, - privateKeyTag, - privateKeyAlgorithm, - privateKeyKeyEncoding, - symmetricKeyKeyEncoding - }, - tx - ); + return org; }; const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg)); - await licenseService.updateSubscriptionOrgMemberCount(organization.id); + await licenseService.updateSubscriptionOrgMemberCount(organization.id, trx); return organization; }; @@ -657,10 +664,17 @@ export const orgServiceFactory = ({ ipAddress: string; orgId: string; actorAuthMethod: ActorAuthMethod; - actorOrgId: string | undefined; + actorOrgId: string; }) => { - const { membership } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); - if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin) { + const { hasRole } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + if (!hasRole(OrgMembershipRole.Admin)) { throw new ForbiddenRequestError({ name: "DeleteOrganizationById", message: "Insufficient privileges" @@ -739,22 +753,33 @@ export const orgServiceFactory = ({ actorOrgId, metadata }: TUpdateOrgMembershipDTO) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); - const foundMembership = await orgMembershipDAL.findById(membershipId); + const foundMembership = await membershipUserDAL.findOne({ + id: membershipId, + scope: AccessScope.Organization, + scopeOrgId: actorOrgId + }); if (!foundMembership) throw new NotFoundError({ message: `Organization membership with ID ${membershipId} not found` }); - if (foundMembership.orgId !== orgId) + if (foundMembership.scopeOrgId !== orgId) throw new UnauthorizedError({ message: "Updated org member doesn't belong to the organization" }); - if (foundMembership.userId === userId) + if (foundMembership.actorUserId === userId) throw new UnauthorizedError({ message: "Cannot update own organization membership" }); const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); let userRole = role; let userRoleId: string | null = null; if (role && isCustomRole) { - const customRole = await orgRoleDAL.findOne({ slug: role, orgId }); + const customRole = await roleDAL.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "UpdateMembership", message: "Organization role not found" }); const plan = await licenseService.getPlan(orgId); @@ -767,17 +792,33 @@ export const orgServiceFactory = ({ userRoleId = customRole.id; } const membership = await orgDAL.transaction(async (tx) => { - const [updatedOrgMembership] = await orgDAL.updateMembership( - { id: membershipId, orgId }, - { role: userRole, roleId: userRoleId, isActive } - ); + // this is because if isActive is undefined then this would fail due to knexjs error + const [updatedOrgMembership] = + typeof isActive === "undefined" + ? [foundMembership] + : await orgDAL.updateMembership( + { id: membershipId, scopeOrgId: orgId, scope: AccessScope.Organization }, + { isActive }, + tx + ); + if (userRole) { + await membershipRoleDAL.delete({ membershipId }, tx); + await membershipRoleDAL.create( + { + membershipId, + role: userRole, + customRoleId: userRoleId + }, + tx + ); + } if (metadata) { - await identityMetadataDAL.delete({ userId: updatedOrgMembership.userId, orgId }, tx); + await identityMetadataDAL.delete({ userId: updatedOrgMembership.actorUserId, orgId }, tx); if (metadata.length) { await identityMetadataDAL.insertMany( metadata.map(({ key, value }) => ({ - userId: updatedOrgMembership.userId, + userId: updatedOrgMembership.actorUserId as string, orgId, key, value @@ -800,7 +841,14 @@ export const orgServiceFactory = ({ membershipId }: TResendOrgMemberInvitationDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); @@ -809,8 +857,9 @@ export const orgServiceFactory = ({ const org = await orgDAL.findOrgById(orgId); const [inviteeOrgMembership] = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, - [`${TableName.OrgMembership}.id` as "id"]: membershipId + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization, + [`${TableName.Membership}.id` as "id"]: membershipId }); if (inviteeOrgMembership.status !== OrgMembershipStatus.Invited) { @@ -821,7 +870,7 @@ export const orgServiceFactory = ({ const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: inviteeOrgMembership.userId, + userId: inviteeOrgMembership.actorUserId as string, orgId }); @@ -849,366 +898,13 @@ export const orgServiceFactory = ({ } }); - await orgMembershipDAL.updateById(inviteeOrgMembership.id, { + await membershipUserDAL.updateById(inviteeOrgMembership.id, { lastInvitedAt: new Date() }); return { signupToken: undefined }; }; - /* - * Invite user to organization - */ - const inviteUserToOrganization = async ({ - orgId, - actorId, - actor, - inviteeEmails, - organizationRoleSlug, - projects: invitedProjects, - actorAuthMethod, - actorOrgId - }: TInviteUserToOrgDTO) => { - const appCfg = getConfig(); - - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - - const invitingUser = await userDAL.findOne({ id: actorId }); - - const org = await orgDAL.findOrgById(orgId); - - const isEmailInvalid = await isDisposableEmail(inviteeEmails); - if (isEmailInvalid) { - throw new BadRequestError({ - message: "Disposable emails are not allowed", - name: "InviteUser" - }); - } - const plan = await licenseService.getPlan(orgId); - const isCustomOrgRole = !Object.values(OrgMembershipRole).includes(organizationRoleSlug as OrgMembershipRole); - if (isCustomOrgRole) { - if (!plan?.rbac) - throw new BadRequestError({ - message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." - }); - } - - const projectsToInvite = invitedProjects?.length - ? await projectDAL.find({ - orgId, - $in: { - id: invitedProjects?.map(({ id }) => id) - } - }) - : []; - - if (projectsToInvite.length !== invitedProjects?.length) { - throw new ForbiddenRequestError({ - message: "Access denied to one or more of the specified projects" - }); - } - - if (projectsToInvite.some((el) => el.version !== ProjectVersion.V3)) { - throw new BadRequestError({ - message: "One or more selected projects are not compatible with this operation. Please upgrade your projects." - }); - } - - const mailsForOrgInvitation: { email: string; userId: string; firstName: string; lastName: string }[] = []; - const mailsForProjectInvitation: { email: string[]; projectName: string }[] = []; - const newProjectMemberships: TProjectMemberships[] = []; - - await orgDAL.transaction(async (tx) => { - const users: Pick[] = []; - - for await (const inviteeEmail of inviteeEmails) { - const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); - let inviteeUser = - usersByUsername?.length > 1 - ? usersByUsername.find((el) => el.username === inviteeEmail) - : usersByUsername?.[0]; - - // if the user doesn't exist we create the user with the email - if (!inviteeUser) { - // TODO(carlos): will be removed once the function receives usernames instead of emails - const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); - if (usersByEmail?.length === 1) { - [inviteeUser] = usersByEmail; - } else { - inviteeUser = await userDAL.create( - { - isAccepted: false, - email: inviteeEmail, - username: inviteeEmail, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); - } - } - - const inviteeUserId = inviteeUser?.id; - const existingEncrytionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); - - // when user is missing the encrytion keys - // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp - // So what we do is we generate a random secure password and then encrypt it with a random pub-private key - // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password - if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncrytionKey)) { - await userDAL.createUserEncryption( - { - userId: inviteeUserId, - encryptionVersion: 2 - }, - tx - ); - } - - const [inviteeOrgMembership] = await orgDAL.findMembership( - { - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId, - [`${TableName.OrgMembership}.userId` as "userId"]: inviteeUserId - }, - { tx } - ); - - // if there exist no org membership we set is as given by the request - if (!inviteeOrgMembership) { - if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { - // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed - throw new BadRequestError({ - name: "InviteUser", - message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members." - }); - } - - if (org?.authEnforced) { - throw new ForbiddenRequestError({ - name: "InviteUser", - message: "Failed to invite user due to org-level auth enforced for organization" - }); - } - - // as its used by project invite also - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); - let roleId; - const orgRole = isCustomOrgRole ? OrgMembershipRole.Custom : organizationRoleSlug; - if (isCustomOrgRole) { - const customRole = await orgRoleDAL.findOne({ slug: organizationRoleSlug, orgId }); - if (!customRole) { - throw new NotFoundError({ - name: "InviteUser", - message: `Custom organization role with slug '${orgRole}' not found` - }); - } - roleId = customRole.id; - } - - await orgDAL.createMembership( - { - userId: inviteeUser.id, - inviteEmail: inviteeEmail, - orgId, - role: orgRole, - status: OrgMembershipStatus.Invited, - isActive: true, - roleId - }, - tx - ); - mailsForOrgInvitation.push({ - email: inviteeEmail, - userId: inviteeUser.id, - firstName: inviteeUser?.firstName || "", - lastName: inviteeUser.lastName || "" - }); - } - - users.push(inviteeUser); - } - - const userIds = users.map(({ id }) => id); - const userEncryptionKeys = await userDAL.findUserEncKeyByUserIdsBatch({ userIds }, tx); - // we don't need to spam with email. Thus org invitation doesn't need project invitation again - const userIdsWithOrgInvitation = new Set(mailsForOrgInvitation.map((el) => el.userId)); - - // if there exist no project membership we set is as given by the request - for await (const project of projectsToInvite) { - const projectId = project.id; - const { permission: projectPermission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(projectPermission).throwUnlessCan( - ProjectPermissionMemberActions.Create, - ProjectPermissionSub.Member - ); - const existingMembers = await projectMembershipDAL.find( - { - projectId: project.id, - $in: { userId: userIds } - }, - { tx } - ); - const existingMembersGroupByUserId = groupBy(existingMembers, (i) => i.userId); - const userWithEncryptionKeyInvitedToProject = userEncryptionKeys.filter( - (user) => !existingMembersGroupByUserId?.[user.userId] - ); - - // eslint-disable-next-line no-continue - if (!userWithEncryptionKeyInvitedToProject.length) continue; - - // validate custom project role - const invitedProjectRoles = invitedProjects.find((el) => el.id === project.id)?.projectRoleSlug || [ - ProjectMembershipRole.Member - ]; - - for await (const invitedRole of invitedProjectRoles) { - const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( - invitedRole, - projectId - ); - - if (invitedRole !== ProjectMembershipRole.NoAccess) { - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member, - projectPermission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to invite user to the project", - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - } - } - - const customProjectRoles = invitedProjectRoles.filter( - (role) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) - ); - const hasCustomRole = Boolean(customProjectRoles.length); - if (hasCustomRole) { - if (!plan?.rbac) - throw new BadRequestError({ - name: "InviteUser", - message: - "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." - }); - } - - const customRoles = hasCustomRole - ? await projectRoleDAL.find({ - projectId, - $in: { slug: customProjectRoles.map((role) => role) } - }) - : []; - if (customRoles.length !== customProjectRoles.length) { - throw new NotFoundError({ name: "InviteUser", message: "Custom project role not found" }); - } - - const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - - const projectMemberships = await projectMembershipDAL.insertMany( - userWithEncryptionKeyInvitedToProject.map((userEnc) => ({ - projectId, - userId: userEnc.userId - })), - tx - ); - newProjectMemberships.push(...projectMemberships); - - const sanitizedProjectMembershipRoles: TProjectUserMembershipRolesInsert[] = []; - invitedProjectRoles.forEach((projectRole) => { - const isCustomRole = Boolean(customRolesGroupBySlug?.[projectRole]?.[0]); - projectMemberships.forEach((membershipEntry) => { - sanitizedProjectMembershipRoles.push({ - projectMembershipId: membershipEntry.id, - role: isCustomRole ? ProjectMembershipRole.Custom : projectRole, - customRoleId: customRolesGroupBySlug[projectRole] ? customRolesGroupBySlug[projectRole][0].id : null - }); - }); - }); - await projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - - mailsForProjectInvitation.push({ - email: userWithEncryptionKeyInvitedToProject - .filter((el) => !userIdsWithOrgInvitation.has(el.userId)) - .map((el) => el.email || el.username), - projectName: project.name - }); - } - return users; - }); - - await licenseService.updateSubscriptionOrgMemberCount(orgId); - const signupTokens: { email: string; link: string }[] = []; - // send org invite mail - await Promise.allSettled( - mailsForOrgInvitation.map(async (el) => { - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: el.userId, - orgId - }); - - signupTokens.push({ - email: el.email, - link: `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${el.email}&organization_id=${org?.id}` - }); - - return smtpService.sendMail({ - template: SmtpTemplates.OrgInvite, - subjectLine: "Infisical organization invitation", - recipients: [el.email], - substitutions: { - inviterFirstName: invitingUser?.firstName, - inviterUsername: invitingUser?.email, - organizationName: org?.name, - email: el.email, - organizationId: org?.id.toString(), - token, - callback_url: `${appCfg.SITE_URL}/signupinvite` - } - }); - }) - ); - - await Promise.allSettled( - mailsForProjectInvitation - .filter((el) => Boolean(el.email.length)) - .map(async (el) => { - return smtpService.sendMail({ - template: SmtpTemplates.WorkspaceInvite, - subjectLine: "Infisical project invitation", - recipients: el.email, - substitutions: { - workspaceName: el.projectName, - callback_url: `${appCfg.SITE_URL}/login` - } - }); - }) - ); - - if (!appCfg.isSmtpConfigured) { - return { signupTokens, projectMemberships: newProjectMemberships }; - } - - return { signupTokens: undefined, projectMemberships: newProjectMemberships }; - }; - /** * Organization invitation step 2: Verify that code [code] was sent to email [email] as part of * magic link and issue a temporary signup token for user to complete setting up their account @@ -1222,9 +918,10 @@ export const orgServiceFactory = ({ } const [orgMembership] = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.Membership}.actorUserId` as "actorUserId"]: user.id, + scope: AccessScope.Organization, status: OrgMembershipStatus.Invited, - [`${TableName.OrgMembership}.orgId` as "orgId"]: orgId + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: orgId }); if (!orgMembership) @@ -1237,7 +934,7 @@ export const orgServiceFactory = ({ await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_ORG_INVITATION, userId: user.id, - orgId: orgMembership.orgId, + orgId: orgMembership.scopeOrgId, code }); @@ -1249,16 +946,17 @@ export const orgServiceFactory = ({ // this means user has already completed signup process // isAccepted is set true when keys are exchanged await orgDAL.updateMembershipById(orgMembership.id, { - orgId, + scopeOrgId: orgId, status: OrgMembershipStatus.Accepted }); await licenseService.updateSubscriptionOrgMemberCount(orgId); return { user }; } + const membershipRole = await membershipRoleDAL.findOne({ membershipId: orgMembership.id }); if ( organization.authEnforced && - !(organization.bypassOrgAuthEnabled && orgMembership.role === OrgMembershipRole.Admin) + !(organization.bypassOrgAuthEnabled && membershipRole.role === OrgMembershipRole.Admin) ) { return { user }; } @@ -1286,7 +984,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TGetOrgMembershipDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const membership = await orgMembershipDAL.findOrgMembershipById(membershipId); @@ -1307,19 +1012,28 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteOrgMembershipDTO) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - const deletedMembership = await deleteOrgMembershipFn({ - orgMembershipId: membershipId, + const [deletedMembership] = await deleteOrgMembershipsFn({ + orgMembershipIds: [membershipId], orgId, orgDAL, - projectMembershipDAL, - projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, licenseService, - userId + userId, + membershipUserDAL, + membershipRoleDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL }); return deletedMembership; @@ -1332,7 +1046,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TDeleteOrgMembershipsDTO) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); if (membershipIds.includes(userId)) { @@ -1343,12 +1064,14 @@ export const orgServiceFactory = ({ orgMembershipIds: membershipIds, orgId, orgDAL, - projectMembershipDAL, - projectUserAdditionalPrivilegeDAL, projectKeyDAL, userAliasDAL, licenseService, - userId + userId, + membershipUserDAL, + membershipRoleDAL, + userGroupMembershipDAL, + additionalPrivilegeDAL }); return deletedMemberships; @@ -1362,7 +1085,14 @@ export const orgServiceFactory = ({ actorAuthMethod, actorOrgId }: TListProjectMembershipsByOrgMembershipIdDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); const membership = await orgMembershipDAL.findOrgMembershipById(orgMembershipId); @@ -1383,9 +1113,16 @@ export const orgServiceFactory = ({ userId: string, orgId: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); const incidentContacts = await incidentContactDAL.findByOrgId(orgId); return incidentContacts; @@ -1396,9 +1133,16 @@ export const orgServiceFactory = ({ orgId: string, email: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email }); if (doesIncidentContactExist) { @@ -1417,9 +1161,16 @@ export const orgServiceFactory = ({ orgId: string, id: string, actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined + actorOrgId: string ) => { - const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor: ActorType.USER, + actorId: userId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.ParentOrganization + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); const incidentContact = await incidentContactDAL.deleteById(id, orgId); @@ -1440,17 +1191,17 @@ export const orgServiceFactory = ({ await Promise.all( invitedUsers.map(async (invitedUser) => { - let org = orgCache[invitedUser.orgId]; + let org = orgCache[invitedUser.scopeOrgId]; if (!org) { - org = await orgDAL.findById(invitedUser.orgId); - orgCache[invitedUser.orgId] = org; + org = await orgDAL.findById(invitedUser.scopeOrgId); + orgCache[invitedUser.scopeOrgId] = org; } - if (!org || !invitedUser.userId) return; + if (!org || !invitedUser.actorUserId) return; const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: invitedUser.userId, + userId: invitedUser.actorUserId, orgId: org.id }); @@ -1488,7 +1239,6 @@ export const orgServiceFactory = ({ findAllOrgMembers, findAllOrganizationOfUser, findIdentityOrganization, - inviteUserToOrganization, verifyUserToOrg, updateOrg, findOrgMembersByUsername, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 1a27d131f..48680456c 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -8,7 +8,7 @@ export type TUpdateOrgMembershipDTO = { membershipId: string; role?: string; isActive?: boolean; - actorOrgId: string | undefined; + actorOrgId: string; metadata?: { key: string; value: string }[]; actorAuthMethod: ActorAuthMethod; }; @@ -21,7 +21,7 @@ export type TDeleteOrgMembershipDTO = { userId: string; orgId: string; membershipId: string; - actorOrgId: string | undefined; + actorOrgId: string; actorAuthMethod: ActorAuthMethod; }; @@ -29,7 +29,7 @@ export type TDeleteOrgMembershipsDTO = { userId: string; orgId: string; membershipIds: string[]; - actorOrgId: string | undefined; + actorOrgId: string; actorAuthMethod: ActorAuthMethod; }; @@ -54,7 +54,7 @@ export type TVerifyUserToOrgDTO = { export type TFindOrgMembersByEmailDTO = { actor: ActorType; - actorOrgId: string | undefined; + actorOrgId: string; actorId: string; actorAuthMethod: ActorAuthMethod; orgId: string; @@ -64,7 +64,7 @@ export type TFindOrgMembersByEmailDTO = { export type TFindAllWorkspacesDTO = { actor: ActorType; actorId: string; - actorOrgId: string | undefined; + actorOrgId: string; actorAuthMethod: ActorAuthMethod; orgId: string; }; diff --git a/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts new file mode 100644 index 000000000..6ed78f665 --- /dev/null +++ b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts @@ -0,0 +1,61 @@ +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type TPamAccountRotationServiceFactoryDep = { + queueService: TQueueServiceFactory; + pamAccountService: Pick; +}; + +export type TPamAccountRotationServiceFactory = ReturnType; + +export const pamAccountRotationServiceFactory = ({ + queueService, + pamAccountService +}: TPamAccountRotationServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.PamAccountRotation, + QueueJobs.PamAccountRotation, + { pattern: "0 * * * *", utc: true }, + QueueName.PamAccountRotation // job id + ); + + await queueService.startPg( + QueueJobs.PamAccountRotation, + async () => { + try { + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task started`); + await pamAccountService.rotateAllDueAccounts(); + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task completed`); + } catch (error) { + logger.error(error, `${QueueName.PamAccountRotation}: pam account rotation failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 5 * 60 + } + ); + + await queueService.schedulePg( + QueueJobs.PamAccountRotation, + "0 * * * *", // Schedule to run every hour + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-constants.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-constants.ts new file mode 100644 index 000000000..265e00cac --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-constants.ts @@ -0,0 +1,52 @@ +import RE2 from "re2"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +/** + * AWS Certificate Manager naming constraints for certificates + */ +export const AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING = { + /** + * Regular expression pattern for valid AWS Certificate Manager certificate names + * Must contain only alphanumeric characters, spaces, hyphens, and underscores + * Must be 1-256 characters long + */ + NAME_PATTERN: new RE2("^[a-zA-Z0-9\\s\\-_]{1,256}$"), + + /** + * String of characters that are forbidden in AWS Certificate Manager certificate names + */ + FORBIDDEN_CHARACTERS: "!@#$%^&*()+={}[]|\\:;\"'<>,.?/~`", + + /** + * Maximum length for certificate names in AWS Certificate Manager + */ + MAX_LENGTH: 256, + + /** + * Minimum length for certificate names in AWS Certificate Manager + */ + MIN_LENGTH: 1, + + /** + * String representation of the allowed character pattern (for UI display) + */ + ALLOWED_CHARACTER_PATTERN: "^[a-zA-Z0-9\\s\\-_]{1,256}$" +} as const; + +/** + * AWS Certificate Manager PKI Sync list option configuration + */ +export const AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION = { + name: "AWS Certificate Manager" as const, + connection: AppConnection.AWS, + destination: PkiSync.AwsCertificateManager, + canImportCertificates: false, + canRemoveCertificates: true, + defaultCertificateNameSchema: "Infisical-{{certificateId}}", + forbiddenCharacters: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS, + allowedCharacterPattern: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN, + maxCertificateNameLength: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MAX_LENGTH, + minCertificateNameLength: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MIN_LENGTH +} as const; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts new file mode 100644 index 000000000..f78bd790e --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts @@ -0,0 +1,634 @@ +/* eslint-disable no-await-in-loop */ +import * as AWS from "aws-sdk"; +import RE2 from "re2"; +import { z } from "zod"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { AwsConnectionMethod } from "@app/services/app-connection/aws/aws-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { + AwsConnectionAccessTokenCredentialsSchema, + AwsConnectionAssumeRoleCredentialsSchema +} from "@app/services/app-connection/aws/aws-connection-schemas"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; + +import { PkiSyncError } from "../pki-sync-errors"; +import { TPkiSyncWithCredentials } from "../pki-sync-types"; +import { + ACMCertificateWithKey, + CertificateImportRequest, + RemoveCertificatesResult, + SyncCertificatesResult, + TAwsCertificateManagerPkiSyncConfig +} from "./aws-certificate-manager-pki-sync-types"; + +const INFISICAL_CERTIFICATE_TAG = "InfisicalCertificate"; +const AWS_CERTIFICATE_ARN_PATTERN = new RE2("^arn:aws:acm:[a-z0-9-]+:\\d{12}:certificate/[a-f0-9-]{36}$"); + +type TAwsAssumeRoleCredentials = z.infer; +type TAwsAccessKeyCredentials = z.infer; + +const AWS_RATE_LIMIT_CONFIG: RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: 10, + BASE_DELAY: 1000, + MAX_DELAY: 30000, + MAX_RETRIES: 3, + RATE_LIMIT_STATUS_CODES: [429, 503] +}; + +const awsConnectionQueue = createConnectionQueue(AWS_RATE_LIMIT_CONFIG); + +const { withRateLimitRetry, executeWithConcurrencyLimit } = awsConnectionQueue; + +const validateCertificateArn = (arn: string): boolean => { + return AWS_CERTIFICATE_ARN_PATTERN.test(arn); +}; + +const extractCertificateNameFromArn = (certificateArn: string): string => { + if (!validateCertificateArn(certificateArn)) { + throw new Error(`Invalid AWS Certificate Manager ARN format: ${certificateArn}`); + } + const parts = certificateArn.split("/"); + return parts[parts.length - 1]; +}; + +const sanitizeInput = (input: string): string => { + return input.trim().replace(new RE2("[^\\w\\s-]", "g"), ""); +}; + +const validateCertificateContent = (cert: string, privateKey: string): void => { + if (!cert || cert.trim().length === 0) { + throw new Error("Certificate content is empty or missing"); + } + + if (!privateKey || privateKey.trim().length === 0) { + throw new Error("Private key content is empty or missing"); + } + + if (!cert.includes("-----BEGIN CERTIFICATE-----") || !cert.includes("-----END CERTIFICATE-----")) { + throw new Error("Certificate is not in valid PEM format"); + } + + if (!privateKey.includes("-----BEGIN") || !privateKey.includes("-----END")) { + throw new Error("Private key is not in valid PEM format"); + } +}; + +const isAwsIssuedCertificate = (certificate: AWS.ACM.CertificateSummary): boolean => { + return certificate.Type === "AMAZON_ISSUED"; +}; + +const shouldSkipCertificateExport = (certificate: AWS.ACM.CertificateSummary): boolean => { + return isAwsIssuedCertificate(certificate); +}; + +const findTagByKey = (tags: AWS.ACM.TagList | undefined, key: string): AWS.ACM.Tag | undefined => { + if (!tags || !Array.isArray(tags)) { + return undefined; + } + return tags.find((tag: AWS.ACM.Tag) => tag.Key === key && tag.Value); +}; + +const findInfisicalCertificateTag = (tags: AWS.ACM.TagList | undefined): AWS.ACM.Tag | undefined => { + return findTagByKey(tags, INFISICAL_CERTIFICATE_TAG); +}; + +const validateCertificateIdentification = ( + certName: string, + existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string } +): boolean => { + if (!existingCert?.arn || !existingCert?.Tags) { + return false; + } + + const certNameTag = findInfisicalCertificateTag(existingCert.Tags); + + if (!certNameTag || !certNameTag.Value) { + return false; + } + + return certNameTag.Value === certName; +}; + +type TAwsCertificateManagerPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +const validateCertificateNameSchema = (schema: string): void => { + if (!schema.includes("{{certificateId}}")) { + throw new Error( + "Certificate name schema must include {{certificateId}} placeholder for proper certificate identification" + ); + } +}; + +const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithCredentials): string => { + if (!certificateName || typeof certificateName !== "string") { + throw new Error("Certificate name must be a non-empty string"); + } + + const sanitizedCertificateName = sanitizeInput(certificateName); + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + validateCertificateNameSchema(certificateNameSchema); + + let certificateId: string; + + if (sanitizedCertificateName.startsWith("Infisical-")) { + certificateId = sanitizedCertificateName.substring("Infisical-".length); + } else { + certificateId = sanitizedCertificateName; + } + + if (!certificateId || certificateId.trim().length === 0) { + throw new Error(`Certificate ID cannot be empty after processing certificate name: ${certificateName}`); + } + + const environment = "global"; + const generatedName = certificateNameSchema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), certificateId) + .replace(new RE2("\\{\\{environment\\}\\}", "g"), environment); + + if (generatedName.length > 256 || generatedName.length < 1) { + throw new Error( + `Generated certificate name length (${generatedName.length}) must be between 1 and 256 characters` + ); + } + + if (generatedName.includes("{{certificateId}}")) { + throw new Error("Certificate name schema failed to properly replace {{certificateId}} placeholder"); + } + + return generatedName; + } + + return sanitizedCertificateName; +}; + +const getAwsAcmClient = async ( + connectionId: string, + region: AWSRegion, + appConnectionDAL: Pick, + kmsService: Pick +): Promise => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AWS) { + throw new BadRequestError({ + message: `Connection '${connectionId}' is not an AWS connection (found: ${appConnection.app})` + }); + } + + const decryptedCredentials = await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId + }); + + let awsConnectionConfig: TAwsConnectionConfig; + switch (appConnection.method) { + case AwsConnectionMethod.AssumeRole: + awsConnectionConfig = { + app: AppConnection.AWS, + method: AwsConnectionMethod.AssumeRole, + credentials: decryptedCredentials as TAwsAssumeRoleCredentials, + orgId: appConnection.orgId + }; + break; + case AwsConnectionMethod.AccessKey: + awsConnectionConfig = { + app: AppConnection.AWS, + method: AwsConnectionMethod.AccessKey, + credentials: decryptedCredentials as TAwsAccessKeyCredentials, + orgId: appConnection.orgId + }; + break; + default: + throw new BadRequestError({ + message: `Unsupported AWS connection method: ${appConnection.method}` + }); + } + + const awsConfig = await getAwsConnectionConfig(awsConnectionConfig, region); + + return new AWS.ACM(awsConfig); +}; + +export const awsCertificateManagerPkiSyncFactory = ({ + kmsService, + appConnectionDAL +}: TAwsCertificateManagerPkiSyncFactoryDeps) => { + const deleteCertificateFromAcm = async ( + acm: AWS.ACM, + certificateArn: string, + operation: string, + syncId: string, + throwOnError = false + ): Promise<{ arn: string; success: boolean; error?: Error }> => { + try { + await withRateLimitRetry(() => acm.deleteCertificate({ CertificateArn: certificateArn }).promise(), { + operation, + syncId + }); + return { arn: certificateArn, success: true }; + } catch (error) { + const errorObj = error instanceof Error ? error : new Error("Unknown error"); + + if (throwOnError) { + throw new PkiSyncError({ + message: `Failed to remove certificate from AWS Certificate Manager: ${errorObj.message}`, + cause: errorObj, + context: { + certificateArn, + operation + } + }); + } + + return { + arn: certificateArn, + success: false, + error: errorObj + }; + } + }; + const $getAwsAcmCertificates = async ( + acm: AWS.ACM, + syncId = "unknown" + ): Promise<{ + acmCertificates: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + >; + }> => { + const paginateAwsAcmCertificates = async () => { + const certificates: AWS.ACM.CertificateSummary[] = []; + let nextToken: string | undefined; + + do { + const listParams: AWS.ACM.ListCertificatesRequest = { + CertificateStatuses: ["ISSUED"], + NextToken: nextToken, + MaxItems: 100 + }; + + const response = await withRateLimitRetry(() => acm.listCertificates(listParams).promise(), { + operation: "list-certificates", + syncId + }); + + if (response.CertificateSummaryList) { + certificates.push(...response.CertificateSummaryList); + } + nextToken = response.NextToken; + } while (nextToken); + + return certificates; + }; + + const certificateSummaries = await paginateAwsAcmCertificates(); + + const certificateResults = await executeWithConcurrencyLimit( + certificateSummaries, + async (certSummary) => { + if (!certSummary.CertificateArn) { + throw new Error("Certificate ARN is missing"); + } + + const [certificateDetails, tagsResponse] = await Promise.all([ + acm.describeCertificate({ CertificateArn: certSummary.CertificateArn }).promise(), + acm.listTagsForCertificate({ CertificateArn: certSummary.CertificateArn }).promise() + ]); + + let certificateContent: AWS.ACM.GetCertificateResponse | undefined; + if (!shouldSkipCertificateExport(certSummary)) { + try { + certificateContent = await acm.getCertificate({ CertificateArn: certSummary.CertificateArn }).promise(); + } catch (error) { + // Certificate content cannot be imported + } + } + + return { + ...certificateDetails.Certificate, + Tags: tagsResponse.Tags, + key: extractCertificateNameFromArn(certSummary.CertificateArn), + cert: certificateContent?.Certificate || "", + certificateChain: certificateContent?.CertificateChain || "", + privateKey: "", // Private keys cannot be exported from ACM + arn: certSummary.CertificateArn + }; + }, + { operation: "fetch-certificate-details", syncId } + ); + + const successfulCertificates: ACMCertificateWithKey[] = []; + certificateResults.forEach((result) => { + if (result.status === "fulfilled") { + successfulCertificates.push(result.value as ACMCertificateWithKey); + } + }); + + const failedFetches = certificateResults.filter((result) => result.status === "rejected"); + if (failedFetches.length > 0) { + throw new PkiSyncError({ + message: `Failed to fetch ${failedFetches.length} certificate details from AWS Certificate Manager`, + shouldRetry: true, + context: { + failedCount: failedFetches.length, + totalCount: certificateSummaries.length + } + }); + } + + const res: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + > = successfulCertificates.reduce( + (obj, certificate) => ({ + ...obj, + [certificate.key]: { + cert: certificate.cert, + privateKey: certificate.privateKey, + certificateChain: certificate.certificateChain, + arn: certificate.CertificateArn, + Tags: certificate.Tags + } + }), + {} as Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + > + ); + + return { + acmCertificates: res + }; + }; + + const syncCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap + ): Promise => { + const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; + const acm = await getAwsAcmClient( + pkiSync.connection.id, + destinationConfig.region as AWSRegion, + appConnectionDAL, + kmsService + ); + + const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + + const setCertificates: CertificateImportRequest[] = []; + + const activeCertificateNames = Object.keys(certificateMap); + + Object.entries(certificateMap).forEach(([certName, certData]) => { + const { cert, privateKey, certificateChain } = certData; + const certificateName = generateCertificateName(certName, pkiSync); + + const existingCert = Object.values(acmCertificates).find((acmCert) => + validateCertificateIdentification(certName, acmCert) + ); + + const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + + try { + validateCertificateContent(cert, privateKey); + } catch (validationError) { + throw new PkiSyncError({ + message: `Certificate validation failed for ${certName}: ${validationError instanceof Error ? validationError.message : String(validationError)}`, + shouldRetry: false, + context: { + certificateName, + certName + } + }); + } + + if (shouldUpdateCert) { + setCertificates.push({ + key: certName, + name: certificateName, + cert, + privateKey, + certificateChain, + existingArn: existingCert?.arn + }); + } + }); + + // Identify expired/removed certificates that need to be cleaned up from ACM + const certificatesToRemove = Object.values(acmCertificates) + .filter((acmCert) => { + if (!acmCert.arn || !acmCert.Tags) { + return false; + } + + const certNameTag = findInfisicalCertificateTag(acmCert.Tags); + if (!certNameTag || !certNameTag.Value) { + return false; + } + + const isActive = activeCertificateNames.includes(certNameTag.Value); + return !isActive; + }) + .map((acmCert) => acmCert.arn!) + .filter((arn) => arn); + + const uploadResults = await executeWithConcurrencyLimit( + setCertificates, + async ({ key, name, cert, privateKey, certificateChain, existingArn }) => { + try { + const importParams: AWS.ACM.ImportCertificateRequest = { + Certificate: cert, + PrivateKey: privateKey, + Tags: [ + { + Key: INFISICAL_CERTIFICATE_TAG, + Value: key + } + ] + }; + + if (certificateChain && certificateChain.trim().length > 0) { + importParams.CertificateChain = certificateChain; + } + if (existingArn) { + importParams.CertificateArn = existingArn; + } + + const response = await withRateLimitRetry(() => acm.importCertificate(importParams).promise(), { + operation: "import-certificate", + syncId: pkiSync.id + }); + + return { key, name, success: true, response }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + throw new PkiSyncError({ + message: `Failed to import certificate ${key} to AWS Certificate Manager: ${errorMessage}`, + cause: error instanceof Error ? error : new Error(errorMessage), + context: { + certificateKey: key, + certificateName: name, + region: destinationConfig.region + } + }); + } + }, + { operation: "import-certificates", syncId: pkiSync.id } + ); + + const results = uploadResults; + const failedUploads = results.filter((result) => result.status === "rejected"); + const successfulUploads = results.filter((result) => result.status === "fulfilled"); + + let removedCertificates = 0; + let failedRemovals = 0; + let removeResults: PromiseSettledResult<{ arn: string; success: boolean; error?: Error }>[] = []; + + if (certificatesToRemove.length > 0) { + removeResults = await executeWithConcurrencyLimit( + certificatesToRemove, + async (certificateArn) => deleteCertificateFromAcm(acm, certificateArn, "delete-certificate", pkiSync.id), + { operation: "remove-certificates", syncId: pkiSync.id } + ); + + const successfulRemovals = removeResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + removedCertificates = successfulRemovals.length; + failedRemovals = removeResults.length - removedCertificates; + } + + const details: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + } = {}; + + if (failedUploads.length > 0) { + details.failedUploads = failedUploads.map((failure, index) => { + const certificateName = setCertificates[index]?.name || "unknown"; + let errorMessage = "Unknown error"; + + if (failure.status === "rejected") { + errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + } + + return { + name: certificateName, + error: errorMessage + }; + }); + } + + if (failedRemovals > 0 && removeResults.length > 0) { + const actualFailedRemovals = removeResults + .map((result, index) => { + if (result.status === "rejected") { + const arn = certificatesToRemove[index] || "unknown"; + const errorMessage = result.reason instanceof Error ? result.reason.message : "Unknown error"; + return { + name: arn.includes("certificate/") ? extractCertificateNameFromArn(arn) : arn, + error: errorMessage + }; + } + return null; + }) + .filter((item): item is { name: string; error: string } => item !== null); + + details.failedRemovals = actualFailedRemovals; + } + + return { + uploaded: successfulUploads.length, + removed: removedCertificates, + failedRemovals, + skipped: Object.keys(certificateMap).length - setCertificates.length, + details: Object.keys(details).length > 0 ? details : undefined + }; + }; + + const removeCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateNames: string[] + ): Promise => { + const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; + const acm = await getAwsAcmClient( + pkiSync.connection.id, + destinationConfig.region as AWSRegion, + appConnectionDAL, + kmsService + ); + + const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + + const certificateArnsToRemove: string[] = []; + + for (const certName of certificateNames) { + const matchingCerts = Object.values(acmCertificates).filter((acmCert) => + validateCertificateIdentification(certName, acmCert) + ); + + for (const acmCert of matchingCerts) { + if (acmCert.arn) { + certificateArnsToRemove.push(acmCert.arn); + } + } + } + + const results = await executeWithConcurrencyLimit( + certificateArnsToRemove, + async (certificateArn) => + deleteCertificateFromAcm(acm, certificateArn, "delete-specific-certificate", pkiSync.id, true), + { operation: "remove-specific-certificates", syncId: pkiSync.id } + ); + + const failedRemovals = results.filter((result) => result.status === "rejected"); + + if (failedRemovals.length > 0) { + const failedReasons = failedRemovals.map((failure) => { + if (failure.status === "rejected") { + return failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + } + return "Unknown error"; + }); + + throw new PkiSyncError({ + message: `Failed to remove ${failedRemovals.length} certificate(s) from AWS Certificate Manager`, + context: { + failedReasons, + totalCertificates: certificateArnsToRemove.length, + failedCount: failedRemovals.length + } + }); + } + + return { + removed: certificateArnsToRemove.length - failedRemovals.length, + failed: failedRemovals.length, + skipped: certificateNames.length - certificateArnsToRemove.length + }; + }; + + return { + syncCertificates, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts new file mode 100644 index 000000000..eb9ae5444 --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts @@ -0,0 +1,84 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; + +import { AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING } from "./aws-certificate-manager-pki-sync-constants"; + +export const AwsCertificateManagerPkiSyncConfigSchema = z.object({ + region: z.nativeEnum(AWSRegion) +}); + +const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (schema) => { + if (!schema) return true; + + // Validate that {{certificateId}} placeholder is present + if (!schema.includes("{{certificateId}}")) { + return false; + } + + const testName = schema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") + .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); + + const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( + (char) => testName.includes(char) + ); + + return ( + AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && + !hasForbiddenChars && + testName.length >= AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MIN_LENGTH && + testName.length <= AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MAX_LENGTH + ); + }, + { + message: + "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager" + } + ) +}); + +export const AwsCertificateManagerPkiSyncSchema = PkiSyncSchema.extend({ + destination: z.literal(PkiSync.AwsCertificateManager), + destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, + syncOptions: AwsCertificateManagerPkiSyncOptionsSchema +}); + +export const CreateAwsCertificateManagerPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, + syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}), + subscriberId: z.string().optional(), + connectionId: z.string(), + projectId: z.string().trim().min(1) +}); + +export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(), + syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(), + subscriberId: z.string().optional(), + connectionId: z.string().optional() +}); + +export const AwsCertificateManagerPkiSyncListItemSchema = z.object({ + name: z.literal("AWS Certificate Manager"), + connection: z.literal(AppConnection.AWS), + destination: z.literal(PkiSync.AwsCertificateManager), + canImportCertificates: z.literal(false), + canRemoveCertificates: z.literal(true) +}); diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts new file mode 100644 index 000000000..717e86438 --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts @@ -0,0 +1,58 @@ +import * as AWS from "aws-sdk"; +import { z } from "zod"; + +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; + +import { + AwsCertificateManagerPkiSyncConfigSchema, + AwsCertificateManagerPkiSyncSchema, + CreateAwsCertificateManagerPkiSyncSchema, + UpdateAwsCertificateManagerPkiSyncSchema +} from "./aws-certificate-manager-pki-sync-schemas"; + +export type TAwsCertificateManagerPkiSyncConfig = z.infer; + +export type TAwsCertificateManagerPkiSync = z.infer; + +export type TAwsCertificateManagerPkiSyncInput = z.infer; + +export type TAwsCertificateManagerPkiSyncUpdate = z.infer; + +export type TAwsCertificateManagerPkiSyncWithCredentials = TAwsCertificateManagerPkiSync & { + connection: TAwsConnection; +}; + +export interface ACMCertificateWithKey extends AWS.ACM.CertificateDetail { + Tags?: AWS.ACM.TagList; + key: string; + cert: string; + certificateChain: string; + privateKey: string; + arn?: string; +} + +export interface SyncCertificatesResult { + uploaded: number; + removed: number; + failedRemovals: number; + skipped: number; + details?: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + }; +} + +export interface RemoveCertificatesResult { + removed: number; + failed: number; + skipped: number; +} + +export interface CertificateImportRequest { + key: string; + name: string; + cert: string; + privateKey: string; + certificateChain?: string; + existingArn?: string; +} diff --git a/backend/src/services/pki-sync/aws-certificate-manager/index.ts b/backend/src/services/pki-sync/aws-certificate-manager/index.ts new file mode 100644 index 000000000..fb30b5c71 --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-certificate-manager-pki-sync-constants"; +export * from "./aws-certificate-manager-pki-sync-fns"; +export * from "./aws-certificate-manager-pki-sync-schemas"; +export * from "./aws-certificate-manager-pki-sync-types"; diff --git a/backend/src/services/pki-sync/pki-sync-enums.ts b/backend/src/services/pki-sync/pki-sync-enums.ts index fadd70914..46a7fc975 100644 --- a/backend/src/services/pki-sync/pki-sync-enums.ts +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -1,5 +1,6 @@ export enum PkiSync { - AzureKeyVault = "azure-key-vault" + AzureKeyVault = "azure-key-vault", + AwsCertificateManager = "aws-certificate-manager" } export enum PkiSyncStatus { diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts index 343afa626..75f312fff 100644 --- a/backend/src/services/pki-sync/pki-sync-fns.ts +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -6,6 +6,8 @@ import { BadRequestError } from "@app/lib/errors"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants"; +import { awsCertificateManagerPkiSyncFactory } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-fns"; import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants"; import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns"; import { PkiSync } from "./pki-sync-enums"; @@ -14,7 +16,8 @@ import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; const PKI_SYNC_LIST_OPTIONS = { - [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION + [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION, + [PkiSync.AwsCertificateManager]: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION }; export const enterprisePkiSyncCheck = async ( @@ -144,8 +147,10 @@ export const matchesCertificateNameSchema = (name: string, environment: string, return name.startsWith(prefix) && name.endsWith(suffix); }; -const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => { - return pkiSync.destination === PkiSync.AzureKeyVault; +const checkPkiSyncDestination = (pkiSync: TPkiSyncWithCredentials, destination: PkiSync): void => { + if (pkiSync.destination !== destination) { + throw new Error(`Invalid PKI sync destination: ${pkiSync.destination}`); + } }; export const PkiSyncFns = { @@ -163,6 +168,11 @@ export const PkiSyncFns = { "Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)" ); } + case PkiSync.AwsCertificateManager: { + throw new Error( + "AWS Certificate Manager does not support importing certificates into Infisical (private keys cannot be extracted)" + ); + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } @@ -188,12 +198,15 @@ export const PkiSyncFns = { }> => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { - if (!isAzureKeyVaultPkiSync(pkiSync)) { - throw new Error("Invalid Azure Key Vault PKI sync configuration"); - } + checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); } + case PkiSync.AwsCertificateManager: { + checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); + const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); + return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap); + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } @@ -209,13 +222,17 @@ export const PkiSyncFns = { ): Promise => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { - if (!isAzureKeyVaultPkiSync(pkiSync)) { - throw new Error("Invalid Azure Key Vault PKI sync configuration"); - } + checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); break; } + case PkiSync.AwsCertificateManager: { + checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); + const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); + await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames); + break; + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } diff --git a/backend/src/services/pki-sync/pki-sync-maps.ts b/backend/src/services/pki-sync/pki-sync-maps.ts index b667416c6..5c416b513 100644 --- a/backend/src/services/pki-sync/pki-sync-maps.ts +++ b/backend/src/services/pki-sync/pki-sync-maps.ts @@ -3,9 +3,11 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { PkiSync } from "./pki-sync-enums"; export const PKI_SYNC_NAME_MAP: Record = { - [PkiSync.AzureKeyVault]: "Azure Key Vault" + [PkiSync.AzureKeyVault]: "Azure Key Vault", + [PkiSync.AwsCertificateManager]: "AWS Certificate Manager" }; export const PKI_SYNC_CONNECTION_MAP: Record = { - [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault + [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault, + [PkiSync.AwsCertificateManager]: AppConnection.AWS }; diff --git a/backend/src/services/project-bot/project-bot-dal.ts b/backend/src/services/project-bot/project-bot-dal.ts index ecb23f78b..2f1863ad6 100644 --- a/backend/src/services/project-bot/project-bot-dal.ts +++ b/backend/src/services/project-bot/project-bot-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TProjectBots, TUserEncryptionKeys } from "@app/db/schemas"; +import { AccessScope, TableName, TProjectBots, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -44,12 +44,13 @@ export const projectBotDALFactory = (db: TDbClient) => { const findProjectUserWorkspaceKey = async (projectId: string) => { try { const doc = await db - .replicaNode()(TableName.ProjectMembership) - .where(`${TableName.ProjectMembership}.projectId` as "projectId", projectId) - .where(`${TableName.ProjectKeys}.projectId` as "projectId", projectId) + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeProjectId` as "projectId", projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) .where(`${TableName.Users}.isGhost` as "isGhost", false) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) - .join(TableName.ProjectKeys, `${TableName.ProjectMembership}.userId`, `${TableName.ProjectKeys}.receiverId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .join(TableName.ProjectKeys, `${TableName.Membership}.actorUserId`, `${TableName.ProjectKeys}.receiverId`) + .where(`${TableName.ProjectKeys}.projectId` as "projectId", projectId) .join( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, diff --git a/backend/src/services/project-key/project-key-dal.ts b/backend/src/services/project-key/project-key-dal.ts index bb91b9c85..07d061e95 100644 --- a/backend/src/services/project-key/project-key-dal.ts +++ b/backend/src/services/project-key/project-key-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TProjectKeys } from "@app/db/schemas"; +import { AccessScope, TableName, TProjectKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -34,11 +34,12 @@ export const projectKeyDALFactory = (db: TDbClient) => { const findAllProjectUserPubKeys = async (projectId: string, tx?: Knex) => { try { - const pubKeys = await (tx || db.replicaNode())(TableName.ProjectMembership) - .where({ projectId }) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + const pubKeys = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scopeProjectId` as "scopeProjectId", projectId) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`) - .select("userId", "publicKey"); + .select(db.ref("userId").withSchema(TableName.Users), "publicKey"); return pubKeys; } catch (error) { throw new DatabaseError({ error, name: "Find all workspace pub keys" }); diff --git a/backend/src/services/project-key/project-key-service.ts b/backend/src/services/project-key/project-key-service.ts index a884d25bc..7faa11f2b 100644 --- a/backend/src/services/project-key/project-key-service.ts +++ b/backend/src/services/project-key/project-key-service.ts @@ -1,25 +1,25 @@ import { ForbiddenError } from "@casl/ability"; -import { ActionProjectType } from "@app/db/schemas"; +import { AccessScope, ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; -import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TProjectKeyDALFactory } from "./project-key-dal"; import { TGetLatestProjectKeyDTO, TUploadProjectKeyDTO } from "./project-key-types"; type TProjectKeyServiceFactoryDep = { permissionService: TPermissionServiceFactory; projectKeyDAL: TProjectKeyDALFactory; - projectMembershipDAL: TProjectMembershipDALFactory; + membershipUserDAL: TMembershipUserDALFactory; }; export type TProjectKeyServiceFactory = ReturnType; export const projectKeyServiceFactory = ({ projectKeyDAL, - projectMembershipDAL, + membershipUserDAL, permissionService }: TProjectKeyServiceFactoryDep) => { const uploadProjectKeys = async ({ @@ -42,9 +42,10 @@ export const projectKeyServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); - const receiverMembership = await projectMembershipDAL.findOne({ - userId: receiverId, - projectId + const receiverMembership = await membershipUserDAL.findOne({ + actorUserId: receiverId, + scopeProjectId: projectId, + scope: AccessScope.Project }); if (!receiverMembership) throw new BadRequestError({ diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index dd503c2a3..fe3b57226 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -1,15 +1,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { AccessScope, TableName, TMemberships, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TProjectMembershipDALFactory = ReturnType; export const projectMembershipDALFactory = (db: TDbClient) => { - const projectMemberOrm = ormify(db, TableName.ProjectMembership); - // special query const findAllProjectMembers = async ( projectId: string, @@ -17,18 +15,17 @@ export const projectMembershipDALFactory = (db: TDbClient) => { ) => { try { const docs = await db - .replicaNode()(TableName.ProjectMembership) - .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) - .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) - .join(TableName.OrgMembership, (qb) => { - qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn( - `${TableName.OrgMembership}.orgId`, - "=", - `${TableName.Project}.orgId` - ); + .replicaNode()(TableName.Membership) + .where({ [`${TableName.Membership}.scopeProjectId` as "scopeProjectId"]: projectId }) + .where({ [`${TableName.Membership}.scope` as "scope"]: AccessScope.Project }) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .join(db(TableName.Membership).as("orgMembership"), (qb) => { + qb.on(`${TableName.Users}.id`, "=", `orgMembership.actorUserId`) + .andOn(`orgMembership.scopeOrgId`, "=", `${TableName.Project}.orgId`) + .andOn("orgMembership.scope", db.raw("?", [AccessScope.Organization])); }) - .where((qb) => { if (filter.usernames) { void qb.whereIn("username", filter.usernames); @@ -37,69 +34,46 @@ export const projectMembershipDALFactory = (db: TDbClient) => { void qb.where("username", filter.username); } if (filter.id) { - void qb.where(`${TableName.ProjectMembership}.id`, filter.id); + void qb.where(`${TableName.Membership}.id`, filter.id); } if (filter.roles && filter.roles.length > 0) { void qb.whereExists((subQuery) => { void subQuery .select("role") - .from(TableName.ProjectUserMembershipRole) - .leftJoin( - TableName.ProjectRoles, - `${TableName.ProjectRoles}.id`, - `${TableName.ProjectUserMembershipRole}.customRoleId` - ) - .whereRaw("??.?? = ??.??", [ - TableName.ProjectUserMembershipRole, - "projectMembershipId", - TableName.ProjectMembership, - "id" - ]) + .from(TableName.MembershipRole) + .leftJoin(TableName.Role, `${TableName.Role}.id`, `${TableName.MembershipRole}.customRoleId`) + .whereRaw("??.?? = ??.??", [TableName.MembershipRole, "membershipId", TableName.Membership, "id"]) .where((subQb) => { void subQb - .whereIn(`${TableName.ProjectUserMembershipRole}.role`, filter.roles as string[]) - .orWhereIn(`${TableName.ProjectRoles}.slug`, filter.roles as string[]); + .whereIn(`${TableName.MembershipRole}.role`, filter.roles as string[]) + .orWhereIn(`${TableName.Role}.slug`, filter.roles as string[]); }); }); } }) - .join( - TableName.UserEncryptionKey, - `${TableName.UserEncryptionKey}.userId`, - `${TableName.Users}.id` - ) - .join( - TableName.ProjectUserMembershipRole, - `${TableName.ProjectUserMembershipRole}.projectMembershipId`, - `${TableName.ProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.ProjectUserMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .select( - db.ref("id").withSchema(TableName.ProjectMembership), - db.ref("createdAt").withSchema(TableName.ProjectMembership), + db.ref("id").withSchema(TableName.Membership), + db.ref("createdAt").withSchema(TableName.Membership), db.ref("isGhost").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), - db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("role").withSchema(TableName.ProjectUserMembershipRole), - db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), - db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project), - db.ref("isActive").withSchema(TableName.OrgMembership) + db.ref("isActive").withSchema("orgMembership") ) .where({ isGhost: false }) .orderBy(`${TableName.Users}.username` as "username"); @@ -111,7 +85,6 @@ export const projectMembershipDALFactory = (db: TDbClient) => { firstName, username, lastName, - publicKey, isGhost, id, userId, @@ -128,7 +101,9 @@ export const projectMembershipDALFactory = (db: TDbClient) => { firstName, lastName, id: userId, - publicKey, + // akhilmhdh: if we do user encryption based join this would fail for scim user who haven't logged in yet + // public key is not used anymore as well + publicKey: "", isGhost, isOrgMembershipActive: isActive }, @@ -184,9 +159,11 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const findProjectGhostUser = async (projectId: string, tx?: Knex) => { try { - const ghostUser = await (tx || db.replicaNode())(TableName.ProjectMembership) - .where({ projectId }) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + const ghostUser = await (tx || db.replicaNode())(TableName.Membership) + .where({ [`${TableName.Membership}.scopeProjectId` as "scopeProjectId"]: projectId }) + .where({ [`${TableName.Membership}.scope` as "scope"]: AccessScope.Project }) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.Users)) .where({ isGhost: true }) .first(); @@ -200,21 +177,24 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const findMembershipsByUsername = async (projectId: string, usernames: string[]) => { try { const members = await db - .replicaNode()(TableName.ProjectMembership) - .where({ projectId }) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where({ [`${TableName.Membership}.scopeProjectId` as "scopeProjectId"]: projectId }) + .where({ [`${TableName.Membership}.scope` as "scope"]: AccessScope.Project }) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .join( TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) .select( - selectAllTableCols(TableName.ProjectMembership), + selectAllTableCols(TableName.Membership), db.ref("id").withSchema(TableName.Users).as("userId"), db.ref("username").withSchema(TableName.Users) ) .whereIn("username", usernames) .where({ isGhost: false }); + return members.map(({ userId, username, ...data }) => ({ ...data, user: { id: userId, username } @@ -227,45 +207,33 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const findProjectMembershipsByUserId = async (orgId: string, userId: string) => { try { const docs = await db - .replicaNode()(TableName.ProjectMembership) - .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where({ [`${TableName.Membership}.scope` as "scope"]: AccessScope.Project }) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .where(`${TableName.Users}.id`, userId) .where(`${TableName.Project}.orgId`, orgId) - .join( - TableName.UserEncryptionKey, - `${TableName.UserEncryptionKey}.userId`, - `${TableName.Users}.id` - ) - .join( - TableName.ProjectUserMembershipRole, - `${TableName.ProjectUserMembershipRole}.projectMembershipId`, - `${TableName.ProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.ProjectUserMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .select( - db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("id").withSchema(TableName.Membership), db.ref("isGhost").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), - db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("role").withSchema(TableName.ProjectUserMembershipRole), - db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), - db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project), db.ref("id").as("projectId").withSchema(TableName.Project), db.ref("type").as("projectType").withSchema(TableName.Project) @@ -274,22 +242,21 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const members = sqlNestRelationships({ data: docs, - parentMapper: ({ - email, - firstName, - username, - lastName, - publicKey, - isGhost, - id, - projectId, - projectName, - projectType - }) => ({ + parentMapper: ({ email, firstName, username, lastName, isGhost, id, projectId, projectName, projectType }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { + email, + username, + firstName, + lastName, + id: userId, + isGhost, + // akhilmhdh: if we do user encryption based join this would fail for scim user who haven't logged in yet + // public key is not used anymore as well + publicKey: "" + }, project: { id: projectId, name: projectName, @@ -336,9 +303,11 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const findProjectMembershipsByUserIds = async (orgId: string, userIds: string[]) => { try { const docs = await db - .replicaNode()(TableName.ProjectMembership) - .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where({ [`${TableName.Membership}.scope` as "scope"]: AccessScope.Project }) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .whereIn(`${TableName.Users}.id`, userIds) .where(`${TableName.Project}.orgId`, orgId) .join( @@ -346,18 +315,10 @@ export const projectMembershipDALFactory = (db: TDbClient) => { `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) - .join( - TableName.ProjectUserMembershipRole, - `${TableName.ProjectUserMembershipRole}.projectMembershipId`, - `${TableName.ProjectMembership}.id` - ) - .leftJoin( - TableName.ProjectRoles, - `${TableName.ProjectUserMembershipRole}.customRoleId`, - `${TableName.ProjectRoles}.id` - ) + .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .select( - db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("id").withSchema(TableName.Membership), db.ref("isGhost").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users), @@ -365,16 +326,16 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), db.ref("id").withSchema(TableName.Users).as("userId"), - db.ref("role").withSchema(TableName.ProjectUserMembershipRole), - db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), - db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), - db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), - db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("role").withSchema(TableName.MembershipRole), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.MembershipRole), + db.ref("name").withSchema(TableName.Role).as("customRoleName"), + db.ref("slug").withSchema(TableName.Role).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole), + db.ref("isTemporary").withSchema(TableName.MembershipRole), + db.ref("temporaryRange").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.MembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.MembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project), db.ref("id").as("projectId").withSchema(TableName.Project), db.ref("type").as("projectType").withSchema(TableName.Project) @@ -444,7 +405,6 @@ export const projectMembershipDALFactory = (db: TDbClient) => { }; return { - ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByUsername, diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 301ce1bc0..b0949d550 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -1,64 +1,51 @@ /* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; -import { ActionProjectType, ProjectMembershipRole, ProjectVersion, TableName } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, ProjectMembershipRole, ProjectVersion, TableName } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { - constructPermissionErrorMessage, - validatePrivilegeChangeOperation -} from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, ForbiddenRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; -import { ms } from "@app/lib/ms"; import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; +import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; import { ActorType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TNotificationServiceFactory } from "../notification/notification-service"; import { NotificationType } from "../notification/notification-types"; -import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; -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 { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { - ProjectUserMembershipTemporaryMode, TAddUsersToWorkspaceDTO, - TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipsDTO, - TGetProjectMembershipByIdDTO, TGetProjectMembershipByUsernameDTO, TGetProjectMembershipDTO, - TLeaveProjectDTO, - TUpdateProjectMembershipDTO + TLeaveProjectDTO } from "./project-membership-types"; -import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; type TProjectMembershipServiceFactoryDep = { permissionService: Pick< TPermissionServiceFactory, - "getProjectPermission" | "getProjectPermissionByRole" | "invalidateProjectPermissionCache" + "getProjectPermission" | "getProjectPermissionByRoles" | "invalidateProjectPermissionCache" >; smtpService: TSmtpService; - projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: TProjectMembershipDALFactory; - projectUserMembershipRoleDAL: Pick; - userDAL: Pick; + membershipUserDAL: TMembershipUserDALFactory; + membershipRoleDAL: Pick; + userDAL: Pick; userGroupMembershipDAL: TUserGroupMembershipDALFactory; - projectRoleDAL: Pick; - orgDAL: Pick; projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; - projectUserAdditionalPrivilegeDAL: Pick; + additionalPrivilegeDAL: Pick; secretReminderRecipientsDAL: Pick; groupProjectDAL: TGroupProjectDALFactory; notificationService: Pick; @@ -69,19 +56,17 @@ export type TProjectMembershipServiceFactory = ReturnType { const getProjectMemberships = async ({ actorId, @@ -150,29 +135,6 @@ export const projectMembershipServiceFactory = ({ return membership; }; - const getProjectMembershipById = async ({ - actorId, - actor, - actorOrgId, - actorAuthMethod, - projectId, - id - }: TGetProjectMembershipByIdDTO) => { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - - const [membership] = await projectMembershipDAL.findAllProjectMembers(projectId, { id }); - if (!membership) throw new NotFoundError({ message: `Project membership not found for user ${id}` }); - return membership; - }; - const addUsersToProject = async ({ projectId, actorId, @@ -194,48 +156,58 @@ export const projectMembershipServiceFactory = ({ actionProjectType: ActionProjectType.Any }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member); - const orgMembers = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId, + const orgMembers = await membershipUserDAL.find({ + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: project.orgId, + scope: AccessScope.Organization, $in: { - [`${TableName.OrgMembership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) + [`${TableName.Membership}.id` as "id"]: members.map(({ orgMembershipId }) => orgMembershipId) } }); + if (orgMembers.length !== members.length) throw new BadRequestError({ message: "Some users are not part of org" }); - const existingMembers = await projectMembershipDAL.find({ - projectId, - $in: { userId: orgMembers.map(({ userId }) => userId).filter(Boolean) } + const existingMembers = await membershipUserDAL.find({ + [`${TableName.Membership}.scopeProjectId` as "scopeProjectId"]: projectId, + scope: AccessScope.Project, + $in: { actorUserId: orgMembers.map(({ actorUserId }) => actorUserId).filter(Boolean) } }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + const orgMembershipUsernames = await userDAL.find({ + $in: { + id: orgMembers.filter((el) => Boolean(el.actorUserId)).map((el) => el.actorUserId as string) + } + }); const userIdsToExcludeForProjectKeyAddition = new Set( await userGroupMembershipDAL.findUserGroupMembershipsInProject( - orgMembers.map(({ username }) => username), + orgMembershipUsernames.map(({ username }) => username), projectId ) ); - await projectMembershipDAL.transaction(async (tx) => { - const projectMemberships = await projectMembershipDAL.insertMany( - orgMembers.map(({ userId }) => ({ - projectId, - userId + await membershipUserDAL.transaction(async (tx) => { + const projectMemberships = await membershipUserDAL.insertMany( + orgMembers.map(({ actorUserId }) => ({ + scopeProjectId: projectId, + actorUserId, + scope: AccessScope.Project, + scopeOrgId: project.orgId })), tx ); - await projectUserMembershipRoleDAL.insertMany( - projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })), + await membershipRoleDAL.insertMany( + projectMemberships.map(({ id }) => ({ membershipId: id, role: ProjectMembershipRole.Member })), tx ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( orgMembers - .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId)) - .map(({ userId, id }) => ({ + .filter(({ actorUserId }) => !userIdsToExcludeForProjectKeyAddition.has(actorUserId as string)) + .map(({ actorUserId, id }) => ({ encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, senderId: actorId, - receiverId: userId, + receiverId: actorUserId as string, projectId })), tx @@ -246,8 +218,8 @@ export const projectMembershipServiceFactory = ({ if (sendEmails) { await notificationService.createUserNotifications( - orgMembers.map((member) => ({ - userId: member.userId, + orgMembershipUsernames.map((member) => ({ + userId: member.id, orgId: project.orgId, type: NotificationType.PROJECT_INVITATION, title: "Project Invitation", @@ -259,7 +231,7 @@ export const projectMembershipServiceFactory = ({ await smtpService.sendMail({ template: SmtpTemplates.WorkspaceInvite, subjectLine: "Infisical project invitation", - recipients: orgMembers.filter((i) => i.email).map((i) => i.email as string), + recipients: orgMembershipUsernames.filter((i) => i.email).map((i) => i.email as string), substitutions: { workspaceName: project.name, callback_url: `${appCfg.SITE_URL}/login` @@ -269,164 +241,6 @@ export const projectMembershipServiceFactory = ({ return orgMembers; }; - const updateProjectMembership = async ({ - actorId, - actor, - actorOrgId, - actorAuthMethod, - projectId, - membershipId, - roles - }: TUpdateProjectMembershipDTO) => { - const { permission, membership } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); - - const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); - if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) { - throw new ForbiddenRequestError({ message: "Forbidden member update" }); - } - - for await (const { role: requestedRoleChange } of roles) { - const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( - requestedRoleChange, - projectId - ); - - const permissionBoundary = validatePrivilegeChangeOperation( - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - `Failed to change role ${requestedRoleChange}`, - membership.shouldUseNewPrivilegeSystem, - ProjectPermissionMemberActions.GrantPrivileges, - ProjectPermissionSub.Member - ), - details: { missingPermissions: permissionBoundary.missingPermissions } - }); - } - - // validate custom roles input - const customInputRoles = roles.filter( - ({ role }) => - !Object.values(ProjectMembershipRole) - // we don't want to include custom in this check; - // this unintentionally enables setting slug to custom which is reserved - .filter((r) => r !== ProjectMembershipRole.Custom) - .includes(role as ProjectMembershipRole) - ); - const hasCustomRole = Boolean(customInputRoles.length); - if (hasCustomRole) { - const plan = await licenseService.getPlan(actorOrgId); - if (!plan?.rbac) - throw new BadRequestError({ - message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." - }); - } - - const customRoles = hasCustomRole - ? await projectRoleDAL.find({ - projectId, - $in: { slug: customInputRoles.map(({ role }) => role) } - }) - : []; - if (customRoles.length !== customInputRoles.length) { - throw new NotFoundError({ message: "One or more custom roles not found" }); - } - const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - - const sanitizedProjectMembershipRoles = roles.map((inputRole) => { - const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); - if (!inputRole.isTemporary) { - return { - projectMembershipId: membershipId, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null - }; - } - - // check cron or relative here later for now its just relative - const relativeTimeInMs = ms(inputRole.temporaryRange); - return { - projectMembershipId: membershipId, - role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, - customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, - isTemporary: true, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: inputRole.temporaryRange, - temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), - temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) - }; - }); - - const updatedRoles = await projectMembershipDAL.transaction(async (tx) => { - await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx); - return projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return updatedRoles; - }; - - // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. - const deleteProjectMembership = async ({ - actorId, - actor, - actorOrgId, - actorAuthMethod, - projectId, - membershipId - }: TDeleteProjectMembershipOldDTO) => { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Delete, ProjectPermissionSub.Member); - - const member = await userDAL.findUserByProjectMembershipId(membershipId); - - if (member?.isGhost) { - throw new ForbiddenRequestError({ - message: "Forbidden membership deletion", - name: "DeleteProjectMembership" - }); - } - - const membership = await projectMembershipDAL.transaction(async (tx) => { - const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); - await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); - await secretReminderRecipientsDAL.delete( - { - projectId, - userId: deletedMembership.userId - }, - tx - ); - return deletedMembership; - }); - - await permissionService.invalidateProjectPermissionCache(projectId); - - return membership; - }; - const deleteProjectMemberships = async ({ actorId, actor, @@ -478,20 +292,21 @@ export const projectMembershipServiceFactory = ({ await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) ); - const memberships = await projectMembershipDAL.transaction(async (tx) => { - await projectUserAdditionalPrivilegeDAL.delete( + const memberships = await membershipUserDAL.transaction(async (tx) => { + await additionalPrivilegeDAL.delete( { projectId, $in: { - userId: projectMembers.map((membership) => membership.user.id) + actorUserId: projectMembers.map((membership) => membership.user.id) } }, tx ); - const deletedMemberships = await projectMembershipDAL.delete( + const deletedMemberships = await membershipUserDAL.delete( { - projectId, + scopeProjectId: projectId, + scope: AccessScope.Project, $in: { id: projectMembers.map(({ id }) => id) } @@ -564,11 +379,11 @@ export const projectMembershipServiceFactory = ({ }); } - const deletedMembership = await projectMembershipDAL.transaction(async (tx) => { - await projectUserAdditionalPrivilegeDAL.delete( + const deletedMembership = await membershipUserDAL.transaction(async (tx) => { + await additionalPrivilegeDAL.delete( { projectId: project.id, - userId: actorId + actorUserId: actorId }, tx ); @@ -582,10 +397,11 @@ export const projectMembershipServiceFactory = ({ ); const membership = ( - await projectMembershipDAL.delete( + await membershipUserDAL.delete( { - projectId: project.id, - userId: actorId + scope: AccessScope.Project, + scopeProjectId: project.id, + actorUserId: actorId }, tx ) @@ -603,11 +419,8 @@ export const projectMembershipServiceFactory = ({ return { getProjectMemberships, getProjectMembershipByUsername, - updateProjectMembership, deleteProjectMemberships, - deleteProjectMembership, // TODO: Remove this addUsersToProject, - leaveProject, - getProjectMembershipById + leaveProject }; }; diff --git a/backend/src/services/project-membership/project-user-membership-role-dal.ts b/backend/src/services/project-membership/project-user-membership-role-dal.ts deleted file mode 100644 index b1cb55b9b..000000000 --- a/backend/src/services/project-membership/project-user-membership-role-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TProjectUserMembershipRoleDALFactory = ReturnType; - -export const projectUserMembershipRoleDALFactory = (db: TDbClient) => { - const orm = ormify(db, TableName.ProjectUserMembershipRole); - return orm; -}; diff --git a/backend/src/services/project-role/project-role-dal.ts b/backend/src/services/project-role/project-role-dal.ts deleted file mode 100644 index 942fefa11..000000000 --- a/backend/src/services/project-role/project-role-dal.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TProjectRoleDALFactory = ReturnType; - -export const projectRoleDALFactory = (db: TDbClient) => ormify(db, TableName.ProjectRoles); diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts deleted file mode 100644 index f30da21f5..000000000 --- a/backend/src/services/project-role/project-role-service.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; -import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; -import { requestContext } from "@fastify/request-context"; - -import { ActionProjectType, ProjectMembershipRole, ProjectType, TableName, TProjects } from "@app/db/schemas"; -import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { - ProjectPermissionActions, - ProjectPermissionSet, - ProjectPermissionSub -} from "@app/ee/services/permission/project-permission"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; -import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; - -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -import { TIdentityDALFactory } from "../identity/identity-dal"; -import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; -import { TProjectDALFactory } from "../project/project-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; -import { TUserDALFactory } from "../user/user-dal"; -import { TProjectRoleDALFactory } from "./project-role-dal"; -import { getPredefinedRoles } from "./project-role-fns"; -import { - ProjectRoleServiceIdentifierType, - TCreateRoleDTO, - TDeleteRoleDTO, - TGetRoleDetailsDTO, - TListRolesDTO, - TUpdateRoleDTO -} from "./project-role-types"; - -type TProjectRoleServiceFactoryDep = { - projectRoleDAL: TProjectRoleDALFactory; - identityDAL: Pick; - userDAL: Pick; - projectDAL: Pick; - permissionService: Pick< - TPermissionServiceFactory, - "getProjectPermission" | "getUserProjectPermission" | "invalidateProjectPermissionCache" - >; - identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; - projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; -}; - -export type TProjectRoleServiceFactory = ReturnType; - -const unpackPermissions = (permissions: unknown) => - UnpackedPermissionSchema.array().parse( - unpackRules((permissions || []) as PackRule>>[]) - ); - -export const projectRoleServiceFactory = ({ - projectRoleDAL, - permissionService, - identityProjectMembershipRoleDAL, - projectUserMembershipRoleDAL, - projectDAL, - identityDAL, - userDAL -}: TProjectRoleServiceFactoryDep) => { - const createRole = async ({ data, actor, actorId, actorAuthMethod, actorOrgId, filter }: TCreateRoleDTO) => { - let projectId = ""; - if (filter.type === ProjectRoleServiceIdentifierType.SLUG) { - const project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); - if (!project) throw new NotFoundError({ message: "Project not found" }); - projectId = project.id; - } else { - projectId = filter.projectId; - } - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Role); - const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId }); - if (existingRole) { - throw new BadRequestError({ name: "Create Role", message: "Project role with same slug already exists" }); - } - - validateHandlebarTemplate("Project Role Create", JSON.stringify(data.permissions || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - const role = await projectRoleDAL.create({ - ...data, - projectId - }); - return { ...role, permissions: unpackPermissions(role.permissions) }; - }; - - const getRoleBySlug = async ({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - roleSlug, - filter - }: TGetRoleDetailsDTO) => { - let project: TProjects; - if (filter.type === ProjectRoleServiceIdentifierType.SLUG) { - project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); - } else { - project = await projectDAL.findProjectById(filter.projectId); - } - - if (!project) throw new NotFoundError({ message: "Project not found" }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: project.id, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); - if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) { - const [predefinedRole] = getPredefinedRoles({ - projectId: project.id, - projectType: project.type as ProjectType, - roleFilter: roleSlug as ProjectMembershipRole - }); - - if (!predefinedRole) throw new NotFoundError({ message: `Default role with slug '${roleSlug}' not found` }); - - return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) }; - } - - const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId: project.id }); - if (!customRole) throw new NotFoundError({ message: `Project role with slug '${roleSlug}' not found` }); - return { ...customRole, permissions: unpackPermissions(customRole.permissions) }; - }; - - const updateRole = async ({ roleId, actorOrgId, actorAuthMethod, actorId, actor, data }: TUpdateRoleDTO) => { - const projectRole = await projectRoleDAL.findById(roleId); - if (!projectRole) throw new NotFoundError({ message: "Project role not found", name: "Delete role" }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectRole.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); - - if (data?.slug) { - const existingRole = await projectRoleDAL.findOne({ slug: data.slug, projectId: projectRole.projectId }); - if (existingRole && existingRole.id !== roleId) - throw new BadRequestError({ name: "Update Role", message: "Project role with the same slug already exists" }); - } - validateHandlebarTemplate("Project Role Update", JSON.stringify(data.permissions || []), { - allowedExpressions: (val) => val.includes("identity.") - }); - - const updatedRole = await projectRoleDAL.updateById(projectRole.id, { - ...data, - permissions: data.permissions ? data.permissions : undefined - }); - if (!updatedRole) throw new NotFoundError({ message: "Project role not found", name: "Update role" }); - - await permissionService.invalidateProjectPermissionCache(projectRole.projectId); - - return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; - }; - - const deleteRole = async ({ actor, actorId, actorAuthMethod, actorOrgId, roleId }: TDeleteRoleDTO) => { - const projectRole = await projectRoleDAL.findById(roleId); - if (!projectRole) throw new NotFoundError({ message: "Project role not found", name: "Delete role" }); - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: projectRole.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); - - const identityRole = await identityProjectMembershipRoleDAL.findOne({ customRoleId: roleId }); - const projectUserRole = await projectUserMembershipRoleDAL.findOne({ customRoleId: roleId }); - - if (identityRole) { - throw new BadRequestError({ - message: "The role is assigned to one or more identities. Make sure to unassign them before deleting the role.", - name: "Delete role" - }); - } - if (projectUserRole) { - throw new BadRequestError({ - message: "The role is assigned to one or more users. Make sure to unassign them before deleting the role.", - name: "Delete role" - }); - } - - const deletedRole = await projectRoleDAL.deleteById(roleId); - if (!deletedRole) throw new NotFoundError({ message: "Project role not found", name: "Delete role" }); - - await permissionService.invalidateProjectPermissionCache(projectRole.projectId); - - return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) }; - }; - - const listRoles = async ({ actorOrgId, actorAuthMethod, actorId, actor, filter }: TListRolesDTO) => { - let project: TProjects; - if (filter.type === ProjectRoleServiceIdentifierType.SLUG) { - project = await projectDAL.findProjectBySlug(filter.projectSlug, actorOrgId); - } else { - project = await projectDAL.findProjectById(filter.projectId); - } - - if (!project) throw new BadRequestError({ message: "Project not found" }); - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: project.id, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); - const customRoles = await projectRoleDAL.find( - { projectId: project.id }, - { sort: [[`${TableName.ProjectRoles}.slug` as "slug", "asc"]] } - ); - const roles = [ - ...getPredefinedRoles({ projectId: project.id, projectType: project.type as ProjectType }), - ...(customRoles || []) - ]; - - return roles; - }; - - const getUserPermission = async ( - userId: string, - projectId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { - const { permission, membership } = await permissionService.getProjectPermission({ - actor: ActorType.USER, - actorId: userId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.Any - }); - // just to satisfy ts - if (!("roles" in membership)) throw new BadRequestError({ message: "Service token not allowed" }); - - const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); - const isAssumingPrivilege = assumedPrivilegeDetailsCtx?.projectId === projectId; - const assumedPrivilegeDetails = isAssumingPrivilege - ? { - actorId: assumedPrivilegeDetailsCtx?.actorId, - actorType: assumedPrivilegeDetailsCtx?.actorType, - actorName: "", - actorEmail: "" - } - : undefined; - - if (assumedPrivilegeDetails?.actorType === ActorType.IDENTITY) { - const identityDetails = await identityDAL.findById(assumedPrivilegeDetails.actorId); - if (!identityDetails) - throw new NotFoundError({ message: `Identity with ID ${assumedPrivilegeDetails.actorId} not found` }); - assumedPrivilegeDetails.actorName = identityDetails.name; - } else if (assumedPrivilegeDetails?.actorType === ActorType.USER) { - const userDetails = await userDAL.findById(assumedPrivilegeDetails?.actorId); - if (!userDetails) - throw new NotFoundError({ message: `User with ID ${assumedPrivilegeDetails.actorId} not found` }); - assumedPrivilegeDetails.actorName = `${userDetails?.firstName} ${userDetails?.lastName || ""}`; - assumedPrivilegeDetails.actorEmail = userDetails?.email || ""; - } - - return { permissions: packRules(permission.rules), membership, assumedPrivilegeDetails }; - }; - - return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; -}; diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index d64977f8b..11d4239db 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + AccessScope, ProjectsSchema, ProjectType, ProjectUpgradeStatus, @@ -24,9 +25,11 @@ export const projectDALFactory = (db: TDbClient) => { const findIdentityProjects = async (identityId: string, orgId: string, projectType?: ProjectType) => { try { - const workspaces = await db(TableName.IdentityProjectMembership) - .where({ identityId }) - .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) + const workspaces = await db + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.actorIdentityId`, identityId) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) .where(`${TableName.Project}.orgId`, orgId) .andWhere((qb) => { if (projectType) { @@ -74,11 +77,23 @@ export const projectDALFactory = (db: TDbClient) => { const findUserProjects = async (userId: string, orgId: string, projectType?: ProjectType) => { try { - const workspaces = await db - .replicaNode()(TableName.ProjectMembership) - .where({ userId }) - .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) + const userGroupSubquery = db + .replicaNode()(TableName.Groups) + .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) + .where(`${TableName.Groups}.orgId`, orgId) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .select(db.ref("id").withSchema(TableName.Groups)); + + const projects = await db + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + void qb + .where(`${TableName.Membership}.actorUserId`, userId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery); + }) .andWhere((qb) => { if (projectType) { void qb.where(`${TableName.Project}.type`, projectType); @@ -97,36 +112,8 @@ export const projectDALFactory = (db: TDbClient) => { { column: `${TableName.Environment}.position`, order: "asc" } ]); - const groups = db(TableName.UserGroupMembership).where({ userId }).select("groupId"); - - const groupWorkspaces = await db(TableName.GroupProjectMembership) - .whereIn("groupId", groups) - .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) - .where(`${TableName.Project}.orgId`, orgId) - .andWhere((qb) => { - if (projectType) { - void qb.where(`${TableName.Project}.type`, projectType); - } - }) - .whereNotIn( - `${TableName.Project}.id`, - workspaces.map(({ id }) => id) - ) - .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) - .select( - selectAllTableCols(TableName.Project), - db.ref("id").withSchema(TableName.Project).as("_id"), - db.ref("id").withSchema(TableName.Environment).as("envId"), - db.ref("slug").withSchema(TableName.Environment).as("envSlug"), - db.ref("name").withSchema(TableName.Environment).as("envName") - ) - .orderBy([ - { column: `${TableName.Project}.name`, order: "asc" }, - { column: `${TableName.Environment}.position`, order: "asc" } - ]); - - const nestedWorkspaces = sqlNestRelationships({ - data: workspaces.concat(groupWorkspaces), + const formattedProjects = sqlNestRelationships({ + data: projects, key: "id", parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), childrenMapper: [ @@ -142,7 +129,7 @@ export const projectDALFactory = (db: TDbClient) => { ] }); - return nestedWorkspaces.map((workspace) => ({ + return formattedProjects.map((workspace) => ({ ...workspace, organization: workspace.orgId })); @@ -153,9 +140,10 @@ export const projectDALFactory = (db: TDbClient) => { const findProjectGhostUser = async (projectId: string, tx?: Knex) => { try { - const ghostUser = await (tx || db.replicaNode())(TableName.ProjectMembership) - .where({ projectId }) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + const ghostUser = await (tx || db.replicaNode())(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, projectId) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .select(selectAllTableCols(TableName.Users)) .where({ isGhost: true }) .first(); @@ -177,54 +165,6 @@ export const projectDALFactory = (db: TDbClient) => { } }; - const findAllProjectsByIdentity = async (identityId: string, projectType?: ProjectType) => { - try { - const workspaces = await db - .replicaNode()(TableName.IdentityProjectMembership) - .where({ identityId }) - .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) - .andWhere((qb) => { - if (projectType) { - void qb.where(`${TableName.Project}.type`, projectType); - } - }) - .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) - .select( - selectAllTableCols(TableName.Project), - db.ref("id").withSchema(TableName.Project).as("_id"), - db.ref("id").withSchema(TableName.Environment).as("envId"), - db.ref("slug").withSchema(TableName.Environment).as("envSlug"), - db.ref("name").withSchema(TableName.Environment).as("envName") - ) - .orderBy("createdAt", "asc", "last"); - - const nestedWorkspaces = sqlNestRelationships({ - data: workspaces, - key: "id", - parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), - childrenMapper: [ - { - key: "envId", - label: "environments" as const, - mapper: ({ envId: id, envSlug: slug, envName: name }) => ({ - id, - slug, - name - }) - } - ] - }); - - // We need to add the organization field, as it's required for one of our API endpoint responses. - return nestedWorkspaces.map((workspace) => ({ - ...workspace, - organization: workspace.orgId - })); - } catch (error) { - throw new DatabaseError({ error, name: "Find all projects by identity" }); - } - }; - const findProjectById = async (id: string) => { try { const workspaces = await db @@ -402,19 +342,26 @@ export const projectDALFactory = (db: TDbClient) => { projectIds?: string[]; }) => { const { limit = 20, offset = 0, sortBy = SearchProjectSortBy.NAME, sortDir = SortDirection.ASC } = dto; - - const userMembershipSubquery = db(TableName.ProjectMembership).where({ userId: dto.actorId }).select("projectId"); - const groups = db(TableName.UserGroupMembership).where({ userId: dto.actorId }).select("groupId"); - const groupMembershipSubquery = db(TableName.GroupProjectMembership).whereIn("groupId", groups).select("projectId"); - - const identityMembershipSubQuery = db(TableName.IdentityProjectMembership) - .where({ identityId: dto.actorId }) - .select("projectId"); + const groupMembershipSubquery = db(TableName.Groups) + .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`) + .where(`${TableName.Groups}.orgId`, dto.orgId) + .where(`${TableName.UserGroupMembership}.userId`, dto.actorId) + .select(db.ref("id").withSchema(TableName.Groups)); + const membershipSubQuery = db(TableName.Membership) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where((qb) => { + if (dto.actor === ActorType.IDENTITY) { + void qb.where(`${TableName.Membership}.actorIdentityId`, dto.actorId); + } else { + void qb + .where(`${TableName.Membership}.actorUserId`, dto.actorId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, groupMembershipSubquery); + } + }) + .select("scopeProjectId"); // Get the SQL strings for the subqueries - const userMembershipSql = userMembershipSubquery.toQuery(); - const groupMembershipSql = groupMembershipSubquery.toQuery(); - const identityMembershipSql = identityMembershipSubQuery.toQuery(); + const membershipSQL = membershipSubQuery.toQuery(); const query = db .replicaNode()(TableName.Project) @@ -422,26 +369,15 @@ export const projectDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.Project)) .select(db.raw("COUNT(*) OVER() AS count")) .select<(TProjects & { isMember: boolean; count: number })[]>( - dto.actor === ActorType.USER - ? db.raw( - ` - CASE - WHEN ${TableName.Project}.id IN (?) THEN TRUE - WHEN ${TableName.Project}.id IN (?) THEN TRUE - ELSE FALSE - END as "isMember" - `, - [db.raw(userMembershipSql), db.raw(groupMembershipSql)] - ) - : db.raw( - ` - CASE - WHEN ${TableName.Project}.id IN (?) THEN TRUE - ELSE FALSE - END as "isMember" - `, - [db.raw(identityMembershipSql)] - ) + db.raw( + ` + CASE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + ELSE FALSE + END as "isMember" + `, + [db.raw(membershipSQL)] + ) ) .limit(limit) .offset(offset); @@ -477,10 +413,12 @@ export const projectDALFactory = (db: TDbClient) => { const countOfOrgProjects = async (orgId: string | null, tx?: Knex) => { try { + const subOrgProjects = db.replicaNode()(TableName.Organization).where({ rootOrgId: orgId }).select("id"); + const doc = await (tx || db.replicaNode())(TableName.Project) .andWhere((bd) => { if (orgId) { - void bd.where({ orgId }); + void bd.where({ orgId }).orWhereIn("orgId", subOrgProjects); } }) .count(); @@ -495,7 +433,6 @@ export const projectDALFactory = (db: TDbClient) => { findUserProjects, findIdentityProjects, setProjectUpgradeStatus, - findAllProjectsByIdentity, findProjectGhostUser, findProjectById, findProjectByFilter, diff --git a/backend/src/services/project/project-queue.ts b/backend/src/services/project/project-queue.ts index e557b7c96..30ec6d29f 100644 --- a/backend/src/services/project/project-queue.ts +++ b/backend/src/services/project/project-queue.ts @@ -1,5 +1,6 @@ /* eslint-disable no-await-in-loop */ import { + AccessScope, IntegrationAuthsSchema, ProjectMembershipRole, ProjectUpgradeStatus, @@ -29,13 +30,13 @@ import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueJobTypes, TQueueServiceFactory } from "@app/queue"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; -import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -55,13 +56,13 @@ type TProjectQueueFactoryDep = { secretApprovalSecretDAL: Pick; projectBotDAL: Pick; orgService: Pick; - projectMembershipDAL: Pick; - projectUserMembershipRoleDAL: Pick; integrationAuthDAL: TIntegrationAuthDALFactory; userDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick; orgDAL: Pick; + membershipUserDAL: TMembershipUserDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; }; export const projectQueueFactory = ({ @@ -79,8 +80,8 @@ export const projectQueueFactory = ({ orgDAL, projectDAL, orgService, - projectMembershipDAL, - projectUserMembershipRoleDAL + membershipUserDAL, + membershipRoleDAL }: TProjectQueueFactoryDep) => { const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { @@ -227,17 +228,16 @@ export const projectQueueFactory = ({ ); // Create a membership for the ghost user - const projectMembership = await projectMembershipDAL.create( + const projectMembership = await membershipUserDAL.create( { - projectId: project.id, - userId: ghostUser.user.id + scopeProjectId: project.id, + scope: AccessScope.Project, + actorUserId: ghostUser.user.id, + scopeOrgId: project.orgId }, tx ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, - tx - ); + await membershipRoleDAL.create({ membershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, tx); // If a bot already exists, delete it if (existingBot) { @@ -272,8 +272,9 @@ export const projectQueueFactory = ({ for (const key of existingProjectKeys) { const user = await userDAL.findUserEncKeyByUserId(key.receiverId); const [orgMembership] = await orgDAL.findMembership({ - [`${TableName.OrgMembership}.userId` as "userId"]: key.receiverId, - [`${TableName.OrgMembership}.orgId` as "orgId"]: project.orgId + [`${TableName.Membership}.actorUserId` as "actorUserId"]: key.receiverId, + [`${TableName.Membership}.scopeOrgId` as "scopeOrgId"]: project.orgId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Organization }); if (!user) { diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 8dbba2b4f..e29f18404 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -3,7 +3,9 @@ import { PackRule, unpackRules } from "@casl/ability/extra"; import slugify from "@sindresorhus/slugify"; import { + AccessScope, ActionProjectType, + OrganizationActionScope, ProjectMembershipRole, ProjectType, ProjectVersion, @@ -49,11 +51,11 @@ import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { expandInternalCa } from "../certificate-authority/certificate-authority-fns"; import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; -import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; -import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; -import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipGroupDALFactory } from "../membership-group/membership-group-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { validateMicrosoftTeamsChannelsSchema } from "../microsoft-teams/microsoft-teams-fns"; import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal"; import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; @@ -65,10 +67,9 @@ import { TPkiCollectionDALFactory } from "../pki-collection/pki-collection-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; -import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { getPredefinedRoles } from "../project-role/project-role-fns"; import { TReminderServiceFactory } from "../reminder/reminder-types"; +import { TRoleDALFactory } from "../role/role-dal"; import { TSecretDALFactory } from "../secret/secret-dal"; import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -125,7 +126,6 @@ export const DEFAULT_PROJECT_ENVS = [ type TProjectServiceFactoryDep = { projectDAL: TProjectDALFactory; - identityProjectDAL: Pick; projectSshConfigDAL: Pick; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; @@ -134,13 +134,11 @@ type TProjectServiceFactoryDep = { secretDAL: Pick; secretV2BridgeDAL: Pick; projectEnvDAL: Pick; - identityOrgMembershipDAL: TIdentityOrgDALFactory; - identityProjectMembershipRoleDAL: Pick; - projectMembershipDAL: Pick< - TProjectMembershipDALFactory, - "create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers" - >; - groupProjectDAL: Pick; + projectMembershipDAL: Pick; + membershipUserDAL: Pick; + membershipGroupDAL: Pick; + membershipIdentityDAL: Pick; + membershipRoleDAL: Pick; projectSlackConfigDAL: Pick< TProjectSlackConfigDALFactory, "findOne" | "transaction" | "updateById" | "create" | "delete" @@ -154,7 +152,6 @@ type TProjectServiceFactoryDep = { TMicrosoftTeamsIntegrationDALFactory, "findById" | "findByIdWithWorkflowIntegrationDetails" >; - projectUserMembershipRoleDAL: Pick; pkiSubscriberDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; @@ -172,7 +169,7 @@ type TProjectServiceFactoryDep = { smtpService: Pick; orgDAL: Pick; keyStore: Pick; - projectRoleDAL: Pick; + roleDAL: Pick; kmsService: Pick< TKmsServiceFactory, | "updateProjectSecretManagerKmsKey" @@ -201,20 +198,15 @@ export const projectServiceFactory = ({ orgDAL, userDAL, folderDAL, - identityOrgMembershipDAL, projectMembershipDAL, projectEnvDAL, licenseService, - projectUserMembershipRoleDAL, - projectRoleDAL, certificateAuthorityDAL, certificateDAL, certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, pkiSubscriberDAL, - identityProjectDAL, - identityProjectMembershipRoleDAL, sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateDAL, @@ -228,10 +220,13 @@ export const projectServiceFactory = ({ slackIntegrationDAL, microsoftTeamsIntegrationDAL, projectTemplateService, - groupProjectDAL, smtpService, reminderService, - notificationService + notificationService, + membershipIdentityDAL, + membershipUserDAL, + membershipRoleDAL, + roleDAL }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -251,13 +246,14 @@ export const projectServiceFactory = ({ type = ProjectType.SecretManager }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - organization.id, + orgId: organization.id, actorAuthMethod, actorOrgId - ); + }); if ( permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace) && @@ -345,7 +341,7 @@ export const projectServiceFactory = ({ tx ); } - await projectRoleDAL.insertMany( + await roleDAL.insertMany( projectTemplate.packedRoles.map((role) => ({ ...role, permissions: JSON.stringify(role.permissions), @@ -374,15 +370,17 @@ export const projectServiceFactory = ({ } // Create a membership for the user - const userProjectMembership = await projectMembershipDAL.create( + const userProjectMembership = await membershipUserDAL.create( { - projectId: project.id, - userId: user.id + scopeProjectId: project.id, + actorUserId: user.id, + scope: AccessScope.Project, + scopeOrgId: project.orgId }, tx ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, + await membershipRoleDAL.create( + { membershipId: userProjectMembership.id, role: ProjectMembershipRole.Admin }, tx ); } @@ -390,10 +388,11 @@ export const projectServiceFactory = ({ // 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( + const identityOrgMembership = await membershipIdentityDAL.findOne( { - identityId: actorId, - orgId: project.orgId + actorIdentityId: actorId, + scopeOrgId: project.orgId, + scope: AccessScope.Organization }, tx ); @@ -405,17 +404,19 @@ export const projectServiceFactory = ({ }); } - const identityProjectMembership = await identityProjectDAL.create( + const identityProjectMembership = await membershipIdentityDAL.create( { - identityId: actorId, - projectId: project.id + actorIdentityId: actorId, + scopeProjectId: project.id, + scope: AccessScope.Project, + scopeOrgId: project.orgId }, tx ); - await identityProjectMembershipRoleDAL.create( + await membershipRoleDAL.create( { - projectMembershipId: identityProjectMembership.id, + membershipId: identityProjectMembership.id, role: ProjectMembershipRole.Admin }, tx @@ -459,8 +460,11 @@ export const projectServiceFactory = ({ const deletedProject = await projectDAL.transaction(async (tx) => { // delete these so that project custom roles can be deleted in cascade effect // direct deletion of project without these will cause fk error - await projectMembershipDAL.delete({ projectId: project.id }, tx); - await groupProjectDAL.delete({ projectId: project.id }, tx); + // this will clean up all memberships + await membershipUserDAL.delete( + { scopeOrgId: project.orgId, scopeProjectId: project.id, scope: AccessScope.Project }, + tx + ); const delProject = await projectDAL.deleteById(project.id, tx); const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(project.id, tx).catch(() => null); // akhilmhdh: before removing those kms checking any other project uses it @@ -511,22 +515,24 @@ export const projectServiceFactory = ({ : await projectDAL.findUserProjects(actorId, actorOrgId, type); if (includeRoles) { - const { permission } = await permissionService.getUserOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); - const customRoles = await projectRoleDAL.find({ + const customRoles = await roleDAL.find({ $in: { projectId: workspaces.map((workspace) => workspace.id) } }); - const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId); + const workspaceMappedToRoles = groupBy(customRoles, (role) => role.projectId as string); const workspacesWithRoles = await Promise.all( workspaces.map(async (workspace) => { @@ -1346,7 +1352,7 @@ export const projectServiceFactory = ({ }; const getProjectKmsKeys = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId }: TGetProjectKmsKey) => { - const { membership } = await permissionService.getProjectPermission({ + await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -1355,10 +1361,6 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.Any }); - if (!membership) { - throw new ForbiddenRequestError({ message: "You are not a member of this project" }); - } - const kmsKeyId = await kmsService.getProjectSecretManagerKmsKeyId(projectId); const kmsKey = await kmsService.getKmsById(kmsKeyId); @@ -1823,13 +1825,14 @@ export const projectServiceFactory = ({ projectIds }: TSearchProjectsDTO) => { // check user belong to org - await permissionService.getOrgPermission( - permission.type, - permission.id, - permission.orgId, - permission.authMethod, - permission.orgId - ); + await permissionService.getOrgPermission({ + actor: permission.type, + actorId: permission.id, + orgId: permission.orgId, + actorAuthMethod: permission.authMethod, + scope: OrganizationActionScope.Any, + actorOrgId: permission.orgId + }); return projectDAL.searchProjects({ limit, @@ -1847,13 +1850,14 @@ export const projectServiceFactory = ({ const requestProjectAccess = async ({ permission, comment, projectId }: TProjectAccessRequestDTO) => { // check user belong to org - await permissionService.getOrgPermission( - permission.type, - permission.id, - permission.orgId, - permission.authMethod, - permission.orgId - ); + await permissionService.getOrgPermission({ + actor: permission.type, + actorId: permission.id, + orgId: permission.orgId, + actorAuthMethod: permission.authMethod, + actorOrgId: permission.orgId, + scope: OrganizationActionScope.Any + }); const projectMember = await permissionService .getProjectPermission({ @@ -1875,7 +1879,7 @@ export const projectServiceFactory = ({ .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) .map((el) => el.user.email!); if (filteredProjectMembers.length === 0) { - const customRolesWithMemberCreate = await projectRoleDAL.find({ projectId }); + const customRolesWithMemberCreate = await roleDAL.find({ projectId }); const customRoleSlugsCanCreate = customRolesWithMemberCreate .filter((role) => { try { diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 74c7e95f4..18ae74350 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -41,7 +41,7 @@ export type TCreateProjectDTO = { actor: ActorType; actorAuthMethod: ActorAuthMethod; actorId: string; - actorOrgId?: string; + actorOrgId: string; projectName: string; projectDescription?: string; slug?: string; diff --git a/backend/src/services/role/namespace/namespace-role-factory.ts b/backend/src/services/role/namespace/namespace-role-factory.ts new file mode 100644 index 000000000..87921c25b --- /dev/null +++ b/backend/src/services/role/namespace/namespace-role-factory.ts @@ -0,0 +1,47 @@ +import { AccessScope } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError } from "@app/lib/errors"; + +import { TRoleScopeFactory } from "../role-types"; + +type TNamespaceRoleScopeFactoryDep = { + permissionService: Pick; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const newNamespaceRoleFactory = (_dto: TNamespaceRoleScopeFactoryDep): TRoleScopeFactory => { + const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = async () => {}; + + const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = async () => {}; + + const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = async () => {}; + + const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = async () => {}; + + const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = async () => {}; + + const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = async () => {}; + + const getScopeField: TRoleScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + const isCustomRole: TRoleScopeFactory["isCustomRole"] = () => false; + + const getPredefinedRoles: TRoleScopeFactory["getPredefinedRoles"] = async () => []; + + return { + onCreateRoleGuard, + onUpdateRoleGuard, + onDeleteRoleGuard, + onListRoleGuard, + onGetRoleByIdGuard, + onGetRoleBySlugGuard, + getScopeField, + isCustomRole, + getPredefinedRoles + }; +}; diff --git a/backend/src/services/role/org/org-role-factory.ts b/backend/src/services/role/org/org-role-factory.ts new file mode 100644 index 000000000..f91dabccb --- /dev/null +++ b/backend/src/services/role/org/org-role-factory.ts @@ -0,0 +1,166 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; +import { + orgAdminPermissions, + orgMemberPermissions, + orgNoAccessPermissions, + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError } from "@app/lib/errors"; +import { TExternalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; + +import { TRoleScopeFactory } from "../role-types"; + +type TOrgRoleScopeFactoryDep = { + permissionService: Pick; + externalGroupOrgRoleMappingDAL: Pick; +}; + +export const newOrgRoleFactory = ({ + permissionService, + externalGroupOrgRoleMappingDAL +}: TOrgRoleScopeFactoryDep): TRoleScopeFactory => { + const getScopeField: TRoleScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + const isCustomRole: TRoleScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Role); + }; + + const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); + }; + + const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); + + const externalGroupMapping = await externalGroupOrgRoleMappingDAL.findOne({ + orgId: dto.permission.orgId, + roleId: dto.selector.id + }); + + if (externalGroupMapping) + throw new BadRequestError({ + message: + "Cannot delete role assigned to external group organization role mapping. Please re-assign external mapping and try again." + }); + }; + + const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + }; + + const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + }; + + const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + }; + + const getPredefinedRoles: TRoleScopeFactory["getPredefinedRoles"] = async (scopeData) => { + const scopeField = getScopeField(scopeData); + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + name: "Admin", + slug: "admin", + orgId: scopeField.value, + description: "Complete administration access over the organization", + permissions: orgAdminPermissions, + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + name: "Member", + slug: "member", + orgId: scopeField.value, + description: "Non-administrative role in an organization", + permissions: orgMemberPermissions, + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b10d49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + name: "No Access", + slug: "no-access", + orgId: scopeField.value, + description: "No access to any resources in the organization", + permissions: orgNoAccessPermissions, + createdAt: new Date(), + updatedAt: new Date() + } + ]; + }; + + return { + onCreateRoleGuard, + onUpdateRoleGuard, + onDeleteRoleGuard, + onListRoleGuard, + onGetRoleByIdGuard, + onGetRoleBySlugGuard, + getScopeField, + getPredefinedRoles, + isCustomRole + }; +}; diff --git a/backend/src/services/role/project/project-role-factory.ts b/backend/src/services/role/project/project-role-factory.ts new file mode 100644 index 000000000..2ab4e59d0 --- /dev/null +++ b/backend/src/services/role/project/project-role-factory.ts @@ -0,0 +1,203 @@ +import { ForbiddenError } from "@casl/ability"; +import { v4 as uuidv4 } from "uuid"; + +import { AccessScope, ActionProjectType, ProjectMembershipRole, ProjectType } from "@app/db/schemas"; +import { + cryptographicOperatorPermissions, + projectAdminPermissions, + projectMemberPermissions, + projectNoAccessPermissions, + projectViewerPermission, + sshHostBootstrapPermissions +} from "@app/ee/services/permission/default-roles"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + isCustomProjectRole, + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TRoleScopeFactory } from "../role-types"; + +type TProjectRoleScopeFactoryDep = { + permissionService: Pick; + projectDAL: Pick; +}; + +export const newProjectRoleFactory = ({ + permissionService, + projectDAL +}: TProjectRoleScopeFactoryDep): TRoleScopeFactory => { + const getScopeField: TRoleScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + const isCustomRole: TRoleScopeFactory["isCustomRole"] = (role: string) => isCustomProjectRole(role); + + const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Role); + }; + + const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); + }; + + const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); + }; + + const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + }; + + const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + }; + + const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + }; + + const getPredefinedRoles: TRoleScopeFactory["getPredefinedRoles"] = async (scopeData) => { + const scope = getScopeField(scopeData); + const project = await projectDAL.findById(scope.value); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + return [ + { + id: uuidv4(), + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date(), + projectId + }, + { + id: uuidv4(), + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date(), + projectId + }, + { + id: uuidv4(), + name: "SSH Host Bootstrapper", + slug: ProjectMembershipRole.SshHostBootstrapper, + permissions: sshHostBootstrapPermissions, + description: "Create and issue SSH Hosts in a project", + createdAt: new Date(), + updatedAt: new Date(), + projectId, + type: ProjectType.SSH + }, + { + id: uuidv4(), + name: "Cryptographic Operator", + slug: ProjectMembershipRole.KmsCryptographicOperator, + permissions: cryptographicOperatorPermissions, + description: "Perform cryptographic operations, such as encryption and signing, in a project", + createdAt: new Date(), + updatedAt: new Date(), + projectId, + type: ProjectType.KMS + }, + { + id: uuidv4(), + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + projectId, + updatedAt: new Date() + }, + { + id: uuidv4(), + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + projectId, + updatedAt: new Date() + } + ].filter(({ type }) => (type ? type === project.type : true)); + }; + + return { + onCreateRoleGuard, + onUpdateRoleGuard, + onDeleteRoleGuard, + onListRoleGuard, + onGetRoleByIdGuard, + onGetRoleBySlugGuard, + getScopeField, + getPredefinedRoles, + isCustomRole + }; +}; diff --git a/backend/src/services/role/role-dal.ts b/backend/src/services/role/role-dal.ts new file mode 100644 index 000000000..9f3d7cf61 --- /dev/null +++ b/backend/src/services/role/role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TRoleDALFactory = ReturnType; + +export const roleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Role); + return orm; +}; diff --git a/backend/src/services/role/role-service.ts b/backend/src/services/role/role-service.ts new file mode 100644 index 000000000..3387dc96b --- /dev/null +++ b/backend/src/services/role/role-service.ts @@ -0,0 +1,277 @@ +import { packRules } from "@casl/ability/extra"; +import { requestContext } from "@fastify/request-context"; + +import { AccessScope, ActionProjectType, OrganizationActionScope, TableName } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; +import { UnpackedPermissionSchema, unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; + +import { ActorType } from "../auth/auth-type"; +import { TExternalGroupOrgRoleMappingDALFactory } from "../external-group-org-role-mapping/external-group-org-role-mapping-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { newNamespaceRoleFactory } from "./namespace/namespace-role-factory"; +import { newOrgRoleFactory } from "./org/org-role-factory"; +import { newProjectRoleFactory } from "./project/project-role-factory"; +import { TRoleDALFactory } from "./role-dal"; +import { + TCreateRoleDTO, + TDeleteRoleDTO, + TGetRoleByIdDTO, + TGetRoleBySlugDTO, + TGetUserPermissionDTO, + TListRoleDTO, + TUpdateRoleDTO +} from "./role-types"; + +type TRoleServiceFactoryDep = { + roleDAL: TRoleDALFactory; + identityDAL: Pick; + userDAL: Pick; + permissionService: Pick; + projectDAL: Pick; + externalGroupOrgRoleMappingDAL: Pick; +}; + +export type TRoleServiceFactory = ReturnType; + +export const roleServiceFactory = ({ + roleDAL, + permissionService, + projectDAL, + identityDAL, + userDAL, + externalGroupOrgRoleMappingDAL +}: TRoleServiceFactoryDep) => { + const orgRoleFactory = newOrgRoleFactory({ + permissionService, + externalGroupOrgRoleMappingDAL + }); + const projectRoleFactory = newProjectRoleFactory({ + permissionService, + projectDAL + }); + const namespaceRoleFactory = newNamespaceRoleFactory({ + permissionService + }); + const scopeFactory = { + [AccessScope.Organization]: orgRoleFactory, + [AccessScope.Project]: projectRoleFactory, + [AccessScope.Namespace]: namespaceRoleFactory + }; + + const createRole = async (dto: TCreateRoleDTO) => { + const { data, scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onCreateRoleGuard(dto); + + const scope = factory.getScopeField(scopeData); + const existingRole = await roleDAL.findOne({ + slug: data.slug, + [scope.key]: scope.value + }); + if (existingRole) throw new NotFoundError({ message: `Role with ${data.slug} exists` }); + + validateHandlebarTemplate("Role Creation", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + + const role = await roleDAL.create({ + name: data.name, + description: data.description, + slug: data.slug, + permissions: data.permissions, + [scope.key]: scope.value + }); + + return { ...role, [scope.key]: scope.value, permissions: unpackPermissions(role.permissions) }; + }; + + const updateRole = async (dto: TUpdateRoleDTO) => { + const { data, scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + const scope = factory.getScopeField(scopeData); + + await factory.onUpdateRoleGuard(dto); + + const existingRole = await roleDAL.findOne({ + id: dto.selector.id, + [scope.key]: scope.value + }); + if (!existingRole) throw new NotFoundError({ message: `Role with ${dto.selector.id} not found` }); + + if (data.slug) { + const existingSlug = await roleDAL.findOne({ + slug: data.slug, + [scope.key]: scope.value + }); + if (existingSlug && existingRole.id !== existingSlug.id) + throw new BadRequestError({ message: `Role with ${data.slug} already exists` }); + } + + validateHandlebarTemplate("Role Update", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + + const role = await roleDAL.updateById(existingRole.id, { + name: data?.name, + description: data?.description, + slug: data?.slug, + permissions: data?.permissions + }); + + return { ...role, [scope.key]: scope.value, permissions: unpackPermissions(role.permissions) }; + }; + + const deleteRole = async (dto: TDeleteRoleDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + const scope = factory.getScopeField(scopeData); + await factory.onDeleteRoleGuard(dto); + + const existingRole = await roleDAL.findOne({ + id: dto.selector.id, + [scope.key]: scope.value + }); + if (!existingRole) throw new NotFoundError({ message: `Role with ${dto.selector.id} not found` }); + + const [role] = await roleDAL.delete({ + id: existingRole.id, + [scope.key]: scope.value + }); + + return { ...role, [scope.key]: scope.value, permissions: unpackPermissions(role.permissions) }; + }; + + const listRoles = async (dto: TListRoleDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListRoleGuard(dto); + + const scope = factory.getScopeField(scopeData); + const predefinedRoles = await factory.getPredefinedRoles(scopeData); + const roles = await roleDAL.find( + { + [scope.key]: scope.value + }, + { limit: dto.data.limit, offset: dto.data.offset, sort: [[`${TableName.Role}.slug` as "slug", "asc"]] } + ); + + return { + roles: [...predefinedRoles, ...roles.map((el) => ({ ...el, permissions: unpackPermissions(el.permissions) }))] + }; + }; + + const getRoleById = async (dto: TGetRoleByIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetRoleByIdGuard(dto); + + const scope = factory.getScopeField(scopeData); + + const predefinedRole = await factory.getPredefinedRoles(scopeData); + const selectedRole = predefinedRole.find((el) => el.id === dto.selector.id); + if (selectedRole) { + return { ...selectedRole, permissions: UnpackedPermissionSchema.array().parse(selectedRole.permissions) }; + } + + const role = await roleDAL.findOne({ + id: selector.id, + [scope.key]: scope.value + }); + if (!role) throw new NotFoundError({ message: `Role with id ${dto.selector.id} not found` }); + + return { ...role, [scope.key]: scope.value, permissions: unpackPermissions(role.permissions) }; + }; + + const getRoleBySlug = async (dto: TGetRoleBySlugDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetRoleBySlugGuard(dto); + + const scope = factory.getScopeField(scopeData); + const isCustomRole = factory.isCustomRole(dto.selector.slug); + if (!isCustomRole) { + const predefinedRole = await factory.getPredefinedRoles(scopeData); + const selectedRole = predefinedRole.find((el) => el.slug === dto.selector.slug); + if (!selectedRole) throw new BadRequestError({ message: `Role with slug ${dto.selector.slug} not found` }); + return { ...selectedRole, permissions: UnpackedPermissionSchema.array().parse(selectedRole.permissions) }; + } + + const role = await roleDAL.findOne({ + slug: selector.slug, + [scope.key]: scope.value + }); + if (!role) throw new NotFoundError({ message: `Role with slug ${dto.selector.slug} not found` }); + + return { ...role, [scope.key]: scope.value, permissions: unpackPermissions(role.permissions) }; + }; + + const getUserPermission = async (dto: TGetUserPermissionDTO) => { + if (dto.scopeData.scope === AccessScope.Organization) { + const { permission, memberships } = await permissionService.getOrgPermission({ + actorId: dto.permission.id, + actor: dto.permission.type, + orgId: dto.permission.orgId, + actorOrgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + scope: OrganizationActionScope.Any + }); + return { permissions: packRules(permission.rules), memberships, assumedPrivilegeDetails: undefined }; + } + + if (dto.scopeData.scope === AccessScope.Project) { + const { permission, memberships } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: dto.scopeData.projectId, + actorOrgId: dto.permission.orgId + }); + + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + const isAssumingPrivilege = assumedPrivilegeDetailsCtx?.projectId === dto.scopeData.projectId; + const assumedPrivilegeDetails = isAssumingPrivilege + ? { + actorId: assumedPrivilegeDetailsCtx?.actorId, + actorType: assumedPrivilegeDetailsCtx?.actorType, + actorName: "", + actorEmail: "" + } + : undefined; + + if (assumedPrivilegeDetails?.actorType === ActorType.IDENTITY) { + const identityDetails = await identityDAL.findById(assumedPrivilegeDetails.actorId); + if (!identityDetails) + throw new NotFoundError({ message: `Identity with ID ${assumedPrivilegeDetails.actorId} not found` }); + assumedPrivilegeDetails.actorName = identityDetails.name; + } else if (assumedPrivilegeDetails?.actorType === ActorType.USER) { + const userDetails = await userDAL.findById(assumedPrivilegeDetails?.actorId); + if (!userDetails) + throw new NotFoundError({ message: `User with ID ${assumedPrivilegeDetails.actorId} not found` }); + assumedPrivilegeDetails.actorName = `${userDetails?.firstName} ${userDetails?.lastName || ""}`; + assumedPrivilegeDetails.actorEmail = userDetails?.email || ""; + } + + return { permissions: packRules(permission.rules), memberships, assumedPrivilegeDetails }; + } + + throw new BadRequestError({ message: "Invalid scope defined" }); + }; + + return { + createRole, + updateRole, + deleteRole, + listRoles, + getRoleById, + getRoleBySlug, + getUserPermission + }; +}; diff --git a/backend/src/services/role/role-types.ts b/backend/src/services/role/role-types.ts new file mode 100644 index 000000000..96fc9c0f3 --- /dev/null +++ b/backend/src/services/role/role-types.ts @@ -0,0 +1,79 @@ +import { MongoAbility, RawRuleOf } from "@casl/ability"; + +import { AccessScopeData, TRoles } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TRoleScopeFactory { + onCreateRoleGuard: (arg: TCreateRoleDTO) => Promise; + onUpdateRoleGuard: (arg: TUpdateRoleDTO) => Promise; + onDeleteRoleGuard: (arg: TDeleteRoleDTO) => Promise; + onListRoleGuard: (arg: TListRoleDTO) => Promise; + getPredefinedRoles: (arg: AccessScopeData) => Promise<(TRoles & { permissions: RawRuleOf[] })[]>; + onGetRoleByIdGuard: (arg: TGetRoleByIdDTO) => Promise; + onGetRoleBySlugGuard: (arg: TGetRoleBySlugDTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; + isCustomRole: (role: string) => boolean; +} + +export type TCreateRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + name: string; + description?: string | null; + slug: string; + permissions: unknown; + }; +}; + +export type TUpdateRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + }; + data: Partial<{ + name: string; + description?: string | null; + slug: string; + permissions: unknown; + }>; +}; + +export type TListRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + limit?: number; + offset?: number; + }; +}; + +export type TDeleteRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + }; +}; + +export type TGetRoleByIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + }; +}; + +export type TGetRoleBySlugDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + slug: string; + }; +}; + +export type TGetUserPermissionDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; +}; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 8e0892bd1..c216ea3a8 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -5,6 +5,7 @@ import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { ActionProjectType, TProjectEnvironments, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; +import { TDynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -12,6 +13,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { PgSqlLock } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; import { @@ -47,7 +49,11 @@ type TSecretFolderServiceFactoryDep = { folderCommitService: Pick; projectDAL: Pick; secretApprovalPolicyService: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + "findByFolderIds" | "invalidateSecretCacheByProjectId" | "findOne" + >; + dynamicSecretDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -61,7 +67,8 @@ export const secretFolderServiceFactory = ({ folderCommitService, projectDAL, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -111,24 +118,11 @@ export const secretFolderServiceFactory = ({ }); } - // check if the exact folder already exists - const existingFolder = await folderDAL.findOne( - { - envId: env.id, - parentId: parentFolder.id, - name, - isReserved: false - }, - tx - ); - - if (existingFolder) { - return existingFolder; - } - // exact folder case if (parentFolder.path === pathWithFolder) { - return parentFolder; + throw new BadRequestError({ + message: `Folder with name '${name}' already exists in path '${secretPath}'` + }); } let currentParentId = parentFolder.id; @@ -534,13 +528,19 @@ export const secretFolderServiceFactory = ({ projectId, env, parentId, - idOrName + idOrName, + actor }: { projectId: string; env: TProjectEnvironments; parentId: string; idOrName: string; + actor: ActorType; }) => { + if (actor === ActorType.IDENTITY) { + return; + } + let targetFolder = await folderDAL .findOne({ envId: env.id, @@ -638,7 +638,8 @@ export const secretFolderServiceFactory = ({ actorAuthMethod, environment, path: secretPath, - idOrName + idOrName, + forceDelete = false }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -664,7 +665,7 @@ export const secretFolderServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ @@ -690,6 +691,22 @@ export const secretFolderServiceFactory = ({ throw new NotFoundError({ message: `Folder with ID '${idOrName}' not found` }); } + // Check if folder contains resources (secrets, dynamic secrets, subfolders) + if (!forceDelete) { + const error = new BadRequestError({ + message: `Cannot delete folder "${folderToDelete.name}" because it contains resources. Use forceDelete=true to delete it forcefully.`, + name: "deleteFolder" + }); + const secretV2 = await secretV2BridgeDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (secretV2) throw error; + + const dynamicSecret = await dynamicSecretDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (dynamicSecret) throw error; + + const subfolder = await folderDAL.findOne({ parentId: folderToDelete.id }).catch(() => null); + if (subfolder) throw error; + } + const [doc] = await folderDAL.delete( { envId: env.id, @@ -1315,7 +1332,7 @@ export const secretFolderServiceFactory = ({ }); } - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index eed815da5..da8be52a0 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -37,6 +37,7 @@ export type TDeleteFolderDTO = { environment: string; path: string; idOrName: string; + forceDelete?: boolean; } & TProjectPermission; export type TGetFolderDTO = { diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index c739c5ad2..42c135c88 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -258,10 +258,6 @@ export const fnSecretsV2FromImports = async ({ })[]; }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; - const processedSecretImports = await processReservedImports(rootSecretImports, secretImportDAL); - - stack[0] = { secretImports: processedSecretImports, depth: 0, parentImportedSecrets: [] }; - const processedImports: TSecretImportSecretsV2[] = []; while (stack.length) { @@ -299,7 +295,9 @@ export const fnSecretsV2FromImports = async ({ ); const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); - sanitizedImports.forEach(({ importPath, importEnv }) => { + const processedBatchImports = await processReservedImports(sanitizedImports, secretImportDAL); + + processedBatchImports.forEach(({ importPath, importEnv }) => { cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); }); // now we need to check recursively deeper imports made inside other imports @@ -308,7 +306,7 @@ export const fnSecretsV2FromImports = async ({ const deeperImportsGroupByFolderId = groupBy(deeperImports, (i) => i.folderId); const isFirstIteration = !processedImports.length; - sanitizedImports.forEach(({ importPath, importEnv, id, folderId }, i) => { + processedBatchImports.forEach(({ importPath, importEnv, id, folderId }, i) => { const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0]; const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) .filter((item) => diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 30cd93291..36f3459cf 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -385,7 +385,7 @@ export const secretImportServiceFactory = ({ path: secretPath, id: secretImportDocId }: TResyncSecretImportReplicationDTO) => { - const { permission, membership } = await permissionService.getProjectPermission({ + const { permission, memberships } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -437,7 +437,7 @@ export const secretImportServiceFactory = ({ secretImportDoc.importPath ); - if (membership && sourceFolder) { + if (memberships?.length && sourceFolder) { await secretQueueService.replicateSecrets({ orgId: actorOrgId, secretPath: secretImportDoc.importPath, diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts index ec4a3f807..384de47e8 100644 --- a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { AccessScope, TableName } from "@app/db/schemas"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TSecretReminderRecipientsDALFactory = ReturnType; @@ -14,13 +14,13 @@ export const secretReminderRecipientsDALFactory = (db: TDbClient) => { .where({ secretId }) .leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.Project, `${TableName.SecretReminderRecipients}.projectId`, `${TableName.Project}.id`) - .leftJoin(TableName.OrgMembership, (bd) => { + .leftJoin(TableName.Membership, (bd) => { void bd - .on(`${TableName.OrgMembership}.userId`, "=", `${TableName.SecretReminderRecipients}.userId`) - .andOn(`${TableName.OrgMembership}.orgId`, "=", `${TableName.Project}.orgId`); + .on(`${TableName.Membership}.actorUserId`, "=", `${TableName.SecretReminderRecipients}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, "=", `${TableName.Project}.orgId`) + .andOn(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Organization])); }) - - .where(`${TableName.OrgMembership}.isActive`, true) + .where(`${TableName.Membership}.isActive`, true) .select(selectAllTableCols(TableName.SecretReminderRecipients)) .select( db.ref("email").withSchema(TableName.Users).as("email"), diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 4cbfcdc7f..87dd207f1 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,4 +1,4 @@ -import { TSecretSharing } from "@app/db/schemas"; +import { OrganizationActionScope, TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -81,12 +81,21 @@ export const secretSharingServiceFactory = ({ }: TCreateSharedSecretDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); $validateSharedSecretExpiry(expiresAt); - const org = await orgDAL.findOrgById(orgId); - if (!org.allowSecretSharingOutsideOrganization && accessType === SecretSharingAccessType.Anyone) { + const rootOrg = await orgDAL.findRootOrgDetails(orgId); + if (!rootOrg) throw new BadRequestError({ message: `Organization with id ${orgId} not found` }); + + if (!rootOrg.allowSecretSharingOutsideOrganization && accessType === SecretSharingAccessType.Anyone) { throw new BadRequestError({ message: "Organization does not allow sharing secrets to members outside of this organization" }); @@ -100,13 +109,16 @@ export const secretSharingServiceFactory = ({ const expiresAtTimestamp = new Date(expiresAt).getTime(); const lifetime = expiresAtTimestamp - new Date().getTime(); - // org.maxSharedSecretLifetime is in seconds - if (org.maxSharedSecretLifetime && lifetime / 1000 > org.maxSharedSecretLifetime) { + // rootOrg.maxSharedSecretLifetime is in seconds + if (rootOrg.maxSharedSecretLifetime && lifetime / 1000 > rootOrg.maxSharedSecretLifetime) { throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" }); } // Check max view count is within org allowance - if (org.maxSharedSecretViewLimit && (!expiresAfterViews || expiresAfterViews > org.maxSharedSecretViewLimit)) { + if ( + rootOrg.maxSharedSecretViewLimit && + (!expiresAfterViews || expiresAfterViews > rootOrg.maxSharedSecretViewLimit) + ) { throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" }); } @@ -122,7 +134,10 @@ export const secretSharingServiceFactory = ({ if (allOrgMembers.some((v) => v.user.email === email)) { orgEmails.push(email); // If the email is not part of the org, but access type / org settings require it - } else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) { + } else if ( + !rootOrg.allowSecretSharingOutsideOrganization || + accessType === SecretSharingAccessType.Organization + ) { throw new BadRequestError({ message: "Organization does not allow sharing secrets to members outside of this organization" }); @@ -196,7 +211,14 @@ export const secretSharingServiceFactory = ({ actorAuthMethod, actorOrgId }: TCreateSecretRequestDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); $validateSharedSecretExpiry(expiresAt); @@ -228,7 +250,14 @@ export const secretSharingServiceFactory = ({ throw new NotFoundError({ message: `Secret request with ID '${id}' not found` }); } - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); if (secretRequest.userId !== actorId || secretRequest.orgId !== orgId) { @@ -267,13 +296,14 @@ export const secretSharingServiceFactory = ({ throw new UnauthorizedError(); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - secretRequest.orgId, + orgId: secretRequest.orgId, actorAuthMethod, actorOrgId - ); + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); } @@ -316,13 +346,14 @@ export const secretSharingServiceFactory = ({ throw new UnauthorizedError(); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - secretRequest.orgId, + orgId: secretRequest.orgId, actorAuthMethod, actorOrgId - ); + }); if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); const user = await userDAL.findById(actorId); @@ -415,13 +446,14 @@ export const secretSharingServiceFactory = ({ }: TGetSharedSecretsDTO) => { if (!actorOrgId) throw new ForbiddenRequestError(); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, actorOrgId - ); + }); if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" }); const secrets = await secretSharingDAL.find( @@ -563,7 +595,14 @@ export const secretSharingServiceFactory = ({ const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }); if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" }); const sharedSecret = isUuidV4(sharedSecretId) diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 4b174ca2a..e7116d604 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -211,10 +211,18 @@ export const GithubSyncFns = { } const { connection } = secretSync; - const token = - connection.method === GitHubConnectionMethod.OAuth - ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + let token: string; + + switch (connection.method) { + case GitHubConnectionMethod.OAuth: + token = connection.credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = connection.credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + } const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token); @@ -269,10 +277,18 @@ export const GithubSyncFns = { const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); const { connection } = secretSync; - const token = - connection.method === GitHubConnectionMethod.OAuth - ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + let token: string; + + switch (connection.method) { + case GitHubConnectionMethod.OAuth: + token = connection.credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = connection.credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + } const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); diff --git a/backend/src/services/secret-sync/laravel-forge/index.ts b/backend/src/services/secret-sync/laravel-forge/index.ts new file mode 100644 index 000000000..f38e2a06b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-sync-constants"; +export * from "./laravel-forge-sync-fns"; +export * from "./laravel-forge-sync-schemas"; +export * from "./laravel-forge-sync-types"; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts new file mode 100644 index 000000000..7bde155ec --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const LARAVEL_FORGE_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Laravel Forge", + destination: SecretSync.LaravelForge, + connection: AppConnection.LaravelForge, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts new file mode 100644 index 000000000..bbb0f354e --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts @@ -0,0 +1,207 @@ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { + LaravelForgeSecret, + TGetLaravelForgeSecrets, + TLaravelForgeSecrets, + TLaravelForgeSyncWithCredentials +} from "./laravel-forge-sync-types"; + +const getLaravelForgeSecretsRaw = async ({ apiToken, orgSlug, serverId, siteId }: TGetLaravelForgeSecrets) => { + const { data } = await request.get( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + + return data.data.attributes.content; +}; + +const parseEnv = (str: string) => { + const lines = str.split("\n"); + const parsed: { key: string; value: string }[] = []; + + let i = 0; + while (i < lines.length) { + const trimmed = lines[i].trim(); + + // Skip empty lines and comments + if (trimmed === "" || trimmed.startsWith("#")) { + i += 1; + // eslint-disable-next-line no-continue + continue; + } + + if (trimmed.includes("=")) { + const equalIndex = trimmed.indexOf("="); + const key = trimmed.substring(0, equalIndex).trim(); + const valueRaw = trimmed.substring(equalIndex + 1).trim(); + + // Check if value starts with a quote + const startsWithDoubleQuote = valueRaw.startsWith('"'); + const startsWithSingleQuote = valueRaw.startsWith("'"); + + if (startsWithDoubleQuote || startsWithSingleQuote) { + const quoteChar = startsWithDoubleQuote ? '"' : "'"; + + const closingQuoteIndex = valueRaw.indexOf(quoteChar, 1); + + if (closingQuoteIndex !== -1) { + // Single-line quoted value + const value = valueRaw.slice(1, closingQuoteIndex); + parsed.push({ key, value }); + i += 1; + } else { + // Multiline quoted value - collect lines until closing quote + let value = valueRaw.slice(1); + i += 1; + + while (i < lines.length) { + const nextLine = lines[i]; + const closingIndex = nextLine.indexOf(quoteChar); + + if (closingIndex !== -1) { + value += `\n${nextLine.substring(0, closingIndex)}`; + parsed.push({ key, value }); + i += 1; + break; + } else { + value += `\n${nextLine}`; + i += 1; + } + } + } + } else { + // Unquoted value + parsed.push({ key, value: valueRaw }); + i += 1; + } + } else { + i += 1; + } + } + + return parsed; +}; + +const getLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials): Promise => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + const secrets = await getLaravelForgeSecretsRaw({ apiToken, orgSlug, serverId, siteId }); + + const parsedSecrets = parseEnv(secrets); + + return parsedSecrets; +}; + +const buildEnvString = (secrets: LaravelForgeSecret[]) => { + if (secrets.length === 0) { + return "# .env"; + } + + return secrets + .map((secret) => { + const { value } = secret; + + if (value.includes(`"`)) { + return `${secret.key}='${value}'`; + } + + if (value.includes(" ") || value.includes("\n") || value.includes(`'`)) { + return `${secret.key}="${value}"`; + } + return `${secret.key}=${value}`; + }) + .join("\n"); +}; + +const updateLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials, envString: string) => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + await request.put( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + environment: envString + }, + + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); +}; + +export const LaravelForgeSyncFns = { + async syncSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const secrets = await getLaravelForgeSecrets(secretSync); + + // Create a map of the existing secrets + const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value])); + + for (const [key, { value }] of Object.entries(secretMap)) { + // Add the new secrets to the map + updatedSecretsMap.set(key, value); + } + + if (!disableSecretDeletion) { + secrets.forEach((secret) => { + if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return; + + if (!secretMap[secret.key]) { + updatedSecretsMap.delete(secret.key); + } + }); + } + + const updatedSecrets = Array.from(updatedSecretsMap.entries()).map(([key, value]) => ({ key, value })); + + const envString = buildEnvString(updatedSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + }, + + async getSecrets(secretSync: TLaravelForgeSyncWithCredentials): Promise { + const secrets = await getLaravelForgeSecrets(secretSync); + return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }])); + }, + + async removeSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const existingSecrets = await getLaravelForgeSecrets(secretSync); + + const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key)); + + if (newSecrets.length === existingSecrets.length) { + return; + } + + const envString = buildEnvString(newSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + } +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts new file mode 100644 index 000000000..168ebde3b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts @@ -0,0 +1,68 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const slugValidator = (val: string) => { + return new RE2("^[a-z0-9.-]+$").test(val) && !new RE2(".[-]$").test(val); +}; + +const LaravelForgeSyncDestinationConfigSchema = z.object({ + orgSlug: z + .string() + .min(1, "Org Slug is required") + .max(512, "Org Slug cannot exceed 512 characters") + .refine( + (val) => slugValidator(val), + "Org Slug can only contain lowercase letters, numbers, dots, and dashes, and cannot end with a dot or dash." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgSlug), + orgName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgName), + serverId: z + .string() + .min(1, "Server ID is required") + .refine((val) => !Number.isNaN(Number(val)), "Server ID must be a valid integer") + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverId), + serverName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverName), + siteId: z.string().min(1, "Site ID is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteId), + siteName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteName) +}); + +const LaravelForgeSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const LaravelForgeSyncSchema = BaseSecretSyncSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const CreateLaravelForgeSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const UpdateLaravelForgeSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema.optional() +}); + +export const LaravelForgeSyncListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + connection: z.literal(AppConnection.LaravelForge), + destination: z.literal(SecretSync.LaravelForge), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts new file mode 100644 index 000000000..faa5dffa4 --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts @@ -0,0 +1,41 @@ +import z from "zod"; + +import { TLaravelForgeConnection } from "@app/services/app-connection/laravel-forge"; + +import { + CreateLaravelForgeSyncSchema, + LaravelForgeSyncListItemSchema, + LaravelForgeSyncSchema +} from "./laravel-forge-sync-schemas"; + +export type TLaravelForgeSyncListItem = z.infer; + +export type TLaravelForgeSync = z.infer; + +export type TLaravelForgeSyncInput = z.infer; + +export type TLaravelForgeSyncWithCredentials = TLaravelForgeSync & { + connection: TLaravelForgeConnection; +}; + +export type TGetLaravelForgeSecrets = { + apiToken: string; + orgSlug: string; + serverId: string; + siteId: string; +}; + +export type TLaravelForgeSecrets = { + data: { + id: string; + type: string; + attributes: { + content: string; + }; + }; +}; + +export type LaravelForgeSecret = { + key: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/railway/railway-sync-fns.ts b/backend/src/services/secret-sync/railway/railway-sync-fns.ts index 07862aeb5..731691acf 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-fns.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-fns.ts @@ -12,6 +12,8 @@ export const RailwaySyncFns = { async getSecrets(secretSync: TRailwaySyncWithCredentials): Promise { try { const config = secretSync.destinationConfig; + const { keySchema } = secretSync.syncOptions; + const { environment } = secretSync; const variables = await RailwayPublicAPI.getVariables(secretSync.connection, { projectId: config.projectId, @@ -26,6 +28,10 @@ export const RailwaySyncFns = { // eslint-disable-next-line no-continue if (key.startsWith("RAILWAY_")) continue; + // Check if key matches the schema + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + entries[key] = { value }; @@ -40,60 +46,73 @@ export const RailwaySyncFns = { } }, + /** + * Syncs secrets to Railway and redeploys the service if needed. + * + * Gets existing Railway vars, merges with new secrets (keeping Railway vars if deletion is disabled), + * then replaces every variable with the new values, if variable is not in the secretMap, it is deleted. + * If there's a service, triggers a redeploy to pick up the changes. + */ async syncSecrets(secretSync: TRailwaySyncWithCredentials, secretMap: TSecretMap) { - const { - environment, - syncOptions: { disableSecretDeletion, keySchema } - } = secretSync; - const railwaySecrets = await this.getSecrets(secretSync); - const config = secretSync.destinationConfig; + try { + const { + syncOptions: { disableSecretDeletion } + } = secretSync; + const railwaySecrets = await this.getSecrets(secretSync); + const config = secretSync.destinationConfig; - for await (const key of Object.keys(secretMap)) { - try { - const existing = railwaySecrets[key]; + const railwaySecretsMap = Object.fromEntries( + Object.entries(railwaySecrets).map(([key, secret]) => [key, secret.value]) + ); + const secretMapMap = Object.fromEntries(Object.entries(secretMap).map(([key, secret]) => [key, secret.value])); - if (existing === undefined || existing.value !== secretMap[key].value) { - await RailwayPublicAPI.upsertVariable(secretSync.connection, { - input: { - projectId: config.projectId, - environmentId: config.environmentId, - serviceId: config.serviceId || undefined, - name: key, - value: secretMap[key].value ?? "" - } - }); + const toReplace = disableSecretDeletion ? { ...railwaySecretsMap, ...secretMapMap } : secretMapMap; + + const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + skipDeploys: true, + variables: toReplace, + replace: true } - } catch (error) { + }); + + if (!upserted) throw new SecretSyncError({ - error, - secretKey: key + message: "Failed to upsert secrets to Railway" }); - } - } - if (disableSecretDeletion) return; + if (!config.serviceId) return; - for await (const key of Object.keys(railwaySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + const latestDeployment = await RailwayPublicAPI.getDeployments(secretSync.connection, { + input: { + serviceId: config.serviceId, + environmentId: config.environmentId + }, + first: 1 + }); - if (!secretMap[key]) { - await RailwayPublicAPI.deleteVariable(secretSync.connection, { - input: { - projectId: config.projectId, - environmentId: config.environmentId, - serviceId: config.serviceId || undefined, - name: key - } - }); + const latestDeploymentId = latestDeployment?.deployments.edges[0].node.id; + + if (!latestDeploymentId) + throw new SecretSyncError({ + message: "Failed to get latest deployment from Railway" + }); + + await RailwayPublicAPI.redeployDeployment(secretSync.connection, { + input: { + deploymentId: latestDeploymentId } - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); - } + }); + } catch (error) { + if (error instanceof SecretSyncError) throw error; + + throw new SecretSyncError({ + error, + message: "Failed to sync secrets to Railway" + }); } }, @@ -101,24 +120,37 @@ export const RailwaySyncFns = { const existing = await this.getSecrets(secretSync); const config = secretSync.destinationConfig; - for await (const secret of Object.keys(existing)) { - try { - if (secret in secretMap) { - await RailwayPublicAPI.deleteVariable(secretSync.connection, { - input: { - projectId: config.projectId, - environmentId: config.environmentId, - serviceId: config.serviceId || undefined, - name: secret - } - }); + // Create a new variables object excluding secrets that exist in secretMap + const remainingVariables = Object.fromEntries( + Object.entries(existing) + .filter(([key]) => !(key in secretMap)) + .map(([key, secret]) => [key, secret.value]) + ); + + try { + const upserted = await RailwayPublicAPI.upsertCollection(secretSync.connection, { + input: { + projectId: config.projectId, + environmentId: config.environmentId, + serviceId: config.serviceId || undefined, + skipDeploys: true, + variables: remainingVariables, + replace: true } - } catch (error) { + }); + + if (!upserted) { throw new SecretSyncError({ - error, - secretKey: secret + message: "Failed to remove secrets from Railway" }); } + } catch (error) { + if (error instanceof SecretSyncError) throw error; + + throw new SecretSyncError({ + error, + message: "Failed to remove secrets from Railway" + }); } } }; diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index 57c6581ce..64d2c0bdb 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -204,5 +204,19 @@ export const secretSyncDALFactory = ( } }; - return { ...secretSyncOrm, findById, findOne, find, create, updateById }; + const findByDestinationAndOrgId = async (destination: string, orgId: string, tx?: Knex) => { + try { + const response = await (tx || db.replicaNode())(TableName.SecretSync) + .join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.SecretSync}.destination`, destination) + .where(`${TableName.Project}.orgId`, orgId) + .select(selectAllTableCols(TableName.SecretSync)); + + return response; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Destination And Org ID - Secret Sync" }); + } + }; + + return { ...secretSyncOrm, findById, findOne, find, create, updateById, findByDestinationAndOrgId }; }; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index fe0dc9f56..235b3db3a 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index a6faebd1e..85fc27250 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -49,6 +49,8 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; +import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; @@ -91,7 +93,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, - [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION + [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, + [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -277,6 +280,8 @@ export const SecretSyncFns = { return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -393,6 +398,9 @@ export const SecretSyncFns = { case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; + case SecretSync.LaravelForge: + secretMap = await LaravelForgeSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -486,6 +494,8 @@ export const SecretSyncFns = { return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 04e91051d..0ec8aede0 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -1,5 +1,6 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; +import { DestinationDuplicateCheckFn } from "@app/services/secret-sync/secret-sync-types"; export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AWSParameterStore]: "AWS Parameter Store", @@ -31,7 +32,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", - [SecretSync.Bitbucket]: "Bitbucket" + [SecretSync.Bitbucket]: "Bitbucket", + [SecretSync.LaravelForge]: "Laravel Forge" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -64,7 +66,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -97,5 +100,109 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, - [SecretSync.Bitbucket]: SecretSyncPlanType.Regular + [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, + [SecretSync.LaravelForge]: SecretSyncPlanType.Regular +}; + +export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { + [SecretSync.AWSParameterStore]: [], + [SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"], + [SecretSync.GitHub]: [], + [SecretSync.GCPSecretManager]: [], + [SecretSync.AzureKeyVault]: [], + [SecretSync.AzureAppConfiguration]: ["label"], + [SecretSync.AzureDevOps]: ["devopsProjectName"], + [SecretSync.Databricks]: [], + [SecretSync.Humanitec]: [], + [SecretSync.TerraformCloud]: ["variableSetName", "workspaceName"], + [SecretSync.Camunda]: [], + [SecretSync.Vercel]: ["appName"], + [SecretSync.Windmill]: [], + [SecretSync.HCVault]: [], + [SecretSync.TeamCity]: [], + [SecretSync.OCIVault]: [], + [SecretSync.OnePass]: ["valueLabel"], + [SecretSync.Heroku]: ["appName"], + [SecretSync.Render]: [], + [SecretSync.Flyio]: [], + [SecretSync.GitLab]: [ + "projectName", + "shouldProtectSecrets", + "shouldMaskSecrets", + "shouldHideSecrets", + "targetEnvironment", + "groupName", + "groupId", + "projectId" + ], + [SecretSync.CloudflarePages]: [], + [SecretSync.CloudflareWorkers]: [], + [SecretSync.Supabase]: ["projectName"], + [SecretSync.Zabbix]: ["hostName", "macroType"], + [SecretSync.Railway]: ["projectName", "environmentName", "serviceName"], + [SecretSync.Checkly]: ["groupName", "accountName"], + [SecretSync.DigitalOceanAppPlatform]: ["appName"], + [SecretSync.Netlify]: ["accountName", "siteName"], + [SecretSync.Bitbucket]: [], + [SecretSync.LaravelForge]: [] +}; + +const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; + +export const DESTINATION_DUPLICATE_CHECK_MAP: Record = { + [SecretSync.AWSParameterStore]: defaultDuplicateCheck, + [SecretSync.AWSSecretsManager]: defaultDuplicateCheck, + [SecretSync.GitHub]: defaultDuplicateCheck, + [SecretSync.GCPSecretManager]: defaultDuplicateCheck, + [SecretSync.AzureKeyVault]: defaultDuplicateCheck, + [SecretSync.AzureAppConfiguration]: defaultDuplicateCheck, + [SecretSync.AzureDevOps]: defaultDuplicateCheck, + [SecretSync.Databricks]: defaultDuplicateCheck, + [SecretSync.Humanitec]: defaultDuplicateCheck, + [SecretSync.TerraformCloud]: defaultDuplicateCheck, + [SecretSync.Camunda]: defaultDuplicateCheck, + [SecretSync.Vercel]: defaultDuplicateCheck, + [SecretSync.Windmill]: defaultDuplicateCheck, + [SecretSync.HCVault]: defaultDuplicateCheck, + [SecretSync.TeamCity]: defaultDuplicateCheck, + [SecretSync.OCIVault]: defaultDuplicateCheck, + [SecretSync.OnePass]: defaultDuplicateCheck, + [SecretSync.Heroku]: defaultDuplicateCheck, + [SecretSync.Render]: defaultDuplicateCheck, + [SecretSync.Flyio]: defaultDuplicateCheck, + [SecretSync.GitLab]: (existingConfig, newConfig) => { + const existingTargetEnv = existingConfig.targetEnvironment as string | undefined; + const newTargetEnv = newConfig.targetEnvironment as string | undefined; + + const wildcardValues = ["*", ""]; + + if ( + (newConfig.scope as string) === "group" + ? existingConfig.groupId !== newConfig.groupId + : existingConfig.projectId !== newConfig.projectId + ) + return false; + + // If either has wildcard, it conflicts with any targetEnvironment + if ( + !existingTargetEnv || + !newTargetEnv || + wildcardValues.includes(existingTargetEnv) || + wildcardValues.includes(newTargetEnv) + ) { + return true; + } + + return existingTargetEnv === newTargetEnv; + }, + [SecretSync.CloudflarePages]: defaultDuplicateCheck, + [SecretSync.CloudflareWorkers]: defaultDuplicateCheck, + [SecretSync.Supabase]: defaultDuplicateCheck, + [SecretSync.Zabbix]: defaultDuplicateCheck, + [SecretSync.Railway]: defaultDuplicateCheck, + [SecretSync.Checkly]: defaultDuplicateCheck, + [SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck, + [SecretSync.Netlify]: defaultDuplicateCheck, + [SecretSync.Bitbucket]: defaultDuplicateCheck, + [SecretSync.LaravelForge]: defaultDuplicateCheck }; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 63faf3b03..e83a0033f 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -11,6 +11,7 @@ import { KeyStorePrefixes, 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 { SecretNameSchema } from "@app/server/lib/schemas"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -408,6 +409,24 @@ export const secretSyncQueueFactory = ({ if (!Object.keys(importedSecrets).length) return {}; + let invalidNameCount = 0; + let errorMessage = ""; + + for (const [key] of Object.entries(importedSecrets)) { + const result = SecretNameSchema.safeParse(key); + if (!result.success) { + invalidNameCount += 1; + if (errorMessage === "") errorMessage = result.error.issues[0]?.message; + } + } + + if (invalidNameCount > 0) { + throw new SecretSyncError({ + message: `Found ${invalidNameCount} invalid secret name${invalidNameCount === 1 ? "" : "s"}. ${errorMessage}`, + shouldRetry: false + }); + } + const importedSecretMap: TSecretMap = {}; const secretMap = await $getInfisicalSecrets(secretSync, false); diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index ecd7d04a5..6a2f49386 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -12,6 +12,7 @@ import { import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { deepEqualSkipFields } from "@app/lib/fn/object"; import { OrgServiceActor } from "@app/lib/types"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -20,6 +21,7 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncStatus, + TCheckDuplicateDestinationDTO, TCreateSecretSyncDTO, TDeleteSecretSyncDTO, TFindSecretSyncByIdDTO, @@ -35,7 +37,12 @@ import { import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretSyncDALFactory } from "./secret-sync-dal"; -import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps"; +import { + DESTINATION_DUPLICATE_CHECK_MAP, + SECRET_SYNC_CONNECTION_MAP, + SECRET_SYNC_NAME_MAP, + SECRET_SYNC_SKIP_FIELDS_MAP +} from "./secret-sync-maps"; import { TSecretSyncQueueFactory } from "./secret-sync-queue"; type TSecretSyncServiceFactoryDep = { @@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; + const checkDuplicateDestination = async ( + { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, + actor: OrgServiceActor + ) => { + const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + if (!destinationConfig || Object.keys(destinationConfig).length === 0) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + + try { + const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); + + const duplicates = existingSyncs.filter((sync) => { + if (sync.id === excludeSyncId) { + return false; + } + + try { + const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); + if (baseFieldsMatch) { + return DESTINATION_DUPLICATE_CHECK_MAP[destination]( + sync.destinationConfig as Record, + destinationConfig + ); + } + return false; + } catch { + return false; + } + }); + + const hasDuplicate = duplicates.length > 0; + return { + hasDuplicate, + duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined + }; + } catch (error) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + }; + return { listSecretSyncOptions, listSecretSyncsByProjectId, @@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({ deleteSecretSync, triggerSecretSyncSyncSecretsById, triggerSecretSyncImportSecretsById, - triggerSecretSyncRemoveSecretsById + triggerSecretSyncRemoveSecretsById, + checkDuplicateDestination }; }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 6435e19d3..9719f0a9e 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -117,6 +117,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TLaravelForgeSync, + TLaravelForgeSyncInput, + TLaravelForgeSyncListItem, + TLaravelForgeSyncWithCredentials +} from "./laravel-forge"; import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify"; import { TRailwaySync, @@ -164,6 +170,7 @@ export type TSecretSync = | TTerraformCloudSync | TCamundaSync | TVercelSync + | TLaravelForgeSync | TWindmillSync | THCVaultSync | TTeamCitySync @@ -212,7 +219,8 @@ export type TSecretSyncWithCredentials = | TSupabaseSyncWithCredentials | TDigitalOceanAppPlatformSyncWithCredentials | TNetlifySyncWithCredentials - | TBitbucketSyncWithCredentials; + | TBitbucketSyncWithCredentials + | TLaravelForgeSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -244,7 +252,8 @@ export type TSecretSyncInput = | TSupabaseSyncInput | TDigitalOceanAppPlatformSyncInput | TNetlifySyncInput - | TBitbucketSyncInput; + | TBitbucketSyncInput + | TLaravelForgeSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -259,6 +268,7 @@ export type TSecretSyncListItem = | TTerraformCloudSyncListItem | TCamundaSyncListItem | TVercelSyncListItem + | TLaravelForgeSyncListItem | TWindmillSyncListItem | THCVaultSyncListItem | TTeamCitySyncListItem @@ -324,6 +334,13 @@ export type TDeleteSecretSyncDTO = { removeSecrets: boolean; }; +export type TCheckDuplicateDestinationDTO = { + destination: SecretSync; + destinationConfig: Record; + excludeSyncId?: string; + projectId: string; +}; + export enum SecretSyncStatus { Pending = "pending", Running = "running", @@ -408,3 +425,8 @@ export type TSecretMap = Record< secretMetadata?: ResourceMetadataDTO; } >; + +export type DestinationDuplicateCheckFn = ( + existingConfig: Record, + newConfig: Record +) => boolean; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index de8f930ed..579b19b19 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -2395,8 +2395,8 @@ export const secretV2BridgeServiceFactory = ({ projectId: folder.projectId, secretVersions: secretVersionsFilter, findOpt: { - offset, limit, + offset, sort: [["createdAt", "desc"]] } }); diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index 0282fa537..7d25dac86 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -2,7 +2,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { SecretVersionsV2Schema, TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; +import { + AccessScope, + SecretVersionsV2Schema, + TableName, + TSecretVersionsV2, + TSecretVersionsV2Update +} from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; @@ -191,11 +197,11 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt; const query = (tx || db.replicaNode())(TableName.SecretVersionV2) .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) - .leftJoin( - TableName.ProjectMembership, - `${TableName.ProjectMembership}.userId`, - `${TableName.SecretVersionV2}.userActorId` - ) + .leftJoin(TableName.Membership, (qb) => { + void qb + .on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) + .andOn(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project])); + }) .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) .leftJoin( @@ -210,19 +216,19 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { ) .where((qb) => { void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); - void qb.where(`${TableName.ProjectMembership}.projectId`, projectId); + void qb.where(`${TableName.Membership}.scopeProjectId`, projectId); if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions); }) .orWhere((qb) => { void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); - void qb.whereNull(`${TableName.ProjectMembership}.projectId`); + void qb.whereNull(`${TableName.Membership}.scopeProjectId`); if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions); }) .select( selectAllTableCols(TableName.SecretVersionV2), db.ref("username").withSchema(TableName.Users).as("userActorName"), db.ref("name").withSchema(TableName.Identity).as("identityActorName"), - db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), + db.ref("id").withSchema(TableName.Membership).as("membershipId"), db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index ed794a664..61507d127 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -4,6 +4,7 @@ import { AxiosError } from "axios"; import { Knex } from "knex"; import { + AccessScope, ProjectMembershipRole, ProjectType, ProjectUpgradeStatus, @@ -43,6 +44,8 @@ import { TIntegrationAuthServiceFactory } from "../integration-auth/integration- import { syncIntegrationSecrets } from "../integration-auth/integration-sync-secret"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { TMembershipDALFactory } from "../membership/membership-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TProjectDALFactory } from "../project/project-dal"; import { createProjectKey } from "../project/project-fns"; @@ -50,7 +53,6 @@ import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; -import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TReminderServiceFactory } from "../reminder/reminder-types"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; @@ -92,7 +94,9 @@ type TSecretQueueFactoryDep = { projectDAL: TProjectDALFactory; projectBotDAL: TProjectBotDALFactory; projectKeyDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; + membershipUserDAL: Pick; + membershipRoleDAL: Pick; smtpService: TSmtpService; secretVersionDAL: TSecretVersionDALFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; @@ -110,7 +114,6 @@ type TSecretQueueFactoryDep = { keyStore: Pick; auditLogService: Pick; orgService: Pick; - projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; folderCommitService: Pick; secretSyncQueue: Pick; @@ -173,14 +176,15 @@ export const secretQueueFactory = ({ keyStore, auditLogService, orgService, - projectUserMembershipRoleDAL, projectKeyDAL, resourceMetadataDAL, secretSyncQueue, folderCommitService, reminderService, eventBusService, - licenseService + licenseService, + membershipUserDAL, + membershipRoleDAL }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -1165,17 +1169,16 @@ export const secretQueueFactory = ({ // if project v1 create the project ghost user if (project.version === ProjectVersion.V1) { const ghostUser = await orgService.addGhostUser(project.orgId, tx); - const projectMembership = await projectMembershipDAL.create( + const projectMembership = await membershipUserDAL.create( { - userId: ghostUser.user.id, - projectId: project.id + actorUserId: ghostUser.user.id, + scopeOrgId: project.orgId, + scope: AccessScope.Project, + scopeProjectId: project.id }, tx ); - await projectUserMembershipRoleDAL.create( - { projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, - tx - ); + await membershipRoleDAL.create({ membershipId: projectMembership.id, role: ProjectMembershipRole.Admin }, tx); const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({ publicKey: ghostUser.keys.publicKey, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 3a5ccc667..723cd368c 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1283,7 +1283,8 @@ export const secretServiceFactory = ({ }); const { userPermissions, identityPermissions, groupPermissions } = await permissionService.getProjectPermissions( - dto.projectId + dto.projectId, + dto.actorOrgId ); const attachAllowedActions = ( diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 2aa495673..081b99208 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -14,6 +14,7 @@ import { logger } from "@app/lib/logger"; import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue"; import { ActorType } from "../auth/auth-type"; +import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; @@ -29,6 +30,7 @@ import { type TServiceTokenServiceFactoryDep = { serviceTokenDAL: TServiceTokenDALFactory; userDAL: TUserDALFactory; + orgDAL: Pick; permissionService: Pick; projectEnvDAL: Pick; projectDAL: Pick; @@ -45,7 +47,8 @@ export const serviceTokenServiceFactory = ({ projectEnvDAL, projectDAL, accessTokenQueue, - smtpService + smtpService, + orgDAL }: TServiceTokenServiceFactoryDep) => { const createServiceToken = async ({ iv, @@ -184,7 +187,15 @@ export const serviceTokenServiceFactory = ({ if (!isMatch) throw new UnauthorizedError({ message: "Invalid service token" }); await accessTokenQueue.updateServiceTokenStatus(serviceToken.id); - return { ...serviceToken, lastUsed: new Date(), orgId: project.orgId }; + const serviceTokenOrgDetails = await orgDAL.findById(project.orgId); + + return { + ...serviceToken, + lastUsed: new Date(), + orgId: project.orgId, + parentOrgId: serviceTokenOrgDetails.parentOrgId || serviceTokenOrgDetails.id, + rootOrgId: serviceTokenOrgDetails.rootOrgId || serviceTokenOrgDetails.id + }; }; const notifyExpiringTokens = async () => { diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index c8aa8aaf6..e4110ac11 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { InstallProvider } from "@slack/oauth"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { getConfig } from "@app/lib/config/env"; @@ -230,13 +231,14 @@ export const slackServiceFactory = ({ }: TGetSlackInstallUrlDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -264,13 +266,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -293,13 +296,14 @@ export const slackServiceFactory = ({ actorOrgId, actorAuthMethod }: TGetSlackIntegrationByOrgDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); @@ -324,13 +328,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -351,13 +356,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); @@ -389,13 +395,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); @@ -432,13 +439,14 @@ export const slackServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - slackIntegration.orgId, + orgId: slackIntegration.orgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); diff --git a/backend/src/services/smtp/emails/AccountDeletionConfirmationTemplate.tsx b/backend/src/services/smtp/emails/AccountDeletionConfirmationTemplate.tsx new file mode 100644 index 000000000..ff4cb7de9 --- /dev/null +++ b/backend/src/services/smtp/emails/AccountDeletionConfirmationTemplate.tsx @@ -0,0 +1,36 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface AccountDeletionConfirmationTemplateProps + extends Omit { + email: string; +} + +export const AccountDeletionConfirmationTemplate = ({ email, siteUrl }: AccountDeletionConfirmationTemplateProps) => { + return ( + + + Account Deleted + +
+ + This email confirms that your Infisical account {email} has been deleted, including all + associated data. + +
+
+ ); +}; + +export default AccountDeletionConfirmationTemplate; + +AccountDeletionConfirmationTemplate.PreviewProps = { + email: "test@infisical.com", + siteUrl: "https://infisical.com" +} as AccountDeletionConfirmationTemplateProps; diff --git a/backend/src/services/smtp/emails/HealthAlertTemplate.tsx b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx new file mode 100644 index 000000000..46b919f11 --- /dev/null +++ b/backend/src/services/smtp/emails/HealthAlertTemplate.tsx @@ -0,0 +1,47 @@ +import { Heading, Section, Text } from "@react-email/components"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface HealthAlertTemplateProps extends Omit { + type: "gateway" | "relay" | "instance-relay"; + names: string; +} + +export const HealthAlertTemplate = ({ siteUrl, names, type }: HealthAlertTemplateProps) => { + return ( + + + {type === "gateway" ? "Gateway" : "Relay"} Health Alert + +
+ + The following {type}(s) in your organization may be offline as they haven't reported a + heartbeat in over an hour: {names}. + + + {type === "instance-relay" && ( + <> + If the issue persists, you can contact the Infisical team at{" "} + support@infisical.com. + + )} + {type === "relay" && <>Please contact your relay administrators.} + {type === "gateway" && <>Please contact your gateway administrators.} + +
+
+ ); +}; + +export default HealthAlertTemplate; + +HealthAlertTemplate.PreviewProps = { + type: "gateway", + names: '"gateway1", "gateway2"', + siteUrl: "https://infisical.com" +} as HealthAlertTemplateProps; diff --git a/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx new file mode 100644 index 000000000..da93fc045 --- /dev/null +++ b/backend/src/services/smtp/emails/SubOrganizationInvitationTemplate.tsx @@ -0,0 +1,50 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SubOrganizationInvitationTemplateProps extends Omit { + callback_url: string; + subOrganizationName: string; +} + +export const SubOrganizationInvitationTemplate = ({ + callback_url, + subOrganizationName, + siteUrl +}: SubOrganizationInvitationTemplateProps) => { + return ( + + + You've been invited to join a sub-organization on Infisical + +
+ + You've been invited to join the sub-organization {subOrganizationName}. + +
+
+ Join Sub-Organization +
+
+ + About Infisical: Infisical is an all-in-one platform to securely manage application secrets, + certificates, SSH keys, and configurations across your team and infrastructure. + +
+
+ ); +}; + +export default SubOrganizationInvitationTemplate; + +SubOrganizationInvitationTemplate.PreviewProps = { + subOrganizationName: "Example Project", + siteUrl: "https://infisical.com", + callback_url: "https://app.infisical.com" +} as SubOrganizationInvitationTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 066744596..692cacbaf 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -1,10 +1,12 @@ export * from "./AccessApprovalRequestTemplate"; export * from "./AccessApprovalRequestUpdatedTemplate"; +export * from "./AccountDeletionConfirmationTemplate"; export * from "./EmailMfaTemplate"; export * from "./EmailVerificationTemplate"; export * from "./ExternalImportFailedTemplate"; export * from "./ExternalImportStartedTemplate"; export * from "./ExternalImportSucceededTemplate"; +export * from "./HealthAlertTemplate"; export * from "./IntegrationSyncFailedTemplate"; export * from "./NewDeviceLoginTemplate"; export * from "./OAuthPasswordResetTemplate"; @@ -29,4 +31,5 @@ export * from "./SecretScanningSecretsDetectedTemplate"; export * from "./SecretSyncFailedTemplate"; export * from "./ServiceTokenExpiryNoticeTemplate"; export * from "./SignupEmailVerificationTemplate"; +export * from "./SubOrganizationInvitationTemplate"; export * from "./UnlockAccountTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index d64582fe0..cef22009a 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -9,11 +9,13 @@ import { logger } from "@app/lib/logger"; import { AccessApprovalRequestTemplate, AccessApprovalRequestUpdatedTemplate, + AccountDeletionConfirmationTemplate, EmailMfaTemplate, EmailVerificationTemplate, ExternalImportFailedTemplate, ExternalImportStartedTemplate, ExternalImportSucceededTemplate, + HealthAlertTemplate, IntegrationSyncFailedTemplate, NewDeviceLoginTemplate, OAuthPasswordResetTemplate, @@ -38,6 +40,7 @@ import { SecretSyncFailedTemplate, ServiceTokenExpiryNoticeTemplate, SignupEmailVerificationTemplate, + SubOrganizationInvitationTemplate, UnlockAccountTemplate } from "./emails"; @@ -63,6 +66,7 @@ export enum SmtpTemplates { // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", + SubOrgInvite = "subOrganizationInvitation", OrgAssignment = "organizationAssignment", OAuthPasswordReset = "oAuthPasswordReset", ResetPassword = "passwordReset", @@ -83,7 +87,9 @@ export enum SmtpTemplates { OrgAdminBreakglassAccess = "orgAdminBreakglassAccess", ServiceTokenExpired = "serviceTokenExpired", SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", - SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected" + SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected", + AccountDeletionConfirmation = "accountDeletionConfirmation", + HealthAlert = "healthAlert" } export enum SmtpHost { @@ -98,6 +104,7 @@ export enum SmtpHost { // eslint-disable-next-line @typescript-eslint/no-explicit-any const EmailTemplateMap: Record> = { [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, + [SmtpTemplates.SubOrgInvite]: SubOrganizationInvitationTemplate, [SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, @@ -128,7 +135,9 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, - [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate + [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate, + [SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate, + [SmtpTemplates.HealthAlert]: HealthAlertTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index b43e4e0a2..63bab2666 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,6 +1,7 @@ import { CronJob } from "cron"; import { + AccessScope, IdentityAuthMethod, OrgMembershipRole, OrgMembershipStatus, @@ -29,7 +30,6 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; -import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TIdentityTokenAuthDALFactory } from "../identity-token-auth/identity-token-auth-dal"; @@ -37,10 +37,12 @@ import { KMS_ROOT_CONFIG_UUID } from "../kms/kms-fns"; import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { RootKeyEncryptionStrategy } from "../kms/kms-types"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; -import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; @@ -64,16 +66,17 @@ type TSuperAdminServiceFactoryDep = { identityDAL: TIdentityDALFactory; identityTokenAuthDAL: TIdentityTokenAuthDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; - identityOrgMembershipDAL: TIdentityOrgDALFactory; orgDAL: TOrgDALFactory; - orgMembershipDAL: TOrgMembershipDALFactory; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; + membershipUserDAL: TMembershipUserDALFactory; + membershipIdentityDAL: TMembershipIdentityDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; userAliasDAL: Pick; authService: Pick; kmsService: Pick; kmsRootConfigDAL: TKmsRootConfigDALFactory; - orgService: Pick; + orgService: Pick; keyStore: Pick; licenseService: Pick; microsoftTeamsService: Pick; @@ -127,7 +130,6 @@ export const superAdminServiceFactory = ({ userDAL, identityDAL, orgDAL, - orgMembershipDAL, userAliasDAL, authService, orgService, @@ -137,11 +139,13 @@ export const superAdminServiceFactory = ({ licenseService, identityAccessTokenDAL, identityTokenAuthDAL, - identityOrgMembershipDAL, microsoftTeamsService, invalidateCacheQueue, smtpService, - tokenService + tokenService, + membershipIdentityDAL, + membershipUserDAL, + membershipRoleDAL }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -588,11 +592,18 @@ export const superAdminServiceFactory = ({ }); const { identity, credentials } = await identityDAL.transaction(async (tx) => { - const newIdentity = await identityDAL.create({ name: "Instance Admin Identity" }, tx); - await identityOrgMembershipDAL.create( + const newIdentity = await identityDAL.create({ name: "Instance Admin Identity", orgId: organization.id }, tx); + const membership = await membershipIdentityDAL.create( { - identityId: newIdentity.id, - orgId: organization.id, + actorIdentityId: newIdentity.id, + scopeOrgId: organization.id, + scope: AccessScope.Organization + }, + tx + ); + await membershipRoleDAL.create( + { + membershipId: membership.id, role: OrgMembershipRole.Admin }, tx @@ -741,13 +752,12 @@ export const superAdminServiceFactory = ({ }; const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => { - const organizations = await orgDAL.findOrganizationsByFilter({ + return orgDAL.findOrganizationsByFilter({ offset, searchTerm, sortBy: "name", limit }); - return organizations; }; const createOrganization = async ( @@ -835,18 +845,26 @@ export const superAdminServiceFactory = ({ }); } - await orgDAL.createMembership( + const membership = await orgDAL.createMembership( { - userId: inviteeUser.id, + actorUserId: inviteeUser.id, + scope: AccessScope.Organization, inviteEmail: inviteeEmail, - orgId: org.id, - role: OrgMembershipRole.Admin, + scopeOrgId: org.id, status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, isActive: true }, tx ); + await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); + users.push(inviteeUser); } @@ -915,23 +933,32 @@ export const superAdminServiceFactory = ({ actorType: ActorType ) => { if (actorType === ActorType.USER) { - const orgMembership = await orgMembershipDAL.findById(membershipId); + const orgMembership = await membershipUserDAL.findOne({ + scope: AccessScope.Organization, + id: membershipId, + scopeOrgId: organizationId + }); if (!orgMembership) { throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); } - if (orgMembership.userId === actorId) { + if (orgMembership.actorUserId === actorId) { throw new BadRequestError({ message: "You cannot remove yourself from the organization from the instance management panel." }); } } - const [organizationMembership] = await orgMembershipDAL.delete({ - orgId: organizationId, + const membershipRole = await membershipRoleDAL.findOne({ membershipId }); + if (!membershipRole) { + throw new NotFoundError({ name: "Membership Role", message: "Membership role not found" }); + } + const [organizationMembership] = await membershipUserDAL.delete({ + scopeOrgId: organizationId, + scope: AccessScope.Organization, id: membershipId }); - return organizationMembership; + return { ...organizationMembership, role: membershipRole.role, orgId: organizationId }; }; const joinOrganization = async (orgId: string, actor: OrgServiceActor) => { @@ -947,25 +974,46 @@ export const superAdminServiceFactory = ({ throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` }); } - const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId }); + const existingOrgMembership = await membershipUserDAL.findOne({ + actorUserId: serverAdmin.id, + scopeOrgId: org.id, + scope: AccessScope.Organization + }); if (existingOrgMembership) { throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` }); } - const orgMembership = await orgDAL.createMembership({ - userId: serverAdmin.id, - orgId: org.id, - role: OrgMembershipRole.Admin, - status: OrgMembershipStatus.Accepted, - isActive: true + const orgMembership = await orgDAL.transaction(async (tx) => { + const membership = await orgDAL.createMembership( + { + actorUserId: serverAdmin.id, + scopeOrgId: org.id, + status: OrgMembershipStatus.Accepted, + isActive: true, + scope: AccessScope.Organization + }, + tx + ); + const membershipRole = await membershipRoleDAL.create( + { + membershipId: membership.id, + role: OrgMembershipRole.Admin + }, + tx + ); + return { ...membership, role: membershipRole.role, orgId: org.id }; }); return orgMembership; }; const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => { - const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId }); + const orgMembership = await membershipUserDAL.findOne({ + id: membershipId, + scopeOrgId: organizationId, + scope: AccessScope.Organization + }); if (!orgMembership) { throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); @@ -977,7 +1025,7 @@ export const superAdminServiceFactory = ({ }); } - if (!orgMembership.userId) { + if (!orgMembership.actorUserId) { throw new NotFoundError({ message: "Cannot find user associated with Org Membership." }); } @@ -985,15 +1033,15 @@ export const superAdminServiceFactory = ({ throw new BadRequestError({ message: "No invite email associated with user." }); } - const org = await orgDAL.findOrgById(orgMembership.orgId); + const org = await orgDAL.findOrgById(orgMembership.scopeOrgId); const appCfg = getConfig(); const serverAdmin = await userDAL.findById(actor.id); const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_ORG_INVITATION, - userId: orgMembership.userId, - orgId: orgMembership.orgId + userId: orgMembership.actorUserId, + orgId: orgMembership.scopeOrgId }); await smtpService.sendMail({ @@ -1005,17 +1053,17 @@ export const superAdminServiceFactory = ({ inviterUsername: serverAdmin?.email, organizationName: org?.name, email: orgMembership.inviteEmail, - organizationId: orgMembership.orgId, + organizationId: orgMembership.scopeOrgId, token, callback_url: `${appCfg.SITE_URL}/signupinvite` } }); - return orgMembership; + return { ...orgMembership, orgId: organizationId, role: "" }; }; const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { - const identities = await identityDAL.getIdentitiesByFilter({ + const result = await identityDAL.getIdentitiesByFilter({ limit, offset, searchTerm, @@ -1023,10 +1071,13 @@ export const superAdminServiceFactory = ({ }); const serverCfg = await getServerCfg(); - return identities.map((identity) => ({ - ...identity, - isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id)) - })); + return { + identities: result.identities.map((identity) => ({ + ...identity, + isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id)) + })), + total: result.total + }; }; const grantServerAdminAccessToUser = async (userId: string) => { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 4267d13ee..41b615b5d 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + AccessScope, TableName, TUserActionsInsert, TUserActionsUpdate, @@ -60,11 +61,20 @@ export const userDALFactory = (db: TDbClient) => { query = query.where("superAdmin", true); } + const countQuery = query.clone(); + if (sortBy) { query = query.orderBy(sortBy); } - return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)); + const [users, totalResult] = await Promise.all([ + query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)), + countQuery.count("*", { as: "count" }).first() + ]); + + const total = Number(totalResult?.count || 0); + + return { users, total }; } catch (error) { throw new DatabaseError({ error, name: "Get users by filter" }); } @@ -118,9 +128,13 @@ export const userDALFactory = (db: TDbClient) => { const findUserByProjectMembershipId = async (projectMembershipId: string) => { try { return await db - .replicaNode()(TableName.ProjectMembership) - .where({ [`${TableName.ProjectMembership}.id` as "id"]: projectMembershipId }) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .where({ + [`${TableName.Membership}.id` as "id"]: projectMembershipId, + [`${TableName.Membership}.scope` as "scope"]: AccessScope.Project + }) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .first(); } catch (error) { throw new DatabaseError({ error, name: "Find user by project membership id" }); @@ -130,9 +144,11 @@ export const userDALFactory = (db: TDbClient) => { const findUsersByProjectMembershipIds = async (projectMembershipIds: string[]) => { try { return await db - .replicaNode()(TableName.ProjectMembership) - .whereIn(`${TableName.ProjectMembership}.id`, projectMembershipIds) - .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .replicaNode()(TableName.Membership) + .whereIn(`${TableName.Membership}.id`, projectMembershipIds) + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) .select("*"); } catch (error) { throw new DatabaseError({ error, name: "Find users by project membership ids" }); @@ -182,8 +198,10 @@ export const userDALFactory = (db: TDbClient) => { try { const doc = await db(TableName.Users) .where({ email }) - .leftJoin(TableName.OrgMembership, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) - .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.Membership, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Membership}.scopeOrgId`) .select(selectAllTableCols(TableName.Users)) .select( db.ref("name").withSchema(TableName.Organization).as("orgName"), diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index b8058f327..56d7ee635 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { Knex } from "knex"; +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto"; @@ -9,12 +10,11 @@ import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; -import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TMembershipUserDALFactory } from "../membership-user/membership-user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; import { TListUserGroupsDTO, TUpdateUserEmailDTO, TUpdateUserMfaDTO } from "./user-types"; @@ -37,9 +37,8 @@ type TUserServiceFactoryDep = { >; groupProjectDAL: Pick; orgDAL: Pick; - orgMembershipDAL: Pick; + membershipUserDAL: Pick; tokenService: Pick; - projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; userAliasDAL: Pick; @@ -50,8 +49,7 @@ export type TUserServiceFactory = ReturnType; export const userServiceFactory = ({ userDAL, orgDAL, - orgMembershipDAL, - projectMembershipDAL, + membershipUserDAL, groupProjectDAL, tokenService, smtpService, @@ -183,13 +181,19 @@ export const userServiceFactory = ({ }; const checkUserScimRestriction = async (userId: string, tx?: Knex) => { - const userOrgs = await orgMembershipDAL.find({ userId }, { tx }); + const userOrgs = await membershipUserDAL.find( + { + actorUserId: userId, + scope: AccessScope.Organization + }, + { tx } + ); if (userOrgs.length === 0) { return false; } - const orgIds = userOrgs.map((membership) => membership.orgId); + const orgIds = userOrgs.map((membership) => membership.scopeOrgId); const organizations = await orgDAL.find({ $in: { id: orgIds } }, { tx }); return organizations.some((org) => org.scimEnabled); @@ -347,6 +351,23 @@ export const userServiceFactory = ({ const deleteUser = async (userId: string) => { const user = await userDAL.deleteById(userId); + + try { + if (user?.email) { + // Send email to user to confirm account deletion + await smtpService.sendMail({ + template: SmtpTemplates.AccountDeletionConfirmation, + subjectLine: "Your Infisical account has been deleted", + recipients: [user.email], + substitutions: { + email: user.email + } + }); + } + } catch (error) { + logger.error(error, `Failed to send account deletion confirmation email to ${user.email}`); + } + return user; }; @@ -380,9 +401,10 @@ export const userServiceFactory = ({ }; const getUserProjectFavorites = async (userId: string, orgId: string) => { - const orgMembership = await orgMembershipDAL.findOne({ - userId, - orgId + const orgMembership = await membershipUserDAL.findOne({ + scope: AccessScope.Organization, + actorUserId: userId, + scopeOrgId: orgId }); if (!orgMembership) { @@ -395,9 +417,10 @@ export const userServiceFactory = ({ }; const updateUserProjectFavorites = async (userId: string, orgId: string, projectIds: string[]) => { - const orgMembership = await orgMembershipDAL.findOne({ - userId, - orgId + const orgMembership = await membershipUserDAL.findOne({ + scope: AccessScope.Organization, + actorUserId: userId, + scopeOrgId: orgId }); if (!orgMembership) { @@ -406,18 +429,20 @@ export const userServiceFactory = ({ }); } - const matchingUserProjectMemberships = await projectMembershipDAL.find({ - userId, + const matchingUserProjectMemberships = await membershipUserDAL.find({ + scope: AccessScope.Project, + scopeOrgId: orgId, + actorUserId: userId, $in: { - projectId: projectIds + scopeProjectId: projectIds } }); const memberProjectFavorites = matchingUserProjectMemberships.map( - (projectMembership) => projectMembership.projectId + (projectMembership) => projectMembership.scopeProjectId as string ); - const updatedOrgMembership = await orgMembershipDAL.updateById(orgMembership.id, { + const updatedOrgMembership = await membershipUserDAL.updateById(orgMembership.id, { projectFavorites: memberProjectFavorites }); @@ -433,13 +458,14 @@ export const userServiceFactory = ({ // This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission. if (user.id !== actorId) { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); } diff --git a/backend/src/services/workflow-integration/workflow-integration-service.ts b/backend/src/services/workflow-integration/workflow-integration-service.ts index cb7f7a325..8fea0e240 100644 --- a/backend/src/services/workflow-integration/workflow-integration-service.ts +++ b/backend/src/services/workflow-integration/workflow-integration-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; +import { OrganizationActionScope } from "@app/db/schemas"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -23,13 +24,14 @@ export const workflowIntegrationServiceFactory = ({ actorOrgId, actorAuthMethod }: TGetWorkflowIntegrationsByOrg) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getOrgPermission({ actor, actorId, - actorOrgId, + orgId: actorOrgId, actorAuthMethod, - actorOrgId - ); + actorOrgId, + scope: OrganizationActionScope.Any + }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2de271180..e60ef1ba5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -77,6 +77,7 @@ services: - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src + - softhsm_tokens:/etc/softhsm2/tokens # SoftHSM tokens are stored in a volume to persist across container restarts extra_hosts: - "host.docker.internal:host-gateway" @@ -197,4 +198,6 @@ volumes: driver: local ldap_data: ldap_config: - grafana_storage: \ No newline at end of file + grafana_storage: + softhsm_tokens: + driver: local \ No newline at end of file diff --git a/docs/CONTRIBUTING.MD b/docs/CONTRIBUTING.MD index a74f0985e..6983ad8f4 100644 --- a/docs/CONTRIBUTING.MD +++ b/docs/CONTRIBUTING.MD @@ -1,24 +1,24 @@ # Contributing to the documentation ## Getting familiar with Mintlify -New to Mintlify. [Start Here](https://mintlify.com/docs/quickstart) +New to Mintlify. [Start Here](https://mintlify.com/docs/quickstart) ## 👩‍💻 Development -Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command +Install the [Mint CLI](https://www.npmjs.com/package/mint) to preview the documentation changes locally. To install, use the following command ``` -npm i -g mintlify +npm i -g mint ``` Run the following command at the root of your documentation (where mint.json is) ``` -mintlify dev +mint dev ``` ## Troubleshooting -- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies. +- `mint dev` isn't running - Run `mint update` to update the Mint CLI. - Page loads as a 404 - Make sure you are running in a folder with `mint.json`. Check the `/docs` folder diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/available.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/available.mdx new file mode 100644 index 000000000..cd525806f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/laravel-forge/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/create.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/create.mdx new file mode 100644 index 000000000..316c3f0d1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/laravel-forge" +--- + + + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx new file mode 100644 index 000000000..e2d8a0f02 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..675551508 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..541a393f9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/laravel-forge/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx new file mode 100644 index 000000000..209eb6514 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx new file mode 100644 index 000000000..b00d06818 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/laravel-forge/{connectionId}" +--- + + + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/certificate-profiles/create.mdx b/docs/api-reference/endpoints/certificate-profiles/create.mdx new file mode 100644 index 000000000..e24e42207 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/delete.mdx b/docs/api-reference/endpoints/certificate-profiles/delete.mdx new file mode 100644 index 000000000..a1762640a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx new file mode 100644 index 000000000..38e0c20f8 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx new file mode 100644 index 000000000..9013020d6 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Slug" +openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx new file mode 100644 index 000000000..d0a690f76 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "List Certificates" +openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list.mdx b/docs/api-reference/endpoints/certificate-profiles/list.mdx new file mode 100644 index 000000000..c0f461512 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/update.mdx b/docs/api-reference/endpoints/certificate-profiles/update.mdx new file mode 100644 index 000000000..e483cf030 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/create.mdx b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx new file mode 100644 index 000000000..2fb4da177 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx new file mode 100644 index 000000000..dc92ca55a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx new file mode 100644 index 000000000..c97389a1d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/list.mdx b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx new file mode 100644 index 000000000..ab752e851 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/update.mdx b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx new file mode 100644 index 000000000..7bdeca14e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx deleted file mode 100644 index 56fcf3791..000000000 --- a/docs/api-reference/endpoints/certificate-templates/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/certificate-templates" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx deleted file mode 100644 index c4f13d470..000000000 --- a/docs/api-reference/endpoints/certificate-templates/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx deleted file mode 100644 index 802dc5326..000000000 --- a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get by ID" -openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx deleted file mode 100644 index 53c5f6fdf..000000000 --- a/docs/api-reference/endpoints/certificate-templates/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/create.mdx b/docs/api-reference/endpoints/pki/subscribers/create.mdx deleted file mode 100644 index 14a53b7fa..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/subscribers" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/delete.mdx b/docs/api-reference/endpoints/pki/subscribers/delete.mdx deleted file mode 100644 index 5975b89e9..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx deleted file mode 100644 index 894c8ed4e..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve latest certificate bundle" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx deleted file mode 100644 index c9c71c80d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Issue Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx deleted file mode 100644 index 3a4607303..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Certificates" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx deleted file mode 100644 index 93abf1433..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Order Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/read.mdx b/docs/api-reference/endpoints/pki/subscribers/read.mdx deleted file mode 100644 index 0d223217d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/read.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx deleted file mode 100644 index d31d30239..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/update.mdx b/docs/api-reference/endpoints/pki/subscribers/update.mdx deleted file mode 100644 index 5b62cbe7d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx new file mode 100644 index 000000000..dcd58cf32 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create AWS Certificate Manager PKI Sync" +openapi: "POST /api/v1/pki/syncs/aws-certificate-manager" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx new file mode 100644 index 000000000..73fed2cdb --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete AWS Certificate Manager PKI Sync" +openapi: "DELETE /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx new file mode 100644 index 000000000..9191bbde3 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get AWS Certificate Manager PKI Sync by ID" +openapi: "GET /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx new file mode 100644 index 000000000..821ddbd61 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List AWS Certificate Manager PKI Syncs" +openapi: "GET /api/v1/pki/syncs/aws-certificate-manager" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx new file mode 100644 index 000000000..5ea989f2a --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from AWS Certificate Manager" +openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/remove-certificates" +--- diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx new file mode 100644 index 000000000..b97b7a9ab --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Certificates to AWS Certificate Manager" +openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/sync" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx new file mode 100644 index 000000000..9b7382ce8 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update AWS Certificate Manager PKI Sync" +openapi: "PATCH /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx new file mode 100644 index 000000000..076571648 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx new file mode 100644 index 000000000..303c82079 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..12602b1db --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..f5082df25 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/laravel-forge/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx new file mode 100644 index 000000000..c86085e95 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx new file mode 100644 index 000000000..fa64bf933 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx new file mode 100644 index 000000000..7b4c3a752 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx new file mode 100644 index 000000000..ce638b4cb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx new file mode 100644 index 000000000..9f9f4a28f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index bddbf614c..59202e46e 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -31,6 +31,7 @@ If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. G ## Subcommands & flags + Run the Infisical gateway component within your the network where your target resources are located. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources within your network. @@ -245,6 +246,7 @@ The Relay supports multiple authentication methods. Below are the available auth ### Other Flags + The name of the relay that this gateway should connect to. The relay must be running and registered before starting the gateway. @@ -276,6 +278,7 @@ The Relay supports multiple authentication methods. Below are the available auth ``` + @@ -293,6 +296,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ### Flags + The machine identity access token to authenticate with Infisical. @@ -334,6 +338,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ``` + ### Service Details @@ -360,9 +365,11 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot ``` + ## Legacy Gateway Commands + **This command is deprecated and will be removed in a future release.** @@ -579,6 +586,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa ### Other Flags + Domain of your self-hosted Infisical instance. @@ -588,6 +596,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa ``` + @@ -614,6 +623,7 @@ sudo infisical gateway install --token= --domain= ### Flags + The machine identity access token to authenticate with Infisical. @@ -635,6 +645,7 @@ sudo infisical gateway install --token= --domain= ``` + ### Service Details @@ -659,3 +670,4 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot ``` + diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index f93e3b4b2..c58c13713 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -9,22 +9,97 @@ infisical login ### Description -The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. +The CLI uses authentication to verify your identity. You can authenticate using: +- **Browser Login** (default): Opens a browser for authentication +- **Direct Login**: Provide email and password via flags or environment variables for non-interactive workflows +- **Interactive CLI Login**: Use the `--interactive` flag to enter credentials via CLI prompts + +When authenticated, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. To change where the login credentials are stored, visit the [vaults command](./vault). If you have added multiple users, you can switch between the users by using the [user command](./user). - When you authenticate with **any other method than `user`**, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable. - - Use flag `--plain` along with `--silent` to print only the token in plain text when using a machine identity auth method. - + **JWT Token Output:** + - For **user authentication** with the `--plain --silent` flags: outputs only the JWT access token (useful for scripting) + - For **machine identity authentication**: an access token is always printed to the console + + Use the `--plain` flag to print only the token in plain text and the `--silent` flag to disable update alerts. + + Both flags are ideal for capturing the token in environment variables or CI/CD pipelines. ### Authentication Methods -The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. +The Infisical CLI supports two main categories of authentication: User Authentication and Machine Identity Authentication. + +#### User Authentication + +User authentication is designed for individual developers and supports multiple login flows. + + + + The User authentication method allows you to log in with your email and password. This method supports three different login flows: + + - **Browser Login** (default): Opens a browser for authentication + - **Direct Login**: Provide credentials via flags or environment variables for CI/CD + - **Interactive CLI Login**: Enter credentials via CLI prompts using `--interactive` + + + + + Your email address. Required for direct login along with `--password` and `--organization-id`. + + + Your password. Required for direct login along with `--email` and `--organization-id`. + + + Your organization id. Required for direct login along with `--password` and `--email`. + + + Force interactive CLI login instead of browser-based authentication. + + + Output only the JWT token (useful for scripting and CI/CD). + + + + + + + ```bash + infisical login + ``` + + + ```bash + infisical login --email=user@example.com --password=your-password --organization-id=your-organization-id + + # Or using environment variables + export INFISICAL_EMAIL="user@example.com" + export INFISICAL_PASSWORD="your-password" + export INFISICAL_ORGANIZATION_ID="your-organization-id" + infisical login + ``` + + + ```bash + infisical login --interactive + ``` + + + ```bash + export INFISICAL_TOKEN=$(infisical login --email=user@example.com --password=your-password --organization-id=your-organization-id --plain --silent) + ``` + + + + + +#### Machine Identity Authentication + +Machine identity authentication methods are designed for automated systems, services, and CI/CD pipelines. @@ -237,7 +312,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Run the `login` command with the following flags to obtain an access token: ```bash - infisical login --method=jwt-auth --jwt= --machine-identity-id= + infisical login --method=jwt-auth --jwt= --machine-identity-id= ``` @@ -262,7 +337,8 @@ The login command supports a number of flags that you can use for different auth - `gcp-id-token`: Login using a GCP ID token native auth. - `gcp-iam`: Login using a GCP IAM. - `aws-iam`: Login using an AWS IAM native auth. - - `oidc-auth`: Login using oidc auth. + - `oidc-auth`: Login using OIDC auth. + - `jwt-auth`: Login using a plain JWT token. @@ -330,22 +406,173 @@ The login command supports a number of flags that you can use for different auth - - - + ```bash - infisical login --oidc-jwt= + infisical login --email= --password= --organization-id= ``` #### Description - The JWT provided by an identity provider for OIDC authentication. + User email address. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--password` and `--organization-id` flag. - The `oidc-jwt` flag can be substituted with the `INFISICAL_OIDC_AUTH_JWT` environment variable. + You can omit the **--method=user** if you want as it's the default method. + + + + The `email` flag can be substituted with the `INFISICAL_EMAIL` environment variable. + + ```bash + infisical login --email= --password= --organization-id= + ``` + #### Description + User password. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--email` and `--organization-id` flag. + + + For security in CI/CD environments, prefer using the `INFISICAL_PASSWORD` environment variable instead of passing the password as a command-line flag. + + + + You can omit the **--method=user** if you want as it's the default method. + + + + The `password` flag can be substituted with the `INFISICAL_PASSWORD` environment variable. + + + + + ```bash + infisical login --email= --password= --organization-id= + ``` + + #### Description + User organization id. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--email` and `--password` flag. + + + You can omit the **--method=user** if you want as it's the default method. + + + + The `organization-id` flag can be substituted with the `INFISICAL_ORGANIZATION_ID` environment variable. + + + + + ```bash + infisical login --interactive + ``` + + #### Description + Forces interactive CLI login where you'll be prompted to enter your email, password, and select your organization in the terminal, instead of opening a browser. + + + + ```bash + infisical login --email= --password= --organization-id= --plain + ``` + + #### Description + When used with direct user login or machine identity authentication, outputs only the JWT access token without any additional formatting. This is useful for scripting and CI/CD pipelines where you need to capture the token. + + ```bash + # Example: Capture token in a variable + export INFISICAL_TOKEN=$(infisical login --email= --password= --organization-id= --plain --silent) + ``` + + + Use it alongside the `silent` flag to disable all messages in the console except from the access token. + + + + + ```bash + infisical login --jwt= --machine-identity-id= + ``` + + #### Description + The JWT provided by an identity provider for OIDC or plain JWT authentication. This is required if the `--method` flag is set to `oidc-auth` or `jwt-auth`. + + + The `jwt` flag can be substituted with the `INFISICAL_JWT` environment variable. + + + + + +### User Authentication Examples + +The following examples demonstrate different ways to authenticate as a user with the Infisical CLI. + + + + By default, running `infisical login` without any flags opens your browser for authentication. + + ```bash + # Opens browser for authentication + infisical login + ``` + + The browser will open to the Infisical login page, and upon successful authentication, the CLI will be automatically authenticated. + + + + + Direct login is ideal for CI/CD pipelines and automation scripts where browser-based authentication is not possible. + + #### Using Command-Line Flags + + ```bash + # Basic direct login (defaults to US Cloud) + infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" + + # EU Cloud (Custom domain) + infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" --domain https://eu.infisical.com + + # Output only JWT token for scripting + export INFISICAL_TOKEN=$(infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" --plain --silent) + ``` + + #### Using Environment Variables (Recommended for CI/CD) + + ```bash + # Set credentials as environment variables + export INFISICAL_EMAIL="user@example.com" + export INFISICAL_PASSWORD="your-password" + export INFISICAL_ORGANIZATION_ID="your-organization-id" + + # Login without additional flags + infisical login + + # Or with plain output for token capture + export INFISICAL_TOKEN=$(infisical login --plain --silent) + ``` + + + + Interactive login prompts you to enter credentials in the terminal instead of opening a browser. + + ```bash + # Force interactive CLI login + infisical login --interactive + ``` + + You'll be prompted to enter: + - Email address + - Password + + After the prompt, you will be shown a list of organizations to choose from. + + + + + + +If you have SSO enabled, we recommend using the default browser login. + ### Machine Identity Authentication Quick Start @@ -367,9 +594,9 @@ In this example we'll be using the `universal-auth` method to login to obtain an ``` - + ```bash - infisical secrets --projectId= --env=dev --recursive ``` This command will fetch all secrets from the `dev` environment in your project, including all secrets in subfolders. diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index b377b9ce2..92358ed8b 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -13,7 +13,7 @@ description: "Relay-related commands for Infisical" ```bash # Install systemd service sudo infisical relay systemd install --host= --name= --token= - + # Uninstall systemd service sudo infisical relay systemd uninstall ``` @@ -26,6 +26,7 @@ Relay-related commands for Infisical that provide identity-aware relay infrastru ## Subcommands & flags + Run the Infisical relay component. The relay handles network traffic routing between Infisical and your gateways. @@ -35,6 +36,7 @@ infisical relay start --host= --name= --auth-method= ### Flags + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. @@ -57,6 +59,7 @@ infisical relay start --host= --name= --auth-method= ``` + ### Authentication @@ -280,6 +283,7 @@ infisical relay systemd ### Subcommands + Install and enable systemd service for the relay. Must be run with sudo on Linux systems. @@ -289,6 +293,7 @@ sudo infisical relay systemd install --host= --name= --token= #### Flags + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. @@ -331,6 +336,7 @@ sudo infisical relay systemd install --domain=http://localhost:8080 --token= + #### Examples @@ -386,5 +392,7 @@ sudo infisical relay systemd uninstall - Cleans up the service configuration + + diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 35912fc8c..1784b77e8 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -7,20 +7,20 @@ To set a strong foundation, this section outlines how we, the community and memb should approach the development and contribution process. ## Code-bases + Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. -- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. - - - - - The Infisical platform is the core of the Infisical ecosystem. - - - The SDKs are the official Infisical client libraries, used by developers to easily interact with the Infisical platform. - - +- The Infisical SDKs, please refer to each individual SDK repositories for more information. + - [Node.js SDK](https://github.com/Infisical/node-sdk-v2) + - [Python SDK](https://github.com/Infisical/python-sdk-official) + - [Java SDK](https://github.com/Infisical/java-sdk) + - [.NET SDK](https://github.com/Infisical/infisical-dotnet-sdk) + - [Go SDK](https://github.com/Infisical/go-sdk) + - [C++ SDK](https://github.com/Infisical/infisical-cpp-sdk) + - [PHP SDK](https://github.com/Infisical/php-sdk) + - [Rust SDK](https://github.com/Infisical/rust-sdk) + - [Ruby SDK](https://github.com/infisical/sdk) ## Community @@ -45,15 +45,12 @@ If you're ever in doubt about whether or not a proposed feature aligns with Infi ## Writing and submitting code -Anyone can contribute code to Infisical. To get started, check out the local development guides for each language. - -- Local development guide for Platform is [here](/contributing/platform/developing). -- Local development guide for SDK is [here](/contributing/sdk/developing). +Anyone can contribute code to Infisical. To get started, check out the local development guide for the platform: +- Local development guide for Platform is [here](/contributing/platform/developing). ## Licensing Most of Infisical's code is under the MIT license, though some paid feature restrictions are covered by a proprietary license. Any third party components incorporated into our code are licensed under the original license provided by the applicable component owner. - diff --git a/docs/contributing/platform/developing.mdx b/docs/contributing/platform/developing.mdx index 68add4603..69710baf3 100644 --- a/docs/contributing/platform/developing.mdx +++ b/docs/contributing/platform/developing.mdx @@ -43,23 +43,23 @@ docker compose -f docker-compose.dev.yml down We use [Mintlify](https://mintlify.com/) for our docs. -#### Install Mintlify CLI. +#### Install Mint CLI. ```bash -npm i -g mintlify +npm i -g mint ``` or ```bash -yarn global add mintlify +yarn global add mint ``` #### Running the docs -Go to `docs` directory and run `mintlify dev`. This will start up the docs on `localhost:3000` +Go to `docs` directory and run `mint dev`. This will start up the docs on `localhost:3000` ```bash # From the root directory -cd docs; mintlify dev; +cd docs; mint dev; ``` diff --git a/docs/docs.json b/docs/docs.json index 210c5145c..dda9adcfc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -125,6 +125,7 @@ "integrations/app-connections/hashicorp-vault", "integrations/app-connections/heroku", "integrations/app-connections/humanitec", + "integrations/app-connections/laravel-forge", "integrations/app-connections/ldap", "integrations/app-connections/mssql", "integrations/app-connections/mysql", @@ -175,16 +176,14 @@ "pages": [ "documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-deployment", - "documentation/platform/gateways/relay-deployment", - "documentation/platform/gateways/security", { - "group": "Gateway (Deprecated)", + "group": "Relay Deployment", "pages": [ - "documentation/platform/gateways-deprecated/overview", - "documentation/platform/gateways-deprecated/gateway-security", - "documentation/platform/gateways-deprecated/networking" + "documentation/platform/gateways/relay-deployment/overview", + "documentation/platform/gateways/relay-deployment/terraform" ] - } + }, + "documentation/platform/gateways/security" ] } ] @@ -221,6 +220,7 @@ ] }, "documentation/platform/sso/auth0-oidc", + "documentation/platform/sso/pingone-oidc", { "group": "General OIDC", "pages": [ @@ -247,6 +247,7 @@ "documentation/platform/scim/okta", "documentation/platform/scim/azure", "documentation/platform/scim/jumpcloud", + "documentation/platform/scim/pingone", "documentation/platform/scim/group-mappings" ] } @@ -314,6 +315,7 @@ "self-hosting/deployment-options/linux-upgrade" ] }, + "self-hosting/guides/replication", "self-hosting/guides/upgrading-infisical", "self-hosting/configuration/envars", "self-hosting/guides/releases", @@ -417,6 +419,7 @@ "documentation/guides/node", "documentation/guides/python", "documentation/guides/nextjs-vercel", + "documentation/guides/kubernetes-operator", "documentation/guides/microsoft-power-apps" ] } @@ -547,6 +550,7 @@ "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", @@ -723,6 +727,7 @@ { "group": "Syncs", "pages": [ + "documentation/platform/pki/certificate-syncs/aws-certificate-manager", "documentation/platform/pki/certificate-syncs/azure-key-vault" ] } @@ -769,6 +774,15 @@ ] } ] + }, + { + "item": "Infisical PAM", + "groups": [ + { + "group": "Infisical PAM", + "pages": ["documentation/platform/pam/overview"] + } + ] } ] }, @@ -1774,6 +1788,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/app-connections/laravel-forge/list", + "api-reference/endpoints/app-connections/laravel-forge/available", + "api-reference/endpoints/app-connections/laravel-forge/get-by-id", + "api-reference/endpoints/app-connections/laravel-forge/get-by-name", + "api-reference/endpoints/app-connections/laravel-forge/create", + "api-reference/endpoints/app-connections/laravel-forge/update", + "api-reference/endpoints/app-connections/laravel-forge/delete" + ] + }, { "group": "LDAP", "pages": [ @@ -2254,6 +2280,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/secret-syncs/laravel-forge/list", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-id", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-name", + "api-reference/endpoints/secret-syncs/laravel-forge/create", + "api-reference/endpoints/secret-syncs/laravel-forge/update", + "api-reference/endpoints/secret-syncs/laravel-forge/delete", + "api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets", + "api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets" + ] + }, { "group": "Netlify", "pages": [ @@ -2420,20 +2459,6 @@ { "group": "Infisical PKI", "pages": [ - { - "group": "Subscribers", - "pages": [ - "api-reference/endpoints/pki/subscribers/list-certs", - "api-reference/endpoints/pki/subscribers/create", - "api-reference/endpoints/pki/subscribers/read", - "api-reference/endpoints/pki/subscribers/update", - "api-reference/endpoints/pki/subscribers/delete", - "api-reference/endpoints/pki/subscribers/issue-cert", - "api-reference/endpoints/pki/subscribers/sign-cert", - "api-reference/endpoints/pki/subscribers/order-cert", - "api-reference/endpoints/pki/subscribers/get-latest-cert-bundle" - ] - }, { "group": "Certificate Authorities", "pages": [ @@ -2490,10 +2515,11 @@ { "group": "Certificate Templates", "pages": [ - "api-reference/endpoints/certificate-templates/create", - "api-reference/endpoints/certificate-templates/update", - "api-reference/endpoints/certificate-templates/get-by-id", - "api-reference/endpoints/certificate-templates/delete" + "api-reference/endpoints/certificate-templates-v2/list", + "api-reference/endpoints/certificate-templates-v2/create", + "api-reference/endpoints/certificate-templates-v2/update", + "api-reference/endpoints/certificate-templates-v2/get-by-id", + "api-reference/endpoints/certificate-templates-v2/delete" ] }, { @@ -2517,12 +2543,33 @@ "api-reference/endpoints/pki-alerts/delete" ] }, + { + "group": "Certificate Profiles", + "pages": [ + "api-reference/endpoints/certificate-profiles/create", + "api-reference/endpoints/certificate-profiles/update", + "api-reference/endpoints/certificate-profiles/get-by-id", + "api-reference/endpoints/certificate-profiles/delete" + ] + }, { "group": "Certificate Syncs", "pages": [ "api-reference/endpoints/pki/syncs/list", "api-reference/endpoints/pki/syncs/get-by-id", "api-reference/endpoints/pki/syncs/options", + { + "group": "AWS Certificate Manager", + "pages": [ + "api-reference/endpoints/pki/syncs/aws-certificate-manager/list", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/create", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/update", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/delete", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates" + ] + }, { "group": "Azure Key Vault", "pages": [ diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index f773019ec..e10d594da 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -38,3 +38,4 @@ Infisical consists of several tightly integrated products, each designed to solv - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. +- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx index 769990987..f51136278 100644 --- a/docs/documentation/getting-started/overview.mdx +++ b/docs/documentation/getting-started/overview.mdx @@ -40,6 +40,12 @@ description: "The open source platform for managing secrets, certificates, and s > Replace static SSH keys with short-lived SSH certificates to simplify access and improve security. + + Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. + diff --git a/docs/documentation/guides/kubernetes-operator.mdx b/docs/documentation/guides/kubernetes-operator.mdx new file mode 100644 index 000000000..fc42b8d2f --- /dev/null +++ b/docs/documentation/guides/kubernetes-operator.mdx @@ -0,0 +1,239 @@ +--- +title: "Managing Secrets With Kubernetes Operator" +sidebarTitle: "Kubernetes Operator" +description: "How to use the Infisical Kubernetes Operator to Push Secrets, Pull Secrets, and Generate Dynamic Secrets within your clusters." +--- + +Infisical's Kubernetes Operator provides a seamless, secure, and automated way to synchronize secrets between your Infisical instance and your Kubernetes clusters. The Operator's three Custom Resource Definitions (CRDs) make this possible. In this guide, we provide the necessary CRDs and configurations for your kubernetes cluster, but you can customize them to fit your use-case. + +In this guide, we'll walk through how to: + +1. **Install the Infisical Operator on your Kubernetes cluster**. +2. **Configure Authentication using Kubernetes Service Accounts**. +3. **Use Each of the three CRDs**. + - **InfisicalSecret** [Sync secrets from Infisical to Kubernetes] + - **InfisicalPushSecret** [Sync secrets from Kubernetes to Infisical] + - **InfisicalDynamicSecret** [Manage Dynamic Secrets and automatically create time-bound leases] + +## Prerequisites + +Before we begin, make sure your environment is ready +1. Installed tools + - [helm](https://helm.sh/docs/intro/install/), [git](https://git-scm.com/downloads), [kubectl](https://kubernetes.io/docs/tasks/tools/) +2. Kubernetes Cluster + - Ensure you have access to a running cluster and connect with kubectl +3. PostgreSQL Cluster (for InfisicalDynamicSecret) + - Ensure you have a running database that you have access to +4. Clone [infisical-guides-source-code](https://github.com/Infisical/infisical-guides-source-code) repository +5. Access to an Infisical instance (cloud or self-hosted) + +## Step-By-Step Guide + + + + +The [Infisical Operator](https://infisical.com/docs/integrations/platforms/kubernetes/overview) runs inside your cluster and is responsible for handling secret synchronization events. + +```console +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +helm repo update +helm install infisical-operator infisical-helm-charts/secrets-operator +``` + +Verify the operator pod is running: + +```console +kubectl get pods -n default +``` + + + +The operator uses a [Machine Identity](https://infisical.com/docs/documentation/platform/identities/machine-identities) to authenticate with Infisical through the Kubernetes Auth Method +1. Login to [Infisical](https://app.infisical.com/) +2. Select **Organization Access** from the left navigational pane +3. Create an Identity and give it a name and a role +4. Once the Machine Identity is created, copy the **Identity ID** (will be used later) +5. Select the created Machine Identity, and add a [Kubernetes Authentication Method](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth). Use these configurations: + - **Allowed Service Account Names**: infisical-service-account, default + - **Allowed Namespaces**: default + - **Kubernetes Host URL** can be found by running ```kubectl cluster-info``` + - **Token Reviewer JWT / CA Certificate**: We will be generating these two later and adding them in later, leave it blank for now +6. Once the Machine Identity has been created, navigate back to **Overview** +7. Now select **Add New Project** + - Add a **Project Name**, and select **Secrets Management** as the product type + - Add a description (Optional). +8. Once the Project is created, navigate into the Project to **Add Secrets**. You can add any key-value pair for this example, however if you want to use the InfisicalSecret CRD example provided in this demo, use the following configurations: + - **Key**: SMTP_HOST + - **Value**: smtp@gmail.com + - **Tags**: N/A (Not needed for this demonstration) + - **Environments**: Production +9. Now lets navigate to the **Project Access** tab on the left hand navigation pane. + - Add the machine identity we created, and give it Admin permissions (just for demonstration purposes) + + + + + +Now we will be interacting with the local repository you cloned earlier. Make sure you are in the directory that contains the yaml configurations. Assuming you are in your root user directory: + +```console +cd infisical-guides-source-code/kubernetes-operator-demo +``` + +1. Create the ```infisical-token-reviewer``` service account. This Manifest creates a **service account** that the Infisical Operator uses to authenticate with Kubernetes for token reviews. It allows Infisical to validate Kubernetes tokens securely during the Machine Identity authentication process. +```console +kubectl apply -f infisical-reviewer-service-account.yaml +``` +2. Create the token for the reviewer service account. This Yaml defines a **service account token secret** linked to the reviewer account created above. It generates a JWT token that Infisical uses for the Kubernetes Auth Method in your Machine Identity configuration. +```console +kubectl apply -f service-account-reviewer-token.yaml +``` +3. This file binds the ```infisical-token-reviewer``` service account to the built-in ```system:auth-delegator``` ClusterRole. That role allows the service account to perform **token review** and **authentication delegation** requests on behalf of other service accounts - a key part of Kubernetes-based identity verification. Without this binding, the Infisical Operator wouldn't have permission to validate tokens. +```console +kubectl apply -f cluster-role-binding.yaml +``` +4. Create the service account that will be used by the InfisicalSecret. This file creates a **dedicated service account** ```infisical-service-account``` that the Infisical Operator uses to access and sync secrets within your cluster. It operates as the Operator's working identity in your cluster, separate from the token reviewer. +```console +kubectl apply -f infisical-service-account.yaml +``` +5. Create the token for the Infisical service account. This manifest defines a **token secret** for the ```infisical-service-account```. It allows the Infisical operator to authenticate against Infisical's API when syncing secrets. The token will then be manually patched and associated with the service account to make sure Kubernetes mains it persistently. +```console +kubectl apply -f infisical-service-account-token.yaml +``` +6. Apply the patch to manually associate the token secret +```console +kubectl patch serviceaccount infisical-service-account -p '{"secrets": [{"name": "infisical-service-account-token"}]}' -n default +``` +7. Create the **JWT Token** and **Certificate** and add it to the **Machine Identity** we created under **Kubernetes Auth**. For the generated CA, navigate to the **Advanced** tab to paste the certificate: +- JWT Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.token}' | base64 -d +``` + +- CA Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.ca\.crt}' | base64 -d +``` + + + +1. Check to see if the service accounts were created + +```console +kubectl get serviceaccount -n default | grep infisical +``` + +2. Verify the tokens were created and linked + +```console +kubectl get secrets -n default | grep infisical +``` + + + +The [InfisicalSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-secret-crd) CRD tells the operator to sync secrets from Infisical to Kubernetes. By referencing your ```identityID```, ```projectSlug```, and ```envSlug```, this CRD tells the Infisical Operator which Infisical secrets to fetch and how to format them into a Kubernetes Secret. Make sure to edit the provided CRD to match your specific Machine Identity ID, Project ID, and which environment your secrets are being pulled from (default is prod). + - **Project Slug**: Can be found when you select your project and navigate to settings + - **Identity ID**: Can be found when you select your machine identity from your organization's access control + +1. After editing the ```example-infisical-secret-crd.yaml``` to contain your demo-specific values, apply the yaml in your cluster +```console +kubectl apply -f example-infisical-secret-crd.yaml +``` + + + + +1. Check that the ```InfisicalSecret``` was created successfully +```console +kubectl get infisicalsecret -n default +``` +2. Check that the operator created the ```managed-secret``` +```console +kubectl get secret managed-secret -n default +``` +3. View the secret contents (base64 encoded) +```console +kubectl get secret managed-secret -n default -o jsonpath='{.data}' | jq +``` + + + +1. Deploy the nginx demo deployment that will use the managed secret. +```console +kubectl apply -f demo-deployment.yaml +``` +2. Wait 15-20 seconds and then verify the deployments +```console +kubectl get deployments +kubectl get pods -l app=nginx +``` + + + +1. Check that the environment variable is in the running pod. If everything was successful, at this point you should be able to see the secret populate in the kubernetes pod and have a successful **sync** from Infisical to Kubernetes. +```console +kubectl exec -it $(kubectl get pod -l app=nginx -o jsonpath='{.items[0].metadata.name}') -- env | grep SMTP +``` + + + +Now that we have successfully synced secrets from Infisical to Kubernetes, lets explore how we can push **Kubernetes Secrets** to Infisical. + + 1. Either create a **Kubernetes Secret** via yaml, or use the one in the repository. +```console +kubectl apply -f source-secret.yaml +``` + 2. Verify creation of the secret +```console +kubectl get secret push-secret-demo -n default -o yaml +``` + + + +The [InfisicalPushSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-push-secret-crd) CRD tells the operator to sync secrets from Kubernetes to Infisical. Make sure you edit the CRD to include the specific **Project Slug**, and **Identity ID**. The other values present in ```example-push-secret.yaml``` should be configured based on the previously committed yaml configurations. + + 1. Apply the InfisicalPushSecret CRD provided after making the necessary changes +```console +kubectl apply -f example-push-secret-crd.yaml +``` + + 2. Once your CRD has been configured, go back to your project within Infisical and check to see if your secrets have populated there. + +![secrets dashboard](../../images/push-secret.png) + + + + +The [InfisicalDynamicSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd) CRD allows you to sync dynamic secrets and create leases automatically in Kubernetes as native **Kubernetes Secret** resources Any Pod, Deployment, or other Kubernetes resource can make use of dynamic secrets from Infisical just like any other Kubernetes secret. + + 1. Navigate to your Infisical **Project** and click on the dropdown next to **Add Secret**. From here you will select **Add Dynamic Secret** + - Select **SQL Database** as the service you would like to connect to. + - Select **PostegreSQL** as the database service. Enter in the connection details for your database, specifically the **Host**, **Port**, **User**, **Password**, and **Database Name**. + - For the **Secret Name**, if you want to use the same name as the one in the cloned **InfisicalDynamicSecret** CRD, use the name **dynamic-secret-lease**. Otherwise you will need to change the **dynamicSecret.secretName** config in the InfisicalDynamicSecret CRD to whatever you name the secret here. + - In the CA (SSL) section, make sure to upload the **CA Certificate** for your database. + - Finally, select **Prod** as the environment (we are keeping this configuration as part of the demonstration). + +![secrets dashboard dynamic](../../images/dynamic-secret.png) + + 2. Edit the ```dynamic-secret-crd``` with the proper machine **Identity ID**, **Project Slug**, **dynamicSecret.secretName** (same as the **Secret Name** you gave to the dynamic secret in Infisical), and managedSecretReference.secretName (name of the kubernetes secret that Infisical Operator will create/populate in the cluster). + - If you want to keep the **managedSecretReference.secretName** then you can leave it as **dynamic-secret-test** + + 3. Once the changes have been saved, apply the yaml: + + ```console + kubectl apply -f dynamic-secret-crd.yaml + ``` + + 4. After applying the CRD, you should notice that the dynamic secret lease has been created and synced with your cluster. Verify by running: + +```console +kubectl get secret dynamic-secret-test -n default -o yaml +``` + + 5. Once the dynamic secret lease has been created, you should see that the secret has data that contains the lease credentials. + +![dynamic secrets output](../../images/dynamic-secret-crd.png) + + + +**Congratulations! You successfully managed secrets with Kubernetes.** \ No newline at end of file diff --git a/docs/documentation/platform/external-migrations/overview.mdx b/docs/documentation/platform/external-migrations/overview.mdx index cf6c5e5a1..d8e85fe91 100644 --- a/docs/documentation/platform/external-migrations/overview.mdx +++ b/docs/documentation/platform/external-migrations/overview.mdx @@ -1,16 +1,22 @@ --- title: "External Migrations" sidebarTitle: "Overview" -description: "Learn how to migrate secrets from third-party secrets management platforms to Infisical." +description: "Learn how to migrate resources from third-party secrets management platforms to Infisical." --- ## Overview -Infisical supports migrating secrets from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing secrets from a different platform. +Infisical supports migrating resources from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing resources from a different platform. + +Infisical offers two types of migration approaches: + +- **In-Platform Migration Tooling**: Configure platform connections to enable granular, on-demand imports of secrets, policies, and configurations directly within the Infisical UI. This allows you to migrate resources incrementally as needed. + +- **Bulk Data Import**: Perform one-time organization-level migrations to import all resources from external platforms at once. This is ideal for initial migrations when moving entirely to Infisical. ## Supported Platforms - [EnvKey](./envkey) - [Vault](./vault) -We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). \ No newline at end of file +We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index 8254e104f..e267cc5e9 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -1,40 +1,239 @@ --- title: "Migrating from Vault to Infisical" sidebarTitle: "Vault" -description: "Learn how to migrate secrets from Vault to Infisical." +description: "Learn how to migrate resources from Vault to Infisical." --- -## Migrating from Vault +Infisical provides two approaches for migrating from HashiCorp Vault. -Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. +### Which approach should I use? -Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +**Choose In-Platform Migration Tooling if you want to:** +- Migrate specific secrets, not everything at once +- Import secrets into existing Infisical projects +- Translate Vault policies to Infisical access controls +- Import Kubernetes authentication configurations +- Have more control over the migration process -### Prerequisites +**Choose Bulk Data Import if you want to:** -- A Vault instance with the KV secret engine enabled. -- An access token to your Vault instance. - - -### Project Mapping - -When migrating from Vault, you'll need to choose how you want to map your Vault resources to Infisical projects. - -There are two options for project mapping: - -- `Namespace`: This will map your selected Vault namespace to a single Infisical project. When you select this option, each KV secret engine within the namespace will be mapped to a single Infisical project. Each KV secret engine will be mapped to a Infisical environment within the project. This means if you have 3 KV secret engines, you'll have 3 environments inside the same project, where the name of the environments correspond to the name of the KV secret engines. -- `Key Vault`: This will map all the KV secret engines within your Vault instance to a Infisical project. Each KV engine will be created as a Infisical project. This means if you have 3 KV secret engines, you'll have 3 Infisical projects. For each of the created projects, a single default environment will be created called `Production`, which will contain all your secrets from the corresponding KV secret engine. +- Migrate all secrets from Vault in one go +- Automatically create new Infisical projects from your Vault structure +- Perform a one-time migration when moving entirely from Vault to Infisical +## In-Platform Migration Tooling +This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical. +### Step 1: Set Up Your Vault Connection - In order to migrate from Vault, you'll need to create a Vault policy that allows Infisical to read the secrets and metadata from the KV v2 secrets engines within your Vault instance. + In your Vault instance, create a policy that allows Infisical to read your secrets, policies, and authentication configurations. This policy grants read-only access and doesn't allow Infisical to modify anything in Vault. + + ```hcl + # System endpoints - for listing namespaces, policies, mounts, and auth methods + path "sys/namespaces" { + capabilities = ["list"] + } - ```python + path "sys/policy" { + capabilities = ["read", "list"] + } + + path "sys/policy/*" { + capabilities = ["read"] + } + + path "sys/mounts" { + capabilities = ["read"] + } + + path "sys/auth" { + capabilities = ["read"] + } + + # KV v2 secrets - for listing and reading secrets + # Replace '+' with your actual KV v2 mount paths (e.g., "secret", "kv") + path "+/metadata/*" { + capabilities = ["list", "read"] + } + + path "+/data/*" { + capabilities = ["read"] + } + + # KV v1 secrets - for listing and reading secrets + # Replace '+' with your actual KV v1 mount paths (e.g., "secret", "kv-v1") + # WARNING: This is broad - ideally specify exact mount names + path "+/*" { + capabilities = ["list", "read"] + } + + # Kubernetes auth - for reading auth configuration and roles + path "auth/+/config" { + capabilities = ["read"] + } + + path "auth/+/role" { + capabilities = ["list"] + } + + path "auth/+/role/*" { + capabilities = ["read"] + } + ``` + + + Save this policy in Vault with the name `infisical-in-platform-migration`. + + + + + In Infisical, navigate to **Organization Settings > App Connections** and create a new HashiCorp Vault connection. + + Follow the [HashiCorp Vault App Connection documentation](/integrations/app-connections/hashicorp-vault) for detailed setup instructions. When configuring authentication (Token or AppRole), make sure it uses the `infisical-in-platform-migration` policy you created. + + + + + Navigate to **Organization Settings > External Migrations** in Infisical. + + Under the "In-Platform Migration Tooling" section for HashiCorp Vault, click **"+ Add Namespace"**. + + ![In-Platform Migration Tooling](/images/platform/external-migrations/vault-in-platform/external-migration-overview.png) + + Configure your namespace: + + ![Namespace Configuration](/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png) + + - **Namespace**: Enter your Vault namespace path (e.g., `admin/namespace1`). If you intend to use the root namespace, set the namespace value to "root". + - **Connection**: Select the App Connection you created in the previous step. + + + You can add multiple namespaces with different connections if you have multiple Vault instances or namespaces to migrate from. + + + + + +### Step 2: Import Your Resources + +Once your Vault connection is configured, you'll see import options throughout Infisical wherever relevant. Here's what you can import: + +#### Import Secrets into a Project + +You can import secrets from Vault directly into a specific environment and secret path: + +1. Navigate to your project and select a specific environment (e.g., Development, Production) +2. In the secrets view, click the dropdown icon (caret) next to the **"+ Add Secret"** button +3. Select **"Add from HashiCorp Vault"** + + ![Import Vault Secrets](/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png) + +4. Choose your Vault namespace and the secret path you want to import +5. Click **"Import Secrets"** + +The secrets will be imported into your current environment and folder path. + +#### Import Kubernetes Authentication Configurations + +When setting up Kubernetes authentication for a machine identity, you can import the configuration from Vault: + +1. Navigate to **Access Control > Machine Identities** and select an identity +2. Click **"Add Authentication Method"** and choose **Kubernetes Auth** +3. In the configuration modal, click **"Load from Vault"** + + ![Load Kubernetes Auth from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png) + +4. Select your Vault namespace and the Kubernetes role +5. Click **"Load"** + + ![Kubernetes Auth Form Populated](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png) + +The authentication settings (service accounts, TTL, policies, etc.) will be automatically populated from your Vault configuration. + + + Sensitive values like service account JWTs cannot be retrieved from Vault and + must be manually provided in the form after importing the configuration. + + +#### Import and Translate Access Control Policies + +When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions. + + + Policy translation is best-effort and provides a starting point based on your + Vault configuration. The translated permissions should be reviewed and + adjusted as needed since Vault and Infisical have different access control + models. Infisical will analyze path patterns and capabilities to suggest + equivalent permissions. + + +**To import and translate a policy:** + +1. Navigate to your project, then go to **Access Control > Roles** and create or edit a role +2. In the policy configuration, click **"Add from HashiCorp Vault"** + + ![Import Vault Policy Button](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png) + +3. Select your Vault namespace +4. Either choose an existing policy from the dropdown or paste your own HCL policy + + ![Translate Vault Policy Modal](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png) + +5. Review the automatically translated Infisical permissions +6. Make any adjustments and save + + + **How policy translation works:** + + - Vault path patterns are analyzed to identify KV secret engines and environments + - Vault capabilities (`read`,`list`, `create`, etc.) are mapped to Infisical permissions + - Wildcards in paths are converted to glob patterns + - Secret paths are preserved for granular access control + + Always review the translated permissions carefully, as Vault's capability-based model may not map 1:1 with Infisical's permission structure. + + +--- + +## Bulk Data Import + +This migration approach imports all secrets from your Vault instance in one operation and automatically creates new Infisical projects based on your Vault structure. + +### Understanding Project Mapping + +Before starting the bulk import, you need to decide how your Vault structure will map to Infisical projects: + + + Each Vault namespace becomes a single Infisical project, with each KV secret engine becoming an environment within that project. + + **Example:** If you have a namespace with 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`): + + - Creates: 1 Infisical project + - Environments: 3 (`dev-secrets`, `staging-secrets`, `prod-secrets`) + + + + Each KV secret engine becomes its own Infisical project with a single `Production` environment. + + **Example:** If you have 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`): + + - Creates: 3 Infisical projects (`dev-secrets`, `staging-secrets`, `prod-secrets`) + - Each project has: 1 environment (`Production`) + + +### How to Perform a Bulk Import + + + + In your Vault instance, create a policy that allows Infisical to read all secrets and metadata. This policy grants read-only access. + + + ```hcl # Allow listing secret engines/mounts path "sys/mounts" { capabilities = ["read", "list"] @@ -63,65 +262,54 @@ There are two options for project mapping: capabilities = ["read", "list"] } ``` + - Save this policy with the name `infisical-migration`. + Save this policy in Vault with the name `infisical-bulk-migration`. - You can use the Vault CLI to easily generate an access token for the new `infisical-migration` policy that you created in the previous step. + Use the Vault CLI to generate an access token: ```bash - vault token create --policy="infisical-migration" + vault token create --policy="infisical-bulk-migration" ``` - After generating the token, you should see the following output: + Copy the `token` value from the output - you'll need it in the next step. - ```t - $ vault token create --policy="infisical-migration" - - Key Value - --- ----- - token - token_accessor p6kJDiBSzYYdabJUIpGCsCBm - token_duration 768h - token_renewable true - token_policies ["default" "infisical-migration"] - identity_policies [] - policies ["default" "infisical-migration"] - ``` - - Copy the `token` field and save it for later, as you'll need this when configuring the migration to Infisical. - - Open the Infisical dashboard and go to Organization Settings > External Migrations. + + In Infisical, navigate to **Organization Settings > External Migrations**. ![Infisical Organization settings](/images/platform/external-migrations/infisical-external-migration-dashboard.png) + + Under the "Bulk Data Import" section, click **"+ Import"**. + - - Select the Vault platform and click on Next. + + Select **HashiCorp Vault** as the migration source and click **Next**. ![Select Vault platform](/images/platform/external-migrations/infisical-import-vault-modal.png) + - - Enter the Vault access token that you generated in the previous step and click Import data. + + Fill in your Vault connection details: ![Configure Vault migration](/images/platform/external-migrations/infisical-import-vault.png) - - `Vault URL`: The URL of your Vault instance. - - `Vault Namespace`: The namespace of your Vault instance. This is optional, and can be left blank if you're not using namespaces for your Vault instance. - - `Vault Access Token`: The access token that you generated in the previous step. + - **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`) + - **Vault Namespace**: Optional - only needed if using Vault Enterprise namespaces + - **Vault Access Token**: The token you generated in step 2 + - **Project Mapping**: Choose how to structure your Infisical projects (see [Understanding Project Mapping](#understanding-project-mapping)) - - `Project Mapping`: Choose how you want to map your Vault resources to Infisical projects. You can review the mapping options in the [Project Mapping](#project-mapping) section. + Click **"Import Data"** to start the migration. - Click on Import data to start the migration. + + The import runs in the background and may take several minutes. You'll receive an email when it completes. + - - - It may take several minutes to complete the migration. You will receive an email when the migration is complete, or if there were any errors during the migration process. - \ No newline at end of file diff --git a/docs/documentation/platform/folder.mdx b/docs/documentation/platform/folder.mdx index a3636a2ea..eab62549e 100644 --- a/docs/documentation/platform/folder.mdx +++ b/docs/documentation/platform/folder.mdx @@ -3,10 +3,10 @@ title: "Folders" description: "Learn how to organize secrets with folders." --- -Infisical Folders enable users to organize secrets using custom structures dependent on the intended use case (also known as **path-based secret storage**). +Infisical Folders enable users to organize secrets using custom structures dependent on the intended use case (also known as **path-based secret storage**). -It is great for organizing secrets around hierarchies with multiple services or types of secrets involved at large quantities. -Infisical Folders can be infinitely nested to mirror your application architecture – whether it's microservices, monorepos, +It is great for organizing secrets around hierarchies with multiple services or types of secrets involved at large quantities. +Infisical Folders can be infinitely nested to mirror your application architecture – whether it's microservices, monorepos, or any logical grouping that best suits your needs. Consider the following structure for a microservice architecture: @@ -22,7 +22,7 @@ Consider the following structure for a microservice architecture: ... ``` -In this example, we store environment variables for each microservice under each respective `/envars` folder. +In this example, we store environment variables for each microservice under each respective `/envars` folder. We also store user-specific secrets for micro-service 1 under `/service1/users`. With this folder structure in place, your applications only need to specify a path like `/microservice1/envars` to fetch secrets from there. By extending this example, you can see how path-based secret storage provides a versatile approach to manage secrets for any architecture. @@ -45,7 +45,27 @@ To delete a folder, hover over it and press the **X** button that appears on the It's possible to compare the contents of folders across environments in the **Secrets Overview** page. When you click on a folder, the table will display the items within it across environments. -In the image below, you can see that the **Development** environment is the only one that contains items +In the image below, you can see that the **Development** environment is the only one that contains items in the `/users` folder, being other folders `/user-a`, `/user-b`, ... `/user-f`. -![comparing folders](../../images/platform/folder/folders-secrets-overview.png) \ No newline at end of file +![comparing folders](../../images/platform/folder/folders-secrets-overview.png) + +### Replicating Folder Contents + +If you want to copy secrets or folders from one path to another, you can utilize the **Replicate Secrets** functionality located in the **Add Secret** dropdown. + +![replicate secrets](../../images/platform/folder/replicate-secrets.png) + +![replicate secrets modal](../../images/platform/folder/replicate-secrets-modal.png) + +First, select the **Source Environment** and the **Source Root Path** you want to copy secrets *from*. In the example provided, we select `/dev-folder` as the source root path from the Development environment. This means any secrets within `/dev-folder` from Development will be replicated. By default, these secrets are copied into the *currently active* folder/path in your target environment (e.g., the root folder of your Staging environment in this scenario). + +As a final step, you can select the specific secrets you wish to copy and then click **Replicate Secrets**. + +![replicate secrets modal](../../images/platform/folder/replicate-secrets-result.png) + +The result shows two secrets successfully copied from the `/dev-folder` in the Development environment into the root folder of the Staging environment. + + + If you do not select a **Source Root Path**, the replication will consider the contents of the *entire root* of the **Source Environment** (e.g., the Development environment). In this example that would mean copying the `/dev-folder` itself rather than just its contents. + diff --git a/docs/documentation/platform/gateways-deprecated/gateway-security.mdx b/docs/documentation/platform/gateways-deprecated/gateway-security.mdx deleted file mode 100644 index 93a7f662f..000000000 --- a/docs/documentation/platform/gateways-deprecated/gateway-security.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: "Gateway Security Architecture" -sidebarTitle: "Architecture" -description: "Understand the security model and tenant isolation of Infisical's Gateway" ---- - -# Gateway Security Architecture - -The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants. -This document explains the internal security architecture and how tenant isolation is maintained. - -## Security Model Overview - -### Private PKI System -Each organization (tenant) in Infisical has its own private PKI system consisting of: - -1. **Root CA**: The ultimate trust anchor for the organization -2. **Intermediate CAs**: - - Client CA: Issues certificates for cloud components - - Gateway CA: Issues certificates for gateway instances - -This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain. - -### Certificate Hierarchy -``` -Root CA (Organization Specific) -├── Client CA -│ └── Client Certificates (Cloud Components) -└── Gateway CA - └── Gateway Certificates (Gateway Instances) -``` - -## Communication Security - -### 1. Gateway Registration -When a gateway is first deployed: - -1. Establishes initial connection using machine identity token -2. Allocates a relay address for communication -3. Exchanges certificates through a secure handshake: - - Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification - -### 2. Mutual TLS Authentication -All communication between gateway and cloud uses mutual TLS (mTLS): - -- **Gateway Authentication**: - - Presents certificate signed by organization's Gateway CA - - Certificate contains unique identifiers (Organization ID, Gateway ID) - - Cloud validates complete certificate chain - -- **Cloud Authentication**: - - Presents certificate signed by organization's Client CA - - Certificate includes required organizational unit ("gateway-client") - - Gateway validates certificate chain back to organization's root CA - -### 3. Relay Communication -The relay system provides secure tunneling: - -1. **Connection Establishment**: - - Uses QUIC protocol over UDP for efficient, secure communication - - Provides built-in encryption, congestion control, and multiplexing - - Enables faster connection establishment and reduced latency - - Each organization's traffic is isolated using separate relay sessions - -2. **Traffic Isolation**: - - Each gateway gets unique relay credentials - - Traffic is end-to-end encrypted using QUIC's TLS 1.3 - - Organization's private keys never leave their environment - -## Tenant Isolation - -### Certificate-Based Isolation -- Each organization has unique root CA and intermediate CAs -- Certificates contain organization-specific identifiers -- Cross-tenant communication is cryptographically impossible - -### Gateway-Project Mapping -- Gateways are explicitly mapped to specific projects -- Access controls enforce organization boundaries -- Project-level permissions determine resource accessibility - -### Resource Access Control -1. **Project Verification**: - - Gateway verifies project membership - - Validates organization ownership - - Enforces project-level permissions - -2. **Resource Restrictions**: - - Gateways only accept connections to approved resources - - Each connection requires explicit project authorization - - Resources remain private to their assigned organization diff --git a/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png b/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png deleted file mode 100644 index 5f942bcf0..000000000 Binary files a/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png and /dev/null differ diff --git a/docs/documentation/platform/gateways-deprecated/networking.mdx b/docs/documentation/platform/gateways-deprecated/networking.mdx deleted file mode 100644 index 6acdc1993..000000000 --- a/docs/documentation/platform/gateways-deprecated/networking.mdx +++ /dev/null @@ -1,168 +0,0 @@ ---- -title: "Networking" -description: "Network configuration and firewall requirements for Infisical Gateway" ---- - -The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. -This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. - -## Network Architecture - -The gateway uses a relay-based architecture to establish secure connections: - -1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol -2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud -3. All traffic is end-to-end encrypted using mutual TLS over QUIC - -## Required Network Connectivity - -### Outbound Connections (Required) - -The gateway requires the following outbound connectivity: - -| Protocol | Destination | Ports | Purpose | -|----------|-------------|-------|---------| -| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | -| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | - -### Relay Server IP Addresses - -Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. - - - - ``` - 54.235.197.91:49152-65535 - 18.215.196.229:49152-65535 - 3.222.120.233:49152-65535 - 34.196.115.157:49152-65535 - ``` - - - ``` - 3.125.237.40:49152-65535 - 52.28.157.98:49152-65535 - 3.125.176.90:49152-65535 - ``` - - - Please contact your Infisical account manager for dedicated relay server IP addresses. - - - - - These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. - - -## Protocol Details - -### QUIC over UDP - -The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: - -- **Port 5349**: STUN/TURN over TLS (secure relay communication) -- **Built-in features**: Connection migration, multiplexing, reduced latency -- **Encryption**: TLS 1.3 with certificate pinning - -## Understanding Firewall Behavior with UDP - -Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. -When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. -Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. - - -### Connection Tracking - -Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: -- Automatically handles return responses -- Reduces firewall rule complexity -- Avoids the need for manual IP whitelisting - -In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. - -## Common Network Scenarios - -### Corporate Firewalls - -For corporate environments with strict egress filtering: - -1. **Whitelist relay IP addresses** (listed above) -2. **Allow UDP port 5349** outbound -3. **Configure connection tracking** for UDP return traffic -4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled - -### Cloud Environments (AWS/GCP/Azure) - -Configure security groups to allow: -- **Outbound UDP** to relay IPs on port 5349 -- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 -- **Inbound UDP** on ephemeral ports (if not using stateful rules) - -## Frequently Asked Questions - - -The gateway is designed to handle network interruptions gracefully: - -- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost -- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention -- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers -- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions -- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity - -No manual intervention is typically required during network interruptions. - - - -QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: - -- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time -- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default -- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) -- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other -- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery -- **Lower latency**: Optimized for real-time communication between gateway and cloud services - -While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. - - - -No inbound ports need to be opened. The gateway only makes outbound connections: - -- **Outbound UDP** to relay servers on ports 49152-65535 -- **Outbound HTTPS** to Infisical API endpoints -- **Return responses** are handled by connection tracking or explicit IP whitelisting - -This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. - - - -If your firewall has strict UDP restrictions: - -1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses -2. **Use explicit IP whitelisting** if connection tracking is disabled -3. **Consider network policy exceptions** for the gateway host -4. **Monitor firewall logs** to identify which specific rules are blocking traffic - -The gateway requires UDP connectivity to function - TCP-only configurations are not supported. - - - -The gateway connects to **one relay server at a time**: - -- **Single active connection**: Only one relay connection is established per gateway instance -- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay -- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing -- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity - -You should whitelist all relay IP addresses to ensure proper failover functionality. - - -No, relay servers cannot decrypt any traffic passing through them: - -- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning -- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys -- **No data storage**: Relay servers do not store any traffic or network-identifiable information -- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation - -The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. - \ No newline at end of file diff --git a/docs/documentation/platform/gateways-deprecated/overview.mdx b/docs/documentation/platform/gateways-deprecated/overview.mdx deleted file mode 100644 index f81809f7b..000000000 --- a/docs/documentation/platform/gateways-deprecated/overview.mdx +++ /dev/null @@ -1,352 +0,0 @@ ---- -title: "Gateway" -sidebarTitle: "Overview" -description: "How to access private network resources from Infisical" ---- - -![Alt text](/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png) - -The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. -This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. -Common use cases include generating dynamic credentials or rotating credentials for private databases. - - - Gateway is a paid feature available under the Enterprise Tier for Infisical - Cloud users. Self-hosted Infisical users can contact - [sales@infisical.com](mailto:sales@infisical.com) to purchase an enterprise - license. - - -## How It Works - -The Gateway serves as a secure intermediary that facilitates direct communication between the Infisical server and your private network. -It’s a lightweight daemon packaged within the Infisical CLI, making it easy to deploy and manage. Once set up, the Gateway establishes a connection with a relay server, ensuring that all communication between Infisical and your Gateway is fully end-to-end encrypted. -This setup guarantees that only the platform and your Gateway can decrypt the transmitted information, keeping communication with your resources secure, private and isolated. - -## Deployment - -The Infisical Gateway is seamlessly integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. -You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. -For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). - -To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. -Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. - -### Get started - - - - 1. Navigate to **Organization Access Control** in your Infisical dashboard. - 2. Create a dedicated machine identity for your Gateway. - 3. **Best Practice:** Assign a unique identity to each Gateway for better security and management. - ![Create Gateway Identity](../../../images/platform/gateways/create-identity-for-gateway.png) - - - - You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). - - - - Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: - - - - For production deployments on Linux, install the Gateway as a systemd service: - ```bash - sudo infisical gateway install --token --domain - sudo systemctl start infisical-gateway - ``` - This will install and start the Gateway as a secure systemd service that: - - Runs with restricted privileges: - - Runs as root user (required for secure token management) - - Restricted access to home directories - - Private temporary directory - - Automatically restarts on failure - - Starts on system boot - - Manages token and domain configuration securely in `/etc/infisical/gateway.conf` - - - The install command requires: - - Linux operating system - - Root/sudo privileges - - Systemd - - - - - - The Gateway can be installed via [Helm](https://helm.sh/). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. - - For production deployments on Kubernetes, install the Gateway using the Infisical Helm chart: - - ### Install the latest Helm Chart repository - ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - ``` - - ### Update the Helm Chart repository - ```bash - helm repo update - ``` - - ### Create a Kubernetes Secret containing gateway environment variables - - The gateway supports all identity authentication methods through the use of environment variables. - The environment variables must be set in the `infisical-gateway-environment` Kubernetes secret. - - - #### Supported authentication methods - - - - The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - The authentication method to use. Must be `universal-auth` when using Universal Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=universal-auth --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= - ``` - - - - The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. - - - - - Your machine identity ID. - - - Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. - - - The authentication method to use. Must be `kubernetes` when using Native Kubernetes. - - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=kubernetes --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The Native Azure method is used to authenticate with Infisical when running in an Azure environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `azure` when using Native Azure. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=azure --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-id-token --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. - - - - - Your machine identity ID. - - - Path to your GCP service account key file _(Must be in JSON format!)_ - - - The authentication method to use. Must be `gcp-iam` when using GCP IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH= - ``` - - - - - The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `aws-iam` when using Native AWS IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=aws-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. - - - - - Your machine identity ID. - - - The OIDC JWT from the identity provider. - - - The authentication method to use. Must be `oidc-auth` when using OIDC Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=oidc-auth --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_JWT= - ``` - - - - The JWT Auth method is used to authenticate with Infisical via a JWT token. - - - - - The JWT token to use for authentication. - - - Your machine identity ID. - - - The authentication method to use. Must be `jwt-auth` when using JWT Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=jwt-auth --from-literal=INFISICAL_JWT= --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. - - - - - The machine identity access token to use for authentication. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_TOKEN= - ``` - - - - - #### Other environment variables - - - - The API URL to use for the gateway. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. - - - - - ### Install the Infisical Gateway Helm Chart - ```bash - helm install infisical-gateway infisical-helm-charts/infisical-gateway - ``` - - ### Check the gateway logs - After installing the gateway, you can check the logs to ensure it's running as expected. - - ```bash - kubectl logs deployment/infisical-gateway - ``` - - You should see the following output which indicates the gateway is running as expected. - ```bash - $ kubectl logs deployment/infisical-gateway - INF Provided relay port 5349. Using TLS - INF Connected with relay - INF 10.0.101.112:56735 - INF Starting relay connection health check - INF Gateway started successfully - INF New connection from: 10.0.1.8:34051 - INF Gateway is reachable by Infisical - ``` - - - - - For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: - ```bash - infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) - ``` - - Alternatively, if you already have the token, use it directly with the `--token` flag: - ```bash - infisical gateway --token - ``` - - Or set it as an environment variable: - ```bash - export INFISICAL_TOKEN= - infisical gateway - ``` - - - - For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway). - - - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical. - - - - - - To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list. - ![Gateway List](../../../images/platform/gateways/gateway-list.png) - - diff --git a/docs/documentation/platform/gateways/gateway-deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx index 40b98965a..9a5b7d816 100644 --- a/docs/documentation/platform/gateways/gateway-deployment.mdx +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -3,7 +3,7 @@ title: "Gateway Deployment" description: "Complete guide to deploying Infisical Gateways including network configuration and firewall requirements" --- -Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. +Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. This guide covers everything you need to deploy and configure Infisical Gateways. ## Deployment Steps @@ -18,73 +18,73 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order Simple and secure authentication using client ID and client secret. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=universal-auth` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - + Direct authentication using a machine identity access token. - + **Environment Variables:** - `INFISICAL_TOKEN=` - + Authentication using Kubernetes service account tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=kubernetes` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using AWS IAM roles. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=aws-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-id-token` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP service account keys. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - + Authentication using Azure managed identity. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=azure` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using OIDC identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=oidc-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_JWT=` - + Authentication using JWT tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=jwt-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` @@ -95,7 +95,7 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order Ensure a relay server is running and accessible before you deploy any gateways. You have two options: - **Managed relay (Infisical Cloud, US/EU only):** Managed relays are only available for Infisical Cloud instances in the US and EU regions. If you are using Infisical Cloud in these regions, you can use the provided managed relay. - - **Self-hosted relay:** For all other cases, including all self-hosted and dedicated enterprise instances of Infisical, you must deploy your own relay server. You can also choose to deploy your own relay server when using Infisical Cloud if you require reduced geographic proximity to your target resources for lower latency or to reduce network congestion. For setup instructions, see the Relay Deployment Guide. + - **Self-hosted relay:** For all other cases, including all self-hosted and dedicated enterprise instances of Infisical, you must deploy your own relay server. You can also choose to deploy your own relay server when using Infisical Cloud if you require reduced geographic proximity to your target resources for lower latency or to reduce network congestion. For setup instructions, see the [Relay Deployment Guide](/documentation/platform/gateways/relay-deployment). Make sure the Infisical CLI is installed on the machine or environment where you plan to deploy the gateway. The CLI is required for gateway installation and management. @@ -111,7 +111,7 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order | TCP | Infisical instance host (US/EU, other) | 443 | API communication and certificate requests | For managed relays, allow outbound traffic to the provided relay server IP/hostname. For self-hosted relays, allow outbound traffic to your own relay server address. - + If you are in a corporate environment with strict egress filtering, ensure outbound TCP 2222 to relay servers and outbound HTTPS 443 to Infisical API endpoints are allowed. @@ -185,10 +185,9 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order - - - ## Frequently Asked Questions + + No inbound ports need to be opened for gateways. The gateway only makes outbound connections: @@ -263,3 +262,4 @@ The gateway is designed to handle network interruptions gracefully: No manual intervention is typically required during network interruptions. + diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index c04f2f7d3..33945337b 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -6,8 +6,8 @@ description: "How to access private network resources from Infisical" ![Architecture Overview](../../../images/platform/gateways/gateway-highlevel-diagram.png) -The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. -This is particularly useful when Infisical isn't hosted within the same network as the resources it needs to reach. +The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. +This is particularly useful when Infisical isn't hosted within the same network as the resources it needs to reach. This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. @@ -25,7 +25,7 @@ The Gateway system consists of two primary components working together to enable A Gateway is a lightweight service that you deploy within your own network infrastructure to provide secure access to your private resources. Think of it as a secure bridge between Infisical and your internal systems. - Gateways must be deployed within the same network where your target resources are located, with direct network connectivity to the private resources you want Infisical to access. + Gateways must be deployed within the same network where your target resources are located, with direct network connectivity to the private resources you want Infisical to access. For different networks, regions, or isolated environments, you'll need to deploy separate gateways. **Core Functions:** @@ -40,7 +40,7 @@ The Gateway system consists of two primary components working together to enable **Core Functions:** - **Traffic Routing**: Routes encrypted traffic between the Infisical platform and your gateways without storing or inspecting the data - - **Network Isolation**: Enables secure communication without requiring direct network connections between Infisical and your private infrastructure + - **Network Isolation**: Enables secure communication without requiring direct network connections between Infisical and your private infrastructure - **Authentication Management**: Validates SSH certificates and manages secure routing between authenticated gateways **Deployment Options:** @@ -59,6 +59,12 @@ The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connec 3. **Request Routing**: When Infisical needs to access your resources, requests are routed through the relay server to the already-established gateway connection 4. **Resource Access**: The gateway receives the routed requests and connects to your private resources on behalf of Infisical +## Health Check + +To monitor their operational status, both gateways and relays transmit hourly heartbeats. A component is considered unhealthy if a heartbeat is not received for over an hour. + +Infisical automatically notifies all organization admins of unhealthy gateway or relay statuses through email and in-app notifications. + ## Getting Started Ready to set up your gateway? Follow the guides below. @@ -75,4 +81,4 @@ Ready to set up your gateway? Follow the guides below. Learn about the security model and implementation best practices. - \ No newline at end of file + diff --git a/docs/documentation/platform/gateways/relay-deployment.mdx b/docs/documentation/platform/gateways/relay-deployment/overview.mdx similarity index 95% rename from docs/documentation/platform/gateways/relay-deployment.mdx rename to docs/documentation/platform/gateways/relay-deployment/overview.mdx index adf5fdb9d..ba7689196 100644 --- a/docs/documentation/platform/gateways/relay-deployment.mdx +++ b/docs/documentation/platform/gateways/relay-deployment/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Relay Deployment" +title: "Overview" description: "How to deploy Infisical Relay Servers" --- @@ -32,73 +32,73 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. Simple and secure authentication using client ID and client secret. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=universal-auth` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - + Direct authentication using a machine identity access token. - + **Environment Variables:** - `INFISICAL_TOKEN=` - + Authentication using Kubernetes service account tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=kubernetes` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using AWS IAM roles. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=aws-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-id-token` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP service account keys. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - + Authentication using Azure managed identity. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=azure` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using OIDC identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=oidc-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_JWT=` - + Authentication using JWT tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=jwt-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` @@ -107,13 +107,8 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. - - Install the Infisical CLI on the server where you plan to deploy the relay. The CLI is required for relay installation and management. - - See the [CLI Installation Guide](/cli/overview) for instructions. - - This server must have a static IP address or DNS name to be identifiable by the Infisical platform. - + + Provision a server or virtual machine where you plan to deploy the relay. This server must have a static IP address or DNS name to be identifiable by the Infisical platform. @@ -133,6 +128,8 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. + You can deploy the Infisical Relay in various ways. This guide provides a manual setup example using the Infisical CLI. For an infrastructure-as-code approach, see our [Terraform guide](/documentation/platform/gateways/relay-deployment/terraform). + The Infisical CLI is used to install and start the relay in your chosen environment. The CLI provides commands for both production and development scenarios, and supports a variety of options/flags to configure your deployment. To view all available flags and equivalent environment variables for relay deployment, see the [Relay CLI Command Reference](/cli/commands/relay). @@ -168,7 +165,6 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. ```bash infisical relay start \ - --type= \ --host= \ --name= \ --auth-method= @@ -185,6 +181,7 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. ## Frequently Asked Questions + No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: @@ -241,3 +238,4 @@ Relay server outages affect gateway connectivity: For production environments, consider deploying multiple relay servers to avoid single points of failure. + diff --git a/docs/documentation/platform/gateways/relay-deployment/terraform.mdx b/docs/documentation/platform/gateways/relay-deployment/terraform.mdx new file mode 100644 index 000000000..e89871cd9 --- /dev/null +++ b/docs/documentation/platform/gateways/relay-deployment/terraform.mdx @@ -0,0 +1,151 @@ +--- +title: "Terraform" +description: "How to deploy Infisical Relay Servers using Terraform" +--- + +This guide walks you through deploying an Infisical Relay server using Terraform. Select a provider below for specific instructions. + + + +The provided configuration automates the creation of the EC2 instance, sets up the necessary security group rules, and uses a startup script to install and configure the Infisical Relay service. + +### Prerequisites + +Before you start, make sure you have the following: +- An AWS account with permissions to create EC2 instances, Security Groups, and Elastic IPs. +- An existing VPC and Subnet ID in your desired AWS region. +- The AMI ID for your chosen OS (this guide uses an Ubuntu 22.04 LTS AMI). +- Credentials for the Infisical Relay to authenticate with your Infisical instance. This guide uses a Machine Identity token, but other methods are available. You can find a full list of authentication options [here](/cli/commands/relay#available-authentication-methods). + +### Terraform Configuration + +Here is the complete Terraform configuration to deploy the Infisical Relay. + +```terraform +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "us-west-2" # Change to your desired AWS region +} + +# Security Group for the Infisical Relay instance +resource "aws_security_group" "infisical_relay_sg" { + name = "infisical-relay-sg" + description = "Allows inbound traffic for Infisical Relay and SSH" + vpc_id = "vpc-0c71f9c5709d88d18" # Change to your VPC ID + + # Inbound: Allows the Infisical platform to securely communicate with the Relay server. + ingress { + from_port = 8443 + to_port = 8443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Inbound: Allows Infisical Gateway to securely communicate via the Relay. + ingress { + from_port = 2222 + to_port = 2222 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Inbound: Allows secure shell (SSH) access for administration. + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # Restrict this to your IP in production + } + + # Outbound: Allows the Relay server to make necessary outbound connections to the Infisical platform. + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "infisical-relay-sg" + } +} + +# Elastic IP for a static public IP address +resource "aws_eip" "infisical_relay_eip" { + tags = { + Name = "infisical-relay-eip" + } +} + +# EC2 instance to run Infisical Relay +module "infisical_relay_instance" { + source = "terraform-aws-modules/ec2-instance/aws" + version = "~> 5.6" + + name = "infisical-relay-example" + ami = "ami-065778886ef8ec7c8" # Change to your desired AMI ID + instance_type = "t3.micro" + subnet_id = "subnet-0fd2337a1c604a494" # Change to your Subnet ID + + vpc_security_group_ids = [aws_security_group.infisical_relay_sg.id] + associate_public_ip_address = false # We are using an Elastic IP instead + + user_data = <<-EOT + #!/bin/bash + set -e + # Install Infisical CLI + curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash + apt-get update && apt-get install -y infisical + + # Install the relay as a systemd service. + # This example uses a Machine Identity token for authentication via the INFISICAL_TOKEN environment variable. + # + # Note: For production environments, you might consider fetching the token from AWS Parameter Store or AWS Secrets Manager. + export INFISICAL_TOKEN="your-machine-identity-token" + sudo -E infisical relay systemd install \ + --name "my-relay-example" \ + --domain "https://app.infisical.com" \ + --host "${aws_eip.infisical_relay_eip.public_ip}" + + # Start and enable the service to run on boot + sudo systemctl start infisical-relay + sudo systemctl enable infisical-relay + EOT +} + +# Associate the Elastic IP with the EC2 instance +resource "aws_eip_association" "eip_assoc" { + instance_id = module.infisical_relay_instance.id + allocation_id = aws_eip.infisical_relay_eip.id +} +``` + + +The provided security group rules are open to the internet (`0.0.0.0/0`) for simplicity. In a production environment, you should restrict the `cidr_blocks` to known IP addresses for enhanced security, especially for the SSH port (22). + + +### How to Deploy + +1. **Save the configuration:** Save the code above to a file named `main.tf`. +2. **Customize values:** Update the placeholder values in `main.tf` to match your AWS environment and Infisical credentials. You'll need to replace: + - `region` in the `provider` block. + - `vpc_id` in the `aws_security_group` resource. + - `ami` and `subnet_id` in the `infisical_relay_instance` module. + - The `INFISICAL_TOKEN` environment variable in the `user_data` script (e.g., `export INFISICAL_TOKEN="your-machine-identity-token"`). + - The `--domain` in the `user_data` script if you are self-hosting Infisical. +3. **Apply the configuration:** Run the following Terraform commands in your terminal: + ```bash + terraform init + terraform plan + terraform apply + ``` + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 7e40f85f9..d7b7663a9 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -13,7 +13,7 @@ Each identity must authenticate with the Infisical API using a supported authent Key Features: -- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. +- Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. - Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API. ## Workflow diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index c7d4d32fa..7a8d15fe5 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -36,7 +36,6 @@ Enabling HSM encryption has a set of key benefits: ### Requirements - An Infisical instance with a version number that is equal to or greater than `v0.91.0`. -- If you are using Docker, your instance must be using the `infisical/infisical-fips` image. - An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others. @@ -238,7 +237,7 @@ Enabling HSM encryption has a set of key benefits: -e DB_CONNECTION_URI="<>" \ -e REDIS_URL="<>" \ -e SITE_URL="<>" \ - infisical/infisical-fips: # Replace with the version you want to use + infisical/infisical: # Replace with the version you want to use ``` We recommend reading further about [using Infisical with Docker](/self-hosting/deployment-options/standalone-infisical). @@ -309,7 +308,7 @@ Enabling HSM encryption has a set of key benefits: -e DB_CONNECTION_URI="<>" \ -e REDIS_URL="<>" \ -e SITE_URL="<>" \ - infisical/infisical-fips: # Replace with the version you want to use + infisical/infisical: # Replace with the version you want to use ``` @@ -319,6 +318,192 @@ Enabling HSM encryption has a set of key benefits: After following these steps, your Docker setup will be ready to use Fortanix HSM encryption. + + + + + + ### Prerequisites + + - An [activated AWS CloudHSM cluster](https://docs.aws.amazon.com/cloudhsm/latest/userguide/activate-cluster.html) with at least 1 HSM device. + - A [HSM user with the `Crypto User` role](https://docs.aws.amazon.com/cloudhsm/latest/userguide/cloudhsm_cli-user-create.html). In this guide we are using a user with the username `testUser` and the password `testPassword`. + + + + + + Before using the CloudHSM client, it must be configured properly so Infisical can use it for cryptographic operations. + + + **1. Download the AWS CloudHSM client** + + You can download the AWS CloudHSM client from [the AWS documentation](https://docs.aws.amazon.com/cloudhsm/latest/userguide/pkcs11-library-install.html). + + + Note that the AWS CloudHSM client is only available for Linux and Windows. + If you're on a different operating system, you'll need to access a Linux machine to configure the client, such as an AWS EC2 Debian instance. + + + **2. Configure the CloudHSM client** + + After installing the CloudHSM client, you should see all related files in the `/opt/cloudhsm/` directory on your machine. + + You need to run the `configure-pkcs11` binary which will configure the client to connect with your AWS CloudHSM cluster. Depending on if you have multiple HSM's inside your cluster, you'll need to run the command with different arguments. Below you'll find the appropriate command for your use case: + + + + + + ```bash + sudo /opt/cloudhsm/bin/configure-pkcs11 -a --disable-key-availability-check + ``` + + + To use a single HSM, you must first manage client key durability settings by setting `disable_key_availability_check` to true by passing the `--disable-key-availability-check` flag. For more information read the [Key Synchronization](https://docs.aws.amazon.com/cloudhsm/latest/userguide/manage-key-sync.html) section in the AWS CloudHSM documentation. + + + + + ```bash + sudo /opt/cloudhsm/bin/configure-pkcs11 -a ... --disable-key-availability-check + ``` + + + + At this point you should have: + 1. [Activated the CloudHSM cluster](https://docs.aws.amazon.com/cloudhsm/latest/userguide/activate-cluster.html) + 2. [Created a Crypto User HSM user](https://docs.aws.amazon.com/cloudhsm/latest/userguide/cloudhsm_cli-user-create.html) + 3. Downloaded and configured the CloudHSM client as described in the previous steps. + + **3. Download the configured HSM client files** + + After configuring the CloudHSM client, you should notice that the PKCS11 configuration file has been updated to include the HSM's ENI IP address. You can find this file in the `/opt/cloudhsm/etc/cloudhsm-pkcs11.cfg` directory, and it should look like this: + + ```json cloudhsm-pkcs11.cfg + { + "clusters": [ + { + "type": "hsm1", + "cluster": { + // Your issuing CA certificate. + // As per AWS documentation, this defaults to `/opt/cloudhsm/etc/customerCA.crt`. + "hsm_ca_file": "/opt/cloudhsm/etc/customerCA.crt", + "servers": [ + { + "hostname": "", + "port": 2223, + "enable": true + }, + { + "hostname": "", + "port": 2223, + "enable": true + } + ], + // Only relevant if you passed the --disable-key-availability-check flag + "options": { + "disable_key_availability_check": true + } + } + } + ], + "logging": { + "log_type": "file", + "log_file": "/opt/cloudhsm/run/cloudhsm-pkcs11.log", + "log_level": "info", + "log_interval": "daily" + } + } + ``` + + Save the entire `/opt/cloudhsm` folder, as you will need to mount this to your Infisical Docker container in the later steps. In this guide we will be saving all the files from the folder as `/etc/cloudhsm` and mounting it to the `/etc/cloudhsm` directory in the Docker container. + + + + + On the same machine that you configured the CloudHSM client, you can use `pkcs11-tool` to find the HSM slot number and to verify that the client is working correctly. + + First, install the `pkcs11-tool` package: + + ```bash + sudo apt-get install opensc -y + ``` + + Then, run the following command to find the HSM slot number: + + ```bash + pkcs11-tool --module /opt/cloudhsm/lib/libcloudhsm_pkcs11.so --list-slots --login + ``` + + It'll prompt you to log in with your PIN, which is your username and password separated by a colon. Example: `testUser:testPassword`. + + This will output the HSM slot number like so: + + ```bash + ubuntu@ec-2:~$ pkcs11-tool --module /opt/cloudhsm/lib/libcloudhsm_pkcs11.so --list-slots + Available slots: + Slot 0 (0x2000000000000001): hsm1 + token label : hsm1 + token manufacturer : Marvell Semiconductors, Inc. + token model : LS2 + token flags : login required, rng, token initialized + hardware version : 66.48 + firmware version : 10.2 + serial num : + pin min/max : 8/32 + ``` + + In this case we see that the HSM has a slot in the position of `0`. This slot number will be used in the later steps to set the `HSM_SLOT` environment variable. + + + + When you initialized your HSM, you were prompted to download the cluster CSR and sign it. + In order to use the HSM with Infisical, you need to obtain the issuer CA certificate that was used to sign the cluster CSR. + + If you followed [the official AWS documentation](https://docs.aws.amazon.com/cloudhsm/latest/userguide/initialize-cluster.html), you should have a CA certificate called `customerCA.crt`. + + Save the CA certificate to a path, as this will need to be mounted as a Docker volume in the next step. For this example, we'll save it to `/aws-files/customerCA.crt`. + + + + Running Docker with HSM encryption requires setting the HSM-related environment variables as mentioned previously in the [HSM setup instructions](#setup-instructions). You can set these environment variables in your Docker run command. + + We are setting the environment variables for Docker via the command line in this example, but you can also pass in a `.env` file to set these environment variables. + + + If no key is found with the provided key label, the HSM will create a new key with the provided label. + Infisical depends on an AES and HMAC key to be present in the HSM. If these keys are not present, Infisical will create them. The AES key label will be the value of the `HSM_KEY_LABEL` environment variable, and the HMAC key label will be the value of the `HSM_KEY_LABEL` environment variable with the suffix `_HMAC`. + + + ```bash + docker run -p 80:8080 \ + + # Mount the HSM client files to "/opt/cloudhsm" + -v /etc/cloudhsm:/opt/cloudhsm \ + # Mount the issuer CA certificate to "/opt/cloudhsm/etc/customerCA.crt" + -v /aws-files/customerCA.crt:/opt/cloudhsm/etc/customerCA.crt \ + + # Set the HSM library path to whats expected within Docker (/opt/cloudhsm/lib/libcloudhsm_pkcs11.so) + -e HSM_LIB_PATH="/opt/cloudhsm/lib/libcloudhsm_pkcs11.so" \ + # Set the HSM PIN to the username and password of the HSM user, separated by a colon + -e HSM_PIN=CryptoUserUsername:CryptoUserPassword \ + # Set the HSM slot number to the slot number of the HSM device as found in the previous step + -e HSM_SLOT= \ + # Set the HSM key label to a label that will be used to identify the encryption key in the HSM. This key label does not need to exist before hand. + -e HSM_KEY_LABEL=infisical-crypto-key \ + + # The rest of your environment variables ... + # -e ... + infisical/infisical: # Replace with the version you want to use + ``` + + We recommend reading further about [using Infisical with Docker](/self-hosting/deployment-options/standalone-infisical). + + + + After following these steps, your Docker setup will be ready to use HSM encryption. + + @@ -326,8 +511,9 @@ Enabling HSM encryption has a set of key benefits: + - This is only supported on helm chart version `1.4.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. + This is only supported on helm chart version `1.7.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. @@ -591,13 +777,11 @@ Enabling HSM encryption has a set of key benefits: After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files. - We need to update the Docker image of the deployment to use `infisical/infisical-fips`. The `infisical/infisical-fips` image is a functionally identical image to the `infisical/infisical` image, but it is built with HSM support. - ```yaml # ... The rest of the values.yaml file ... image: - repository: infisical/infisical-fips # Very important: Must use "infisical/infisical-fips" + repository: infisical/infisical tag: "v0.117.1-postgres" pullPolicy: IfNotPresent @@ -757,13 +941,13 @@ Enabling HSM encryption has a set of key benefits: - Update your Helm values to use the FIPS-compliant image and mount the Fortanix HSM files: + Update your Helm values to mount the Fortanix HSM files: ```yaml # ... The rest of the values.yaml file ... image: - repository: infisical/infisical-fips # Must use "infisical/infisical-fips" + repository: infisical/infisical tag: "v0.117.1-postgres" pullPolicy: IfNotPresent @@ -800,6 +984,493 @@ Enabling HSM encryption has a set of key benefits: After following these steps, your Kubernetes setup will be ready to use Fortanix HSM encryption. + + + + ### Prerequisites + + - An [activated AWS CloudHSM cluster](https://docs.aws.amazon.com/cloudhsm/latest/userguide/activate-cluster.html) with at least 1 HSM device. + - A [HSM user with the `Crypto User` role](https://docs.aws.amazon.com/cloudhsm/latest/userguide/cloudhsm_cli-user-create.html). In this guide we are using a user with the username `testUser` and the password `testPassword`. + - A Kubernetes cluster + + + AWS CloudHSM is supported on helm chart version `1.7.1` and above. Please see the [Helm Chart Changelog](https://github.com/Infisical/infisical/blob/main/helm-charts/infisical-standalone-postgres/CHANGELOG.md#141-march-19-2025) for more information. + + + + + + + If you're using AWS EKS, you need to specify a storage class for the PVC and ensure that the EBS CSI Driver is installed and running. + + By default, EKS exposes `gp2` as the default storage class. Below are the steps required for setting the default storage class and ensuring the EBS CSI Driver is installed and running: + + + + + + Enable OIDC authentication for the EKS cluster: + ```bash + eksctl utils associate-iam-oidc-provider \ + --region \ + --cluster \ + --approve + ``` + + * Replace `` with your AWS region. + * Replace `` with your cluster name. + + + + + + 1. Check if EBS CSI Driver is installed and running by running the following command: + + ```bash + kubectl get pods -n kube-system | grep ebs-csi + ``` + + If you see no pods, you need to install the EBS CSI Driver as seen in the next step. + + + + Create a new IAM service account for the EBS CSI Driver: + + ```bash + eksctl create iamserviceaccount \ + --name ebs-csi-controller-sa \ + --namespace kube-system \ + --region \ + --cluster \ + --attach-policy-arn arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy \ + --approve \ + --role-name AmazonEKS_EBS_CSI_DriverRole + ``` + + * Replace `` with your cluster name. + * Replace `` with your AWS region. + + Install the EBS CSI Driver: + + ```bash + eksctl create addon \ + --name aws-ebs-csi-driver \ + --cluster \ + --region \ + --service-account-role-arn arn:aws:iam:::role/AmazonEKS_EBS_CSI_DriverRole \ + --force + ``` + + * Replace `` with your cluster name. + * Replace `` with your AWS region. + * Replace `` with your actual account ID. Can be obtained by running `aws sts get-caller-identity --query Account --output text`. + + + + Verify the EBS CSI Driver is installed and running by running the following command: + + ```bash + kubectl get pods -n kube-system | grep ebs-csi + ``` + + You should see an output like this: + + ```bash + kubectl get pods -n kube-system | grep ebs-csi + ebs-csi-controller-6b6bbf996-rvf8r 6/6 Running 0 21s + ebs-csi-controller-6b6bbf996-vk4ng 6/6 Running 0 21s + ebs-csi-node-c6vbb 3/3 Running 0 21s + ebs-csi-node-s9zlr 3/3 Running 0 21s + ``` + + + + You can find the enabled storage class by running the following command: + + ```bash + kubectl get storageclass + ``` + + You should see an output like this: + + ```bash + $ kubectl get storageclass + + NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE + gp2 kubernetes.io/aws-ebs Delete WaitForFirstConsumer false 65m + ``` + + In this case, the enabled storage class is `gp2`. + + + + You can set the default PVC storage class by patching the storage class with the following command: + + ```bash + kubectl patch storageclass gp2 -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}' + ``` + + This will set the `gp2` storage class as the default storage class. + + + Now when you run `kubectl get storageclass`, you should see that `gp2` is the default storage class. + + ```bash + $ kubectl get storageclass + + NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE + gp2 (default) kubernetes.io/aws-ebs Delete WaitForFirstConsumer false 68m + ``` + + Notice the `(default)` next to the `gp2` storage class. + + + + + You need to create a Persistent Volume Claim (PVC) to mount the HSM client files to the Infisical deployment. + + ```bash + kubectl apply -f - < + + + + We need to configure the PVC to work with the CloudHSM, so Infisical can consume the HSM client files. + + **2.1. Start a shell in the PVC pod:** + + This will allow us to run commands directly within the setup pod. We'll use this to configure the CloudHSM client and to validate that it's working correctly. + + ```bash + kubectl exec -it cloudhsm-setup-pod -- /bin/sh + ``` + + **2.2. Install the necessary packages:** + + This will install the necessary packages to allow us to test and install the CloudHSM client. + + ```bash + apt-get update -y + apt-get install opensc telnet wget -y + ``` + + **2.3. Try to reach the HSM device:** + + We need to validate that we're able to reach the HSM device from within Kubernetes. You can use telnet to ping the HSM device like so: + + ```bash + telnet 2223 + ``` + + You should see an output like this: + ```bash + $ telnet 2223 + Trying ... + Connected to . + ``` + + If it gets stuck on `Trying ....`, you may have configured your HSM client's security group incorrectly. Make sure you configure the security group to allow traffic from EKS on port 2223-2225. + + **2.4. Install the AWS CloudHSM client:** + + The Infisical images run on Debian, so we need to install a Debian-compatible version of the AWS CloudHSM client. + + ```bash + wget https://s3.amazonaws.com/cloudhsmv2-software/CloudHsmClient/Jammy/cloudhsm-pkcs11_latest_u22.04_amd64.deb + apt-get install ./cloudhsm-pkcs11_latest_u22.04_amd64.deb -y + ``` + + **2.5. Configure the CloudHSM client:** + + After installing the CloudHSM client, you should see all related files in the `/opt/cloudhsm/` directory on the CloudHSM setup pod. + + You need to run the `configure-pkcs11` binary which will configure the client to connect with your AWS CloudHSM cluster. Depending on if you have multiple HSM's inside your cluster, you'll need to run the command with different arguments. Below you'll find the appropriate command for your use case: + + + + + ```bash + /opt/cloudhsm/bin/configure-pkcs11 -a --disable-key-availability-check + ``` + + + To use a single HSM, you must first manage client key durability settings by setting `disable_key_availability_check` to true by passing the `--disable-key-availability-check` flag. For more information read the [Key Synchronization](https://docs.aws.amazon.com/cloudhsm/latest/userguide/manage-key-sync.html) section in the AWS CloudHSM documentation. + + + + + ```bash + /opt/cloudhsm/bin/configure-pkcs11 -a ... --disable-key-availability-check + ``` + + + + **2.6. Verify the CloudHSM client is configured correctly:** + + You can verify the CloudHSM client is configured correctly by running the following command: + ```bash + cat /opt/cloudhsm/etc/cloudhsm-pkcs11.cfg + ``` + + You should see an output like this: + + ```json + { + "clusters": [ + { + "type": "hsm1", + "cluster": { + "hsm_ca_file": "/opt/cloudhsm/etc/customerCA.crt", + "servers": [ + { + "hostname": "172.31.39.155", + "port": 2223, + "enable": true + } + ], + "options": { + "disable_key_availability_check": true + } + } + } + ], + "logging": { + "log_type": "file", + "log_file": "/opt/cloudhsm/run/cloudhsm-pkcs11.log", + "log_level": "info", + "log_interval": "daily" + } + } + ``` + + **2.7. Exit the pod:** + + Exit the pod by running the following command: + ```bash + exit + ``` + + **2.8. Copy your issuer CA certificate to the PVC:** + + When you initialized your HSM, you were prompted to download the cluster CSR and sign it. + In order to use the HSM with Infisical, you need to obtain the issuer CA certificate that was used to sign the cluster CSR. + + If you followed [the official AWS documentation](https://docs.aws.amazon.com/cloudhsm/latest/userguide/initialize-cluster.html), you should have a CA certificate called `customerCA.crt`. + + Copy the CA certificate from your local machine to the setup pod: + + ```bash + kubectl cp /path/to/customerCA.crt cloudhsm-setup-pod:/opt/cloudhsm/etc/customerCA.crt + ``` + + Ensure that the file is at `/opt/cloudhsm/etc/customerCA.crt` inside the setup pod by running the following command: + ```bash + kubectl exec -it cloudhsm-setup-pod -- cat /opt/cloudhsm/etc/customerCA.crt + ``` + + **2.9. Test the HSM client:** + + Finally, after we're done configuring the HSM client, we need to test it to ensure that it's working correctly. + + First, start a new shell into the setup pod by running the same shell command as before: + ```bash + kubectl exec -it cloudhsm-setup-pod -- /bin/sh + ``` + + Next, try generating a random 32 bytes long string by running the following command: + ```bash + pkcs11-tool --module /opt/cloudhsm/lib/libcloudhsm_pkcs11.so \ + --login --pin : \ + --generate-random 32 | base64 + ``` + + You should see an output like this: + ```bash + Using slot 0 with a present token (0x2000000000000001) + av1dlhVEsssjpcTNS+ysGUoKWH6+/PCaEDIdal5oQc0= + ``` + + + Replace the `:` with your username and password combination of the Crypto user you have created that you want to use to perform cryptographic operations. + + In AWS CloudHSM, the PIN is always the username and password separated by a colon. + + + + + **2.10. Copy the configured client to the PVC:** + + Copy from the HSM files into the `/data` directory in the PVC, which is what will be mounted for the Infisical deployment. + ```bash + cp -r /opt/cloudhsm/. /data/ + ``` + + Verify the files were copied correctly by running the following command: + ```bash + ls -la /data/ + ``` + + You should see an output like this: + ```bash + drwxr-xr-x. 8 root root 4096 Oct 13 18:50 . + drwxr-xr-x. 1 root root 131 Oct 13 18:29 .. + drwxr-xr-x. 2 root root 4096 Oct 13 18:50 bin + drwxr-xr-x. 3 root root 4096 Oct 13 18:50 doc + drwxr-xr-x. 2 root root 4096 Oct 13 18:50 etc + drwxr-xr-x. 3 root root 4096 Oct 13 18:50 include + drwxr-xr-x. 2 root root 4096 Oct 13 18:50 lib + drwxr-xr-t. 2 root root 4096 Oct 13 18:50 run + ``` + + **2.11. Set the correct permissions for the HSM client files:** + + ```bash + chmod -R 755 /data/ + ``` + + **2.12. Exit the pod:** + + Exit the pod by running the following command: + ```bash + exit + ``` + + **2.13. Delete the setup pod:** + + Delete the setup pod by running the following command: + ```bash + kubectl delete pod cloudhsm-setup-pod + ``` + + + + + Next we need to update the environment variables used for the deployment. If you followed the [setup instructions for Kubernetes deployments](/self-hosting/deployment-options/kubernetes-helm), you should have a Kubernetes secret called `infisical-secrets`. + We need to update the secret with the following environment variables: + + - `HSM_LIB_PATH` - The path to the CloudHSM PKCS#11 library _(mapped to `/opt/cloudhsm/lib/libcloudhsm_pkcs11.so`)_ + - `HSM_PIN` - The PIN for the HSM device, which is the username and password of your Crypto User separated by a colon (e.g., `testUser:testPassword`) + - `HSM_SLOT` - The slot number for the HSM device that you found in the previous step + - `HSM_KEY_LABEL` - The label for the HSM key. If no key is found with the provided key label, the HSM will create a new key with the provided label. + + The following is an example of the secret that you should update: + + ```yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-secrets + type: Opaque + stringData: + # ... Other environment variables ... + HSM_LIB_PATH: "/opt/cloudhsm/lib/libcloudhsm_pkcs11.so" + HSM_PIN: "testUser:testPassword" # Replace with your actual Crypto User credentials + HSM_SLOT: "0" # Replace with your actual slot number + HSM_KEY_LABEL: "infisical-crypto-key" + ``` + + Save the file after updating the environment variables, and apply the secret changes + + ```bash + kubectl apply -f ./secret-file-name.yaml + ``` + + + + After we've successfully configured the PVC and updated our environment variables, we are ready to update the deployment configuration so that the pods it creates can access the HSM client files. + + ```yaml + # ... The rest of the values.yaml file ... + infisical: + image: + repository: infisical/infisical + tag: "v0.151.0" + pullPolicy: IfNotPresent + + extraVolumeMounts: + - name: cloudhsm-data + mountPath: /opt/cloudhsm # The path we will mount the HSM client files to + + extraVolumes: + - name: cloudhsm-data + persistentVolumeClaim: + claimName: cloudhsm-data-pvc # The PVC we created in the previous step + + # ... The rest of the values.yaml file ... + ``` + + + Make sure to set the `tag` to **`v0.151.0-nightly-20251013.1` or above**, as this is the minimum Infisical version that supports AWS CloudHSM. + + + + Ensure that the configuration file at `/opt/cloudhsm/etc/cloudhsm-pkcs11.cfg` references the correct path for the issuer CA certificate (`/opt/cloudhsm/etc/customerCA.crt`). This should already be configured correctly if you followed the previous steps. + + + + + + After updating the values.yaml file, you need to upgrade the Helm chart in order for the changes to take effect. + + ```bash + helm repo update + helm upgrade --install infisical infisical-helm-charts/infisical-standalone --values /path/to/values.yaml + ``` + + + After upgrading the Helm chart, you need to restart the deployment in order for the changes to take effect. + + ```bash + kubectl rollout restart deployment/infisical-infisical-standalone-infisical + ``` + + + After following these steps, your Kubernetes setup will be ready to use AWS CloudHSM encryption. + diff --git a/docs/documentation/platform/kms/kmip.mdx b/docs/documentation/platform/kms/kmip.mdx index 1e025d51a..d82269447 100644 --- a/docs/documentation/platform/kms/kmip.mdx +++ b/docs/documentation/platform/kms/kmip.mdx @@ -8,135 +8,256 @@ description: "Learn more about integrating with Infisical KMS using KMIP (Key Ma sales@infisical.com if you have any questions. -## Overview +Infisical KMS provides Key Management Interoperability Protocol (KMIP) support for integration with KMIP-compatible clients. This allows for enhanced key management across various applications that support the KMIP 1.4 protocol. -Infisical KMS provides **Key Management Interoperability Protocol (KMIP)** support, enabling seamless integration with KMIP-compatible clients. This allows for enhanced key management across various applications that support the **KMIP 1.4 protocol**. +## How KMIP Works with Infisical -## Supported Operations +At a high level, the KMIP integration follows this architecture: -The Infisical KMIP server supports the following operations for **symmetric keys**: -- **Create** - Generate symmetric keys. -- **Register** - Register externally created keys. -- **Locate** - Find keys using attributes. -- **Get** - Retrieve keys securely. -- **Activate** - Enable keys for usage. -- **Revoke** - Revoke existing keys. -- **Destroy** - Permanently remove keys. -- **Get Attributes** - Retrieve metadata associated with keys. -- **Query** - Query server capabilities and supported operations. +![KMIP Architecture Diagram](/images/kmip-diagram.png) -## Benefits of KMIP Integration +At a high level, the KMIP integration works as follows: -Integrating Infisical KMS with KMIP-compatible clients provides the following benefits: +1. KMIP clients (your applications or tools) communicate with the KMIP server +2. The KMIP server acts as a proxy and forwards requests to Infisical KMS +3. The KMIP server authenticates to Infisical using a machine identity -- **Standardized Key Management**: Allows interoperability with security and cryptographic applications that support KMIP. -- **Enterprise-Grade Security**: Utilizes Infisical’s encryption mechanisms to securely store and manage keys. -- **Centralized Key Management**: Enables a unified approach for managing cryptographic keys across multiple environments. +The KMIP server itself is deployed using the Infisical CLI (`infisical kmip start` command) and serves as an intermediary between your KMIP clients and Infisical's key management system. -## Compatibility +### Supported Operations -Infisical KMIP supports **KMIP versions 1.0 to 1.4**, ensuring compatibility with a wide range of clients and security tools. +The Infisical KMIP server supports the following operations for symmetric keys: -## Secure Communication & Authorization +- **Create** - Generate symmetric keys +- **Register** - Register externally created keys +- **Locate** - Find keys using attributes +- **Get** - Retrieve keys securely +- **Activate** - Enable keys for usage +- **Revoke** - Revoke existing keys +- **Destroy** - Permanently remove keys +- **Get Attributes** - Retrieve metadata associated with keys +- **Query** - Query server capabilities and supported operations -KMIP client-server communication is secured using **mutual TLS (mTLS)**, ensuring strong identity verification and encrypted data exchange via **PKI certificates**. Each KMIP entity must possess valid certificates signed by a trusted Root CA to establish trust. -For strong isolation, each Infisical organization has its own KMIP PKI (Public Key Infrastructure), ensuring that cryptographic operations and certificate authorities remain separate across organizations. +### Compatibility -Infisical KMS enforces a **two-layer authorization model** for KMIP operations: +Infisical KMIP supports KMIP versions 1.0 to 1.4, ensuring compatibility with a wide range of clients and security tools. -1. **KMIP Server Authorization** – The KMIP server, acting as a proxy, must have the `proxy KMIP` permission to forward client requests to Infisical KMS. This is done using a **machine identity** attached to the KMIP server. -2. **KMIP Client Authorization** – Clients must have the necessary KMIP-level permissions to perform specific key management operations. +### Network Requirements -By combining **mTLS for secure communication** and **machine identity-based proxying**, Infisical KMS ensures **strong authentication, controlled access, and centralized key management** for KMIP operations. +Ensure the following network connectivity is in place: -## Setup Instructions +- **KMIP Client → KMIP Server**: KMIP clients must be able to reach the KMIP server on port 5696 (or your configured port). Ensure firewalls allow this traffic and DNS resolution works if using hostnames. -### Setup KMIP for your organization +- **KMIP Server → Infisical Platform**: The KMIP server needs outbound HTTP access to Infisical. For self-hosted instances, ensure connectivity to your custom domain. + +## Configure and Deploy the KMIP Server + +Follow these steps in order to set up KMIP integration with Infisical: - - From there, press Setup KMIP. + + First, you need to enable KMIP for your entire Infisical organization and set up its PKI infrastructure. + + Navigate to **Organization Settings > KMIP** and click **Setup KMIP**. + ![KMIP org navigate](/images/platform/kms/kmip/kmip-org-setup-navigation.png) - - - In the modal, select the desired key algorithm to use for the KMIP PKI of your organization. Press continue. + + In the modal, select the desired key algorithm to use for the KMIP PKI of your organization, then click **Continue**. + ![KMIP org PKI setup](/images/platform/kms/kmip/kmip-org-setup-modal.png) - This generates the KMIP PKI for your organization. After this, you can proceed to setting up your KMIP server. - + This generates the KMIP PKI for your organization, creating the cryptographic foundation that will be used for secure KMIP communications. + + You do not need to manage these certificates yourself; Infisical handles the PKI infrastructure for you. + - -### Deploying and Configuring the KMIP Server + + The KMIP server needs a machine identity to authenticate with Infisical and proxy requests on behalf of clients. -Follow these steps to configure and deploy a KMIP server. + Configure a [machine identity](/documentation/platform/identities/machine-identities#machine-identities) by heading to your organization's **Access Control** and switching over to the **identities** tab. + From there you can click **Create Identity**. - - - Configure a [machine identity](https://infisical.com/docs/documentation/platform/identities/machine-identities#machine-identities) for the KMIP server to use. + This guide assumes you'll be using the [Universal Auth](/documentation/platform/identities/universal-auth) method for the machine identity but you can choose any supported authentication method. + ![KMIP create machine identity](/images/platform/kms/kmip/kmip-create-mi.png) + This machine identity will be used by the KMIP server to authenticate and forward client requests to Infisical KMS. + + + + The machine identity needs permission to proxy KMIP requests. + Create a custom organization role and give it the **Proxy KMIP** permission. + ![KMIP create custom role](/images/platform/kms/kmip/kmip-create-custom-role.png) ![KMIP assign proxy to role](/images/platform/kms/kmip/kmip-assign-custom-role-proxy.png) - Assign the machine identity to the custom organization role. This allows the machine identity to serve KMIP client requests and forward them from your KMIP server to Infisical. - ![KMIP assign role to machine identity](/images/platform/kms/kmip/kmip-assign-mi-to-role.png) - + This permission allows the KMIP server to act as an intermediary between KMIP clients and Infisical. - - To deploy the KMIP server, use the Infisical CLI’s `kmip start` command. - Before proceeding, make sure you have the [Infisical CLI installed](https://infisical.com/docs/cli/overview). + + Now connect the machine identity to the role you just created. - Once installed, launch the KMIP server with the following command: + Assign the machine identity to the custom organization role. + + ![KMIP assign role to machine identity](/images/platform/kms/kmip/kmip-assign-mi-to-role.png) + + This grants the machine identity the ability to serve KMIP client requests and forward them from your KMIP server to Infisical. + + + + Now you're ready to deploy the KMIP server. + You can run the KMIP server on any infrastructure that can reach the Infisical platform, such as a VM or container. + + Once you have your infrastructure ready, you'll need to install the Infisical CLI on the server where you want to run the KMIP server. + + To install the latest Infisical CLI visit [Infisical CLI instructions](https://infisical.com/docs/cli/overview). + If you need to install specific versions of the CLI, you can find them on the [Infisical CLI GitHub Releases](https://github.com/Infisical/cli/releases). + + Then, launch the KMIP server with the following command: ```bash infisical kmip start \ - --identity-client-id= \ # This can be set by defining the INFISICAL_UNIVERSAL_AUTH_CLIENT_ID ENV variable - --identity-client-secret= \ # This can be set by defining the INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET ENV variable - --domain=https://app.infisical.com \ + --identity-client-id=example-client-id \ + --identity-client-secret=example-client-secret \ + --domain=https://my-infisical-instance.com \ + --listen-address="0.0.0.0:5696" \ --hostnames-or-ips="my-kmip-server.com" ``` - The following flags are available for the `infisical kmip start` command:: - - **listen-address** (default: localhost:5696): The address the KMIP server listens on. - - **identity-auth-method** (default: universal-auth): The authentication method for the machine identity. - - **identity-client-id**: The client ID of the machine identity. This can be set by defining the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` ENV variable. - - **identity-client-secret**: The client secret of the machine identity. This can be set by defining the `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` ENV variable. - - **server-name** (default: "kmip-server"): The name of the KMIP server. - - **certificate-ttl** (default: "1y"): The duration for which the server certificate is valid. - - **hostnames-or-ips:** A comma-separated list of hostnames or IPs the KMIP server will use (required). + **Available flags:** + - **listen-address** (default: localhost:5696): The address the KMIP server listens on. In most cases you'll want to listen on all interfaces (0.0.0.0:5696) + - **identity-auth-method** (default: universal-auth): The authentication method for the machine identity + - **identity-client-id**: The client ID of the machine identity (can be set via `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` env var) + - **identity-client-secret**: The client secret of the machine identity (can be set via `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` env var) + - **server-name** (default: "kmip-server"): The name of the KMIP server + - **certificate-ttl** (default: "1y"): The duration for which the server certificate is valid + - **hostnames-or-ips**: The IP address or the hostname of the server where you have deployed the KMIP server. + Once started, your KMIP server is now running and ready to accept client connections. It will authenticate to Infisical using the machine identity and proxy all KMIP operations. - -### Add and Configure KMIP Clients + + Now that the KMIP server is running, you need to register KMIP clients that will connect to it. - - - From there, press Add KMIP Client + Navigate to the desired KMS project if you already have one or create a new project of type KMS, then select **KMIP** once inside the project, and click **Add KMIP Client**. + ![KMIP client overview](/images/platform/kms/kmip/kmip-client-overview.png) - - In the modal, provide the details of your client. The selected permissions determine what KMIP operations can be performed in your KMS project. + + + Define the client and its permissions. + + In the modal, provide the details of your client. The selected permissions determine what KMIP operations (Create, Get, Revoke, etc.) can be performed in your KMS project. + ![KMIP client modal](/images/platform/kms/kmip/kmip-client-modal.png) + + This creates a KMIP client entity in Infisical that will be authenticated via mTLS certificates. - - Once the KMIP client is created, you will have to generate a client certificate. - Press Generate Certificate. + + + Each KMIP client needs its own certificate for mTLS authentication. + + Click **Generate Certificate** for your newly created client. + ![KMIP generate client cert](/images/platform/kms/kmip/kmip-client-generate-cert.png) - Provide the desired TTL and key algorithm to use and press Generate Client Certificate. + Provide the desired TTL (time-to-live) and key algorithm, then click **Generate Client Certificate**. + ![KMIP client cert config](/images/platform/kms/kmip/kmip-client-cert-config-modal.png) - Configure your KMIP clients to use the generated client certificate, certificate chain and private key. + Download the generated client certificate, certificate chain, and private key. + ![KMIP client cert modal](/images/platform/kms/kmip/kmip-client-certificate-modal.png) + Configure your KMIP-compatible applications or tools to use these credentials when connecting to the KMIP server. The client will now authenticate via mTLS and perform authorized key management operations through the KMIP server, which proxies requests to Infisical KMS. -## Additional Resources +## Connecting your KMIP Client to Infisical -- [KMIP 1.4 Specification](http://docs.oasis-open.org/kmip/spec/v1.4/os/kmip-spec-v1.4-os.html) +After completing the setup, configure your KMIP compatible application to connect to the KMIP server. + +While exact configuration steps vary by application, you'll generally need to provide: + +1. **KMIP Server Address**: The hostname or IP and port where your KMIP server is listening (e.g., `my-kmip-server.com:5696`) + +2. **Client Certificates**: The certificate credentials generated from your Infisical KMS project: + - **Client Certificate** (`client-cert.pem`) - Identifies your KMIP client + - **Client Private Key** (`client-key.pem`) - Used for mTLS authentication + - **Certificate Chain** (`cert-chain.pem`) - Verifies the KMIP server + +### General Configuration Steps + + + Determine the address where your KMIP server is accessible. This should match one of the hostnames or IPs you specified when starting the KMIP server with the `--hostnames-or-ips` flag. + + **Example endpoints:** + - `my-kmip-server.com:5696` + - `10.0.1.50:5696` + - `kmip.example.com:5696` + + The default port is `5696`, but this can be changed using the `--listen-address` flag when starting the server. + + + + Organize the certificate materials you downloaded when generating the client certificate from the Infisical KMS project. You should have three files: + + - **client-cert.pem** - The client certificate + - **cert-chain.pem** - The certificate chain (includes intermediate and root CA certificates) + - **client-key.pem** - The private key + + Most KMIP clients require these files in PEM format, which is what Infisical provides by default. + + + + The exact configuration steps vary depending on your KMIP client application. Generally, you'll need to specify: + + **Common configuration parameters:** + - **Server hostname/IP**: Your KMIP server address (e.g., `my-kmip-server.com`) + - **Server port**: Default is `5696` + - **Client certificate**: Path to `client-cert.pem` + - **Client private key**: Path to `client-key.pem` + - **CA certificate**: Path to `cert-chain.pem` (used to verify the server) + - **Protocol version**: KMIP 1.0 through 1.4 are supported + + **Example configuration for PyKMIP:** + ```ini + [client] + host=my-kmip-server.com + port=5696 + certfile=/path/to/client-cert.pem + keyfile=/path/to/client-key.pem + ca_certs=/path/to/cert-chain.pem + ``` + + + + + Once configured, test the connection by performing a simple KMIP operation, such as: + - Querying server capabilities + - Creating a test key + - Listing available keys + + If the connection is successful, your KMIP client is now integrated with Infisical KMS and can perform key management operations according to the permissions you assigned. + + **Troubleshooting connection issues:** + - Verify network connectivity between your KMIP client and the KMIP server + - Check that certificate files are readable and in the correct format + - Ensure the KMIP server is running and accessible + - Review KMIP server logs for authentication errors + - Confirm the client certificate has not expired + + If you require further verification of your certificate details and connectivity to the KMIP server from your KMIP client, you can use the following command from your client machine: + + ```bash + openssl s_client -connect kmip-server-ip-here:5696 --cert /path/to/client-cert.pem --key /path/to/client-cert.pem --CAfile /path/to/cert-chain.pem --tls1_2 --showcerts --state --debug + ``` + + This command attempts to establish a TLS connection to the KMIP server using your client certificate and key, displaying detailed information about the handshake process. + If the connection is successful, you'll see the server's certificate chain and a message indicating that the handshake was completed. + + + diff --git a/docs/documentation/platform/pam/overview.mdx b/docs/documentation/platform/pam/overview.mdx new file mode 100644 index 000000000..a6e0094f5 --- /dev/null +++ b/docs/documentation/platform/pam/overview.mdx @@ -0,0 +1,45 @@ +--- +title: "Infisical PAM" +sidebarTitle: "Overview" +description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals." +--- + +Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to. + +### How it Works + +Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable. + +#### 1. Create a Resource + +The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port. + +![Create Resource](/images/pam/overview/create-resource.png) + +#### 2. Add Accounts to the Resource + +Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place. + +![Create Account](/images/pam/overview/create-account.png) + +### Infisical PAM Features + +#### Session Logging and Auditing + +- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session. +- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project. + +![Session Page](/images/pam/overview/session-page.png) + +#### Automated Credential Rotation + +Infisical PAM can automatically rotate account credentials to enhance your security posture. + +Here’s how it works: +1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource. +![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png) + +2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days). +![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png) + +Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness. diff --git a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx new file mode 100644 index 000000000..de064bf4e --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx @@ -0,0 +1,146 @@ +--- +title: "AWS Certificate Manager" +description: "Learn how to configure an AWS Certificate Manager Certificate Sync for Infisical PKI." +--- + +**Prerequisites:** + +- Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) +- Create an [AWS Connection](/integrations/app-connections/aws) + + + The AWS Certificate Manager Certificate Sync requires the following ACM permissions to be set on the IAM user/role + for Infisical to sync certificates to AWS Certificate Manager: `acm:ListCertificates`, `acm:DescribeCertificate`, `acm:ImportCertificate`, `acm:DeleteCertificate`, and `acm:ListTagsForCertificate`. + + These permissions allow Infisical to list, import, tag, and manage certificates in your AWS Certificate Manager service. + + + + Certificates synced to AWS Certificate Manager will be stored as imported certificates, preserving both the certificate and private key components. + + + + + 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. + ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + + 2. Select the **AWS Certificate Manager** option. + ![Select ACM](/images/certificate-syncs/aws-certificate-manager/select-acm-option.png) + + 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. + ![Configure Source](/images/certificate-syncs/aws-certificate-manager/acm-source.png) + + - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. + + 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/certificate-syncs/aws-certificate-manager/acm-destination.png) + + - **AWS Connection**: The AWS Connection to authenticate with. + - **AWS Region**: The AWS region where certificates should be stored. + + 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/certificate-syncs/aws-certificate-manager/acm-options.png) + + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. + - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. + - **Certificate Name Schema** (Optional): Customize how certificate tags are generated in AWS Certificate Manager. Must include `{{certificateId}}` as a placeholder for the certificate ID to ensure proper certificate identification and management. If not specified, defaults to `Infisical-{{certificateId}}`. + + + **AWS Certificate Manager Certificate Limits**: AWS Certificate Manager has limits on the number of certificates per account and region. Refer to AWS documentation for current limits. Deleted certificates count toward your quota until they are permanently purged by AWS (typically after 30 days). + + + 6. Configure the **Details** of your AWS Certificate Manager Certificate Sync, then click **Next**. + ![Configure Details](/images/certificate-syncs/aws-certificate-manager/acm-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your AWS Certificate Manager Certificate Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/certificate-syncs/aws-certificate-manager/acm-review.png) + + 8. If enabled, your AWS Certificate Manager Certificate Sync will begin syncing your certificates to the destination endpoint. + ![Sync Certificates](/images/certificate-syncs/aws-certificate-manager/acm-synced.png) + + + + To create an **AWS Certificate Manager Certificate Sync**, make an API request to the [Create AWS Certificate Manager Certificate Sync](/api-reference/endpoints/pki/syncs/aws-certificate-manager/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/syncs/aws-certificate-manager \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-acm-cert-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example certificate sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "aws-certificate-manager", + "isAutoSyncEnabled": true, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "destinationConfig": { + "region": "us-east-1" + } + }' + ``` + + ### Sample response + + ```json Response + { + "pkiSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-acm-cert-sync", + "description": "an example certificate sync", + "destination": "aws-certificate-manager", + "isAutoSyncEnabled": true, + "destinationConfig": { + "region": "us-east-1" + }, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-01-01T00:00:00.000Z", + "updatedAt": "2023-01-01T00:00:00.000Z" + } + } + ``` + + + +## Certificate Management + +Your AWS Certificate Manager Certificate Sync will: + +- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to AWS Certificate Manager +- **Certificate Updates**: Update certificates in AWS Certificate Manager when renewals occur +- **Expiration Handling**: Optionally remove expired certificates from AWS Certificate Manager (if enabled) +- **Tagging**: Automatically tag certificates with an InfisicalCertificate tag for easy identification and management + + + AWS Certificate Manager Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + + +## Manual Certificate Sync + +You can manually trigger certificate synchronization from your PKI subscriber to AWS Certificate Manager using the sync certificates functionality. This is useful for: + +- Initial setup when you have existing certificates to deploy +- One-time sync of specific certificates +- Testing certificate sync configurations +- Force sync after making changes + +To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates) API endpoint or the manual sync option in the Infisical UI. + + +AWS Certificate Manager does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from AWS Certificate Manager. Only certificates imported into ACM (not AWS-issued certificates) can be managed by the sync. + \ No newline at end of file diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 7d0df2e22..f2570f290 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -22,6 +22,7 @@ The supported project types are: - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. +- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. ## Roles and Access Control diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx index 5b8507961..21f06cd8d 100644 --- a/docs/documentation/platform/scim/overview.mdx +++ b/docs/documentation/platform/scim/overview.mdx @@ -24,3 +24,4 @@ SCIM providers: - [Okta SCIM](/documentation/platform/scim/okta) - [Azure SCIM](/documentation/platform/scim/azure) - [JumpCloud SCIM](/documentation/platform/scim/jumpcloud) +- [PingOne SCIM](/documentation/platform/scim/pingone) diff --git a/docs/documentation/platform/scim/pingone.mdx b/docs/documentation/platform/scim/pingone.mdx new file mode 100644 index 000000000..b7263290d --- /dev/null +++ b/docs/documentation/platform/scim/pingone.mdx @@ -0,0 +1,149 @@ +--- +title: "PingOne SCIM" +description: "Learn how to configure SCIM provisioning with PingOne for Infisical." +--- + + + PingOne SCIM provisioning is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase a self-hosted license to use it. + + +Prerequisites: +- [Configure PingOne OIDC for Infisical](/documentation/platform/sso/pingone-oidc) + + + + In Infisical, head to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. Under SCIM Configuration, + press the **Enable SCIM provisioning** toggle to allow PingOne 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 PingOne. + + ![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 PingOne. + + ![SCIM copy token](/images/platform/scim/scim-copy-token.png) + + + Inside your PingOne environment, navigate to Directory > Users. Add any users and/or groups to your application that you would like + to be provisioned over to Infisical. + + ![SCIM PingOne Users and Groups](/images/platform/scim/pingone/pingone-create-user.png) + + + + + **1. Create a new connection** + + In PingOne, head to Integrations > Provisioning, and inside provisioning, press the **Connections** tab. Here you'll see a plus icon to add a new connection. + + ![SCIM PingOne Connections](/images/platform/scim/pingone/pingone-new-connection.png) + + ![SCIM PingOne Connections](/images/platform/scim/pingone/pingone-new-connection-identity-store.png) + + Select the "Identity Store" option. + + + ![SCIM PingOne SCIM Outbound](/images/platform/scim/pingone/pingone-connection-select-scim-outbound.png) + + Search for the "SCIM Outbound" option to start the configuration process for SCIM. Finally, press the **Next** button. Give the connection a name and optionally add a description. + + **2. Configure the connection** + + Once you have selected the SCIM Outbound option, you'll be prompted to enter the authentication details that PingOne will use to authenticate with Infisical SCIM. This is the **SCIM URL** and **New SCIM Token** from the previous step. + + ![SCIM PingOne SCIM Outbound](/images/platform/scim/pingone/pingone-connection-auth.png) + + Set the following fields: + - `SCIM BASE URL`: Input the **SCIM URL** from the previous step. + - `Users Resource`: Leave as default, `/Users`. + - `Groups Resource`: Leave as default, `/Groups`. + - `SCIM Version`: Leave as default, `2.0`. + - `Authentication Method`: Select `OAuth 2 Bearer Token`. + - `Oauth Access Token`: Input the **New SCIM Token** from step 1. + - `Auth Type Header`: Select `Bearer`. + + Once this is done, you can press the **Test Connection** button to check that SCIM is configured properly. You should see a success message saying "Connection Successful". + If the connection is successful, press the "Next" button. + + In the final step, you'll be prompted to configure the mappings for the connection. + + Set the following fields: + - `User Filter Expression`: `email.value Eq "%s"`. + - `User Identifier`: `workEmail`. + - `Deprovision on Rule Deletion:` Enabled. + + ![SCIM PingOne Connection Mappings](/images/platform/scim/pingone/pingone-connection-preferences.png) + + Once this is configured, press the "Save" button. + + **3. Enable the connection** + + Finally, remember to enable the connection by pressing the enable toggle. + + ![SCIM PingOne Connection Enable](/images/platform/scim/pingone/pingone-connection-enable.png) + + + + + **1. Create a new rule** + + After creating a connection, you can now access the "Rules" tab under the Provisioning section. Here you can configure the rules for the connection. + + ![SCIM PingOne Create New Rule 1](/images/platform/scim/pingone/pingone-new-rule-1.png) + + ![SCIM PingOne Create New Rule 2](/images/platform/scim/pingone/pingone-new-rule-2.png) + + Select the "New Rule" button and choose a name for the rule, then press the "Create Rule" button. + + **2. Configure the rule connection** + + Once you have created a rule, you now need to configure the connection to use for the rule. + + ![SCIM PingOne Create New Rule 3](/images/platform/scim/pingone/pingone-rule-select-connection.png) + + Select the connection you created in the previous step and press the "Save" button. + + **3. Configure the rule user filter** + + ![SCIM PingOne Create New Rule 4](/images/platform/scim/pingone/pingone-rule-select-user-filter.png) + + Select the Edit pencil icon to open the user filter configuration. This step dictates which users will be provisioned to Infisical. + + ![SCIM PingOne Create New Rule 5](/images/platform/scim/pingone/pingone-rule-user-filter.png) + + In this case, we are provisioning all users that are enabled in PingOne. Configure your user filter to match your desired users, and then press the "Save" button. + + **4. Configure Groups** + + This step is optional and only relevant if you want to provision PingOne groups to Infisical. + + ![SCIM PingOne Create New Rule 6](/images/platform/scim/pingone/pingone-rule-group-provisioning-tab.png) + + Open the "Group Provisioning" tab and press the "Add Groups" button to select which groups will be provisioned to Infisical. + + ![SCIM PingOne Create New Rule 7](/images/platform/scim/pingone/pingone-select-group.png) + + Select the groups you want to provision to Infisical and press the "Save" button. + + **5. Enable the rule** + + Once you have configured the rule, you can enable it by pressing the "Enable" toggle. + + ![SCIM PingOne Create New Rule 8](/images/platform/scim/pingone/pingone-rule-enable.png) + + + +**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. + + diff --git a/docs/documentation/platform/sso/auth0-oidc.mdx b/docs/documentation/platform/sso/auth0-oidc.mdx index 4b54c053d..0f616c519 100644 --- a/docs/documentation/platform/sso/auth0-oidc.mdx +++ b/docs/documentation/platform/sso/auth0-oidc.mdx @@ -6,7 +6,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." Auth0 OIDC SSO is a paid feature. If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you - should contact sales@infisical.com to purchase an enterprise license to use + should contact sales@infisical.com to purchase a self-hosted license to use it. @@ -55,7 +55,7 @@ description: "Learn how to configure Auth0 OIDC for Infisical SSO." Enabling OIDC allows members in your organization to log into Infisical via Auth0. - ![OIDC auth0 enable OIDC](../../../images/sso/auth0-oidc/enable-oidc.png) + ![OIDC auth0 enable OIDC](../../../images/sso/enable-oidc.png) diff --git a/docs/documentation/platform/sso/general-oidc/overview.mdx b/docs/documentation/platform/sso/general-oidc/overview.mdx index 07ddaaedd..586f66f26 100644 --- a/docs/documentation/platform/sso/general-oidc/overview.mdx +++ b/docs/documentation/platform/sso/general-oidc/overview.mdx @@ -7,7 +7,7 @@ description: "Learn how to configure OIDC for Infisical SSO with any OIDC-compli OIDC SSO is a paid feature. If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then you - should contact sales@infisical.com to purchase an enterprise license to use + should contact sales@infisical.com to purchase a self-hosted license to use it. diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 2c75fc6fe..727bf9be6 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -7,7 +7,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." Keycloak OIDC SSO is a paid feature. If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, then - you should contact sales@infisical.com to purchase an enterprise license to + you should contact sales@infisical.com to purchase a self-hosted license to use it. @@ -82,7 +82,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." Enabling OIDC SSO allows members in your organization to log into Infisical via Keycloak. - ![OIDC keycloak enable OIDC](/images/sso/keycloak-oidc/enable-oidc.png) + ![OIDC keycloak enable OIDC](/images/sso/enable-oidc.png) diff --git a/docs/documentation/platform/sso/pingone-oidc.mdx b/docs/documentation/platform/sso/pingone-oidc.mdx new file mode 100644 index 000000000..1fea5f815 --- /dev/null +++ b/docs/documentation/platform/sso/pingone-oidc.mdx @@ -0,0 +1,108 @@ +--- +title: "PingOne OIDC" +description: "Learn how to configure PingOne OIDC for Infisical SSO." +--- + + + PingOne OIDC SSO is a paid feature. If you're using Infisical Cloud, then it is + available under the **Pro Tier**. If you're self-hosting Infisical, then you + should contact sales@infisical.com to purchase a self-hosted license to use + it. + + + + + 1.1. From the Application's Page, create a new OIDC Web App application. + ![OIDC pingone create application](../../../images/sso/pingone-oidc/pingone-create-application.png) + + 1.2. Enable the application by pressing the "Enable" toggle. + ![OIDC PingOne Enable Application](../../../images/sso/pingone-oidc/pingone-enable-application.png) + + + 1.3. In the Application "Configuration" tab, press the "Edit" pencil icon to configure the application callback URI. + ![OIDC PingOne Edit Application Configuration](../../../images/sso/pingone-oidc/pingone-edit-application-configuration.png) + + + 1.4 Set the Redirect URL to `https://app.infisical.com/api/v1/sso/oidc/callback` and press the "Save" button. + ![OIDC PingOne Edit Redirect URI](../../../images/sso/pingone-oidc/pingone-edit-application-redirect-uri.png) + + + + If you're self-hosting Infisical, then you will want to replace https://app.infisical.com with your own domain. + + + + 1.5 After configuring the redirect URL, go to the "Attribute Mappings" tab and press the "Edit" pencil icon to configure the attribute mappings. + ![OIDC PingOne Edit Attribute Mappings](../../../images/sso/pingone-oidc/pingone-edit-application-attribute-mappings.png) + + 1.6 Map the following attributes: + - `email` -> `Email Address` + - `name` -> `Username` + ![OIDC PingOne Edit Attribute Mappings](../../../images/sso/pingone-oidc/pingone-edit-application-attribute-mappings-2.png) + + Once done, press the "Save" button. + + + + 2.1. Open the "Overview" tab and copy the **Client ID** and **Client Secret**. + ![OIDC PingOne Application Credential](../../../images/sso/pingone-oidc/pingone-overview-credentials.png) + + 2.2. Still in the "Overview" tab, scroll down to the Connection Details section and retrieve the **OIDC Discovery Endpoint**. + ![OIDC PingOne OIDC Discovery Endpoint](../../../images/sso/pingone-oidc/pingone-overview-oidc-discovery-endpoint.png) + + Keep these values handy as we will need them in the next steps. + + + + 3.1. Back in Infisical, head to the **Single Sign-On (SSO)** page and select the **General** tab. Click **Connect** for **OIDC**. + ![OIDC SSO Connect](../../../images/sso/connect-oidc.png) + + 3.2. For configuration type, select **Discovery URL**. Then, set **Discovery Document URL**, **Client ID**, and **Client Secret** from step 2.1 and 2.2. + + ![OIDC PingOne paste values into Infisical](../../../images/sso/pingone-oidc/infisical-configure-oidc.png) + + + Currently, the following JWT signature algorithms are supported: RS256, RS512, HS256, and EdDSA + + + Once you've done that, press **Update** to complete the required configuration. + + + + Enabling OIDC allows members in your organization to log into Infisical via PingOne + + ![OIDC PingOne enable OIDC](../../../images/sso/enable-oidc.png) + + + + Enforcing OIDC SSO ensures that members in your organization can only access Infisical + by logging into the organization via PingOne. + + To enforce OIDC SSO, you're required to test out the OpenID connection by successfully authenticating at least one PingOne user with Infisical. + Once you've completed this requirement, you can toggle the **Enforce OIDC SSO** button to enforce OIDC SSO. + + + We recommend ensuring that your account is provisioned using the application in PingOne + prior to enforcing OIDC SSO to prevent any unintended issues. + + + In case of a lockout, an organization admin can use the [Admin Login Portal](https://infisical.com/docs/documentation/platform/sso/overview#admin-login-portal) in the `/login/admin` path e.g. https://app.infisical.com/login/admin. + + + + + + If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite OIDC login. + + + + If you're configuring OIDC 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 absolute URL of your self-hosted instance of Infisical including the protocol (e.g. https://app.infisical.com) + diff --git a/docs/images/app-connections/github/create-pat-form.png b/docs/images/app-connections/github/create-pat-form.png new file mode 100644 index 000000000..cc8abdf15 Binary files /dev/null and b/docs/images/app-connections/github/create-pat-form.png differ diff --git a/docs/images/app-connections/github/create-pat-method.png b/docs/images/app-connections/github/create-pat-method.png new file mode 100644 index 000000000..eec811705 Binary files /dev/null and b/docs/images/app-connections/github/create-pat-method.png differ diff --git a/docs/images/app-connections/github/create-pat-token-generated.png b/docs/images/app-connections/github/create-pat-token-generated.png new file mode 100644 index 000000000..430e67671 Binary files /dev/null and b/docs/images/app-connections/github/create-pat-token-generated.png differ diff --git a/docs/images/app-connections/github/pat-connection.png b/docs/images/app-connections/github/pat-connection.png new file mode 100644 index 000000000..802ed4e38 Binary files /dev/null and b/docs/images/app-connections/github/pat-connection.png differ diff --git a/docs/images/app-connections/laravel-forge/api-token-create-form.png b/docs/images/app-connections/laravel-forge/api-token-create-form.png new file mode 100644 index 000000000..895da4d77 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-create-form.png differ diff --git a/docs/images/app-connections/laravel-forge/api-token-generated.png b/docs/images/app-connections/laravel-forge/api-token-generated.png new file mode 100644 index 000000000..8edc62261 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png new file mode 100644 index 000000000..69fe3b2b3 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-form.png b/docs/images/app-connections/laravel-forge/app-connection-form.png new file mode 100644 index 000000000..e03289a65 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-form.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-generated.png b/docs/images/app-connections/laravel-forge/app-connection-generated.png new file mode 100644 index 000000000..badc5d8c5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-option.png b/docs/images/app-connections/laravel-forge/app-connection-option.png new file mode 100644 index 000000000..50ac89c27 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-option.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-profile.png b/docs/images/app-connections/laravel-forge/app-connection-profile.png new file mode 100644 index 000000000..18e66a5a5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-profile.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png new file mode 100644 index 000000000..42a20fc99 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png new file mode 100644 index 000000000..483cee003 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png new file mode 100644 index 000000000..aa08b2d19 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png new file mode 100644 index 000000000..5f7b216ad Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png new file mode 100644 index 000000000..0d92fe69e Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png new file mode 100644 index 000000000..7e1ed12c5 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png b/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png new file mode 100644 index 000000000..79515516a Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png differ diff --git a/docs/images/dynamic-secret-crd.png b/docs/images/dynamic-secret-crd.png new file mode 100644 index 000000000..8b20dbd2e Binary files /dev/null and b/docs/images/dynamic-secret-crd.png differ diff --git a/docs/images/dynamic-secret.png b/docs/images/dynamic-secret.png new file mode 100644 index 000000000..812b2dfc3 Binary files /dev/null and b/docs/images/dynamic-secret.png differ diff --git a/docs/images/integrations/github/integrations-github-config-new-pat.png b/docs/images/integrations/github/integrations-github-config-new-pat.png new file mode 100644 index 000000000..11766edfd Binary files /dev/null and b/docs/images/integrations/github/integrations-github-config-new-pat.png differ diff --git a/docs/images/kmip-diagram.png b/docs/images/kmip-diagram.png new file mode 100644 index 000000000..cc56607a2 Binary files /dev/null and b/docs/images/kmip-diagram.png differ diff --git a/docs/images/pam/overview/create-account.png b/docs/images/pam/overview/create-account.png new file mode 100644 index 000000000..34f1c7434 Binary files /dev/null and b/docs/images/pam/overview/create-account.png differ diff --git a/docs/images/pam/overview/create-resource.png b/docs/images/pam/overview/create-resource.png new file mode 100644 index 000000000..ac34b9dca Binary files /dev/null and b/docs/images/pam/overview/create-resource.png differ diff --git a/docs/images/pam/overview/credential-rotation-account.png b/docs/images/pam/overview/credential-rotation-account.png new file mode 100644 index 000000000..5e379eccc Binary files /dev/null and b/docs/images/pam/overview/credential-rotation-account.png differ diff --git a/docs/images/pam/overview/rotate-credentials-account.png b/docs/images/pam/overview/rotate-credentials-account.png new file mode 100644 index 000000000..3c908cd49 Binary files /dev/null and b/docs/images/pam/overview/rotate-credentials-account.png differ diff --git a/docs/images/pam/overview/session-page.png b/docs/images/pam/overview/session-page.png new file mode 100644 index 000000000..5c2fa41cf Binary files /dev/null and b/docs/images/pam/overview/session-page.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png new file mode 100644 index 000000000..3f2be6217 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png new file mode 100644 index 000000000..230393be6 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png new file mode 100644 index 000000000..e3c41d187 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png new file mode 100644 index 000000000..55185db37 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png new file mode 100644 index 000000000..828b82544 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png new file mode 100644 index 000000000..34955b03f Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png new file mode 100644 index 000000000..a165fff77 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png differ diff --git a/docs/images/platform/folder/replicate-secrets-modal.png b/docs/images/platform/folder/replicate-secrets-modal.png new file mode 100644 index 000000000..eb9eeebf9 Binary files /dev/null and b/docs/images/platform/folder/replicate-secrets-modal.png differ diff --git a/docs/images/platform/folder/replicate-secrets-result.png b/docs/images/platform/folder/replicate-secrets-result.png new file mode 100644 index 000000000..473ad38d0 Binary files /dev/null and b/docs/images/platform/folder/replicate-secrets-result.png differ diff --git a/docs/images/platform/folder/replicate-secrets.png b/docs/images/platform/folder/replicate-secrets.png new file mode 100644 index 000000000..77e36d62c Binary files /dev/null and b/docs/images/platform/folder/replicate-secrets.png differ diff --git a/docs/images/platform/gateways/assign-project.png b/docs/images/platform/gateways/assign-project.png deleted file mode 100644 index a1ff61909..000000000 Binary files a/docs/images/platform/gateways/assign-project.png and /dev/null differ diff --git a/docs/images/platform/gateways/create-identity-for-gateway.png b/docs/images/platform/gateways/create-identity-for-gateway.png deleted file mode 100644 index d7ef6b02a..000000000 Binary files a/docs/images/platform/gateways/create-identity-for-gateway.png and /dev/null differ diff --git a/docs/images/platform/gateways/dynamic-secret.png b/docs/images/platform/gateways/dynamic-secret.png deleted file mode 100644 index bf742413e..000000000 Binary files a/docs/images/platform/gateways/dynamic-secret.png and /dev/null differ diff --git a/docs/images/platform/gateways/edit-gateway.png b/docs/images/platform/gateways/edit-gateway.png deleted file mode 100644 index 04ef2a7d2..000000000 Binary files a/docs/images/platform/gateways/edit-gateway.png and /dev/null differ diff --git a/docs/images/platform/gateways/gateway-list.png b/docs/images/platform/gateways/gateway-list.png deleted file mode 100644 index 11f8206fe..000000000 Binary files a/docs/images/platform/gateways/gateway-list.png and /dev/null differ diff --git a/docs/images/platform/scim/pingone/pingone-connection-auth.png b/docs/images/platform/scim/pingone/pingone-connection-auth.png new file mode 100644 index 000000000..c29d8b702 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-connection-auth.png differ diff --git a/docs/images/platform/scim/pingone/pingone-connection-enable.png b/docs/images/platform/scim/pingone/pingone-connection-enable.png new file mode 100644 index 000000000..24e423742 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-connection-enable.png differ diff --git a/docs/images/platform/scim/pingone/pingone-connection-preferences.png b/docs/images/platform/scim/pingone/pingone-connection-preferences.png new file mode 100644 index 000000000..e8372fd68 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-connection-preferences.png differ diff --git a/docs/images/platform/scim/pingone/pingone-connection-select-scim-outbound.png b/docs/images/platform/scim/pingone/pingone-connection-select-scim-outbound.png new file mode 100644 index 000000000..a83d4a590 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-connection-select-scim-outbound.png differ diff --git a/docs/images/platform/scim/pingone/pingone-create-user.png b/docs/images/platform/scim/pingone/pingone-create-user.png new file mode 100644 index 000000000..f48d4e74c Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-create-user.png differ diff --git a/docs/images/platform/scim/pingone/pingone-new-connection-identity-store.png b/docs/images/platform/scim/pingone/pingone-new-connection-identity-store.png new file mode 100644 index 000000000..371645ace Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-new-connection-identity-store.png differ diff --git a/docs/images/platform/scim/pingone/pingone-new-connection.png b/docs/images/platform/scim/pingone/pingone-new-connection.png new file mode 100644 index 000000000..ea17e15f4 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-new-connection.png differ diff --git a/docs/images/platform/scim/pingone/pingone-new-rule-1.png b/docs/images/platform/scim/pingone/pingone-new-rule-1.png new file mode 100644 index 000000000..065ffb446 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-new-rule-1.png differ diff --git a/docs/images/platform/scim/pingone/pingone-new-rule-2.png b/docs/images/platform/scim/pingone/pingone-new-rule-2.png new file mode 100644 index 000000000..4aedebaad Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-new-rule-2.png differ diff --git a/docs/images/platform/scim/pingone/pingone-rule-enable.png b/docs/images/platform/scim/pingone/pingone-rule-enable.png new file mode 100644 index 000000000..0cc3f58a1 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-rule-enable.png differ diff --git a/docs/images/platform/scim/pingone/pingone-rule-group-provisioning-tab.png b/docs/images/platform/scim/pingone/pingone-rule-group-provisioning-tab.png new file mode 100644 index 000000000..25decadba Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-rule-group-provisioning-tab.png differ diff --git a/docs/images/platform/scim/pingone/pingone-rule-select-connection.png b/docs/images/platform/scim/pingone/pingone-rule-select-connection.png new file mode 100644 index 000000000..5eabc22f3 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-rule-select-connection.png differ diff --git a/docs/images/platform/scim/pingone/pingone-rule-select-user-filter.png b/docs/images/platform/scim/pingone/pingone-rule-select-user-filter.png new file mode 100644 index 000000000..d40a951f0 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-rule-select-user-filter.png differ diff --git a/docs/images/platform/scim/pingone/pingone-rule-user-filter.png b/docs/images/platform/scim/pingone/pingone-rule-user-filter.png new file mode 100644 index 000000000..3470f5678 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-rule-user-filter.png differ diff --git a/docs/images/platform/scim/pingone/pingone-select-group.png b/docs/images/platform/scim/pingone/pingone-select-group.png new file mode 100644 index 000000000..4a234b3b1 Binary files /dev/null and b/docs/images/platform/scim/pingone/pingone-select-group.png differ diff --git a/docs/images/push-secret.png b/docs/images/push-secret.png new file mode 100644 index 000000000..a70228434 Binary files /dev/null and b/docs/images/push-secret.png differ diff --git a/docs/images/secret-syncs/laravel-forge/select-option.png b/docs/images/secret-syncs/laravel-forge/select-option.png new file mode 100644 index 000000000..a10f3a437 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/select-option.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-created.png b/docs/images/secret-syncs/laravel-forge/sync-created.png new file mode 100644 index 000000000..3e85f0879 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-created.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-destination.png b/docs/images/secret-syncs/laravel-forge/sync-destination.png new file mode 100644 index 000000000..735471a7a Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-destination.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-details.png b/docs/images/secret-syncs/laravel-forge/sync-details.png new file mode 100644 index 000000000..3588f88db Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-details.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-options.png b/docs/images/secret-syncs/laravel-forge/sync-options.png new file mode 100644 index 000000000..0c83e0a19 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-options.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-review.png b/docs/images/secret-syncs/laravel-forge/sync-review.png new file mode 100644 index 000000000..2d617a8b0 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-review.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-source.png b/docs/images/secret-syncs/laravel-forge/sync-source.png new file mode 100644 index 000000000..5bffb0ba9 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-source.png differ diff --git a/docs/images/sso/auth0-oidc/enable-oidc.png b/docs/images/sso/enable-oidc.png similarity index 100% rename from docs/images/sso/auth0-oidc/enable-oidc.png rename to docs/images/sso/enable-oidc.png diff --git a/docs/images/sso/keycloak-oidc/enable-oidc.png b/docs/images/sso/keycloak-oidc/enable-oidc.png deleted file mode 100644 index 0a43f22ed..000000000 Binary files a/docs/images/sso/keycloak-oidc/enable-oidc.png and /dev/null differ diff --git a/docs/images/sso/pingone-oidc/infisical-configure-oidc.png b/docs/images/sso/pingone-oidc/infisical-configure-oidc.png new file mode 100644 index 000000000..b60ff0f27 Binary files /dev/null and b/docs/images/sso/pingone-oidc/infisical-configure-oidc.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-create-application.png b/docs/images/sso/pingone-oidc/pingone-create-application.png new file mode 100644 index 000000000..7f188da7d Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-create-application.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-edit-application-attribute-mappings-2.png b/docs/images/sso/pingone-oidc/pingone-edit-application-attribute-mappings-2.png new file mode 100644 index 000000000..05f212b73 Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-edit-application-attribute-mappings-2.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-edit-application-attribute-mappings.png b/docs/images/sso/pingone-oidc/pingone-edit-application-attribute-mappings.png new file mode 100644 index 000000000..1beba0117 Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-edit-application-attribute-mappings.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-edit-application-configuration.png b/docs/images/sso/pingone-oidc/pingone-edit-application-configuration.png new file mode 100644 index 000000000..9be619298 Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-edit-application-configuration.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-edit-application-redirect-uri.png b/docs/images/sso/pingone-oidc/pingone-edit-application-redirect-uri.png new file mode 100644 index 000000000..e5292a85f Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-edit-application-redirect-uri.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-enable-application.png b/docs/images/sso/pingone-oidc/pingone-enable-application.png new file mode 100644 index 000000000..51219b9fd Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-enable-application.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-overview-credentials.png b/docs/images/sso/pingone-oidc/pingone-overview-credentials.png new file mode 100644 index 000000000..af609ff0b Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-overview-credentials.png differ diff --git a/docs/images/sso/pingone-oidc/pingone-overview-oidc-discovery-endpoint.png b/docs/images/sso/pingone-oidc/pingone-overview-oidc-discovery-endpoint.png new file mode 100644 index 000000000..da7e981b6 Binary files /dev/null and b/docs/images/sso/pingone-oidc/pingone-overview-oidc-discovery-endpoint.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 0d18856d2..fc9f6722e 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -177,6 +177,45 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync certificates to AWS Certificate Manager: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCertificateManagerAccess", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ImportCertificate", + "acm:ExportCertificate", + "acm:DeleteCertificate", + "acm:AddTagsToCertificate", + "acm:RemoveTagsFromCertificate", + "acm:ListTagsForCertificate" + ], + "Resource": "*" + } + ] + } + ``` + + - **ListCertificates**: Lists all certificates in the account + - **ImportCertificate**: Imports certificates from Infisical into AWS Certificate Manager + - **ExportCertificate**: Exports certificates for synchronization + - **DeleteCertificate**: Removes certificates that are no longer managed by Infisical + - **DescribeCertificate** and **GetCertificate**: Retrieves certificate details for comparison during sync + - Tag-related permissions: Manages certificate tags for identification and organization + + + + @@ -354,6 +393,45 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync certificates to AWS Certificate Manager: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCertificateManagerAccess", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ImportCertificate", + "acm:ExportCertificate", + "acm:DeleteCertificate", + "acm:AddTagsToCertificate", + "acm:RemoveTagsFromCertificate", + "acm:ListTagsForCertificate" + ], + "Resource": "*" + } + ] + } + ``` + + - **ListCertificates**: Lists all certificates in the account + - **ImportCertificate**: Imports certificates from Infisical into AWS Certificate Manager + - **ExportCertificate**: Exports certificates for synchronization + - **DeleteCertificate**: Removes certificates that are no longer managed by Infisical + - **DescribeCertificate** and **GetCertificate**: Retrieves certificate details for comparison during sync + - Tag-related permissions: Manages certificate tags for identification and organization + + + + diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx index e44fc405a..40eb0525f 100644 --- a/docs/integrations/app-connections/github.mdx +++ b/docs/integrations/app-connections/github.mdx @@ -3,7 +3,7 @@ title: "GitHub Connection" description: "Learn how to configure a GitHub Connection for Infisical." --- -Infisical supports two methods for connecting to GitHub. +Infisical supports three methods for connecting to GitHub. @@ -178,5 +178,72 @@ Infisical supports two methods for connecting to GitHub. + + Infisical will use a Personal Access Token to connect to GitHub. + + ## Create a Personal Access Token + + + Navigate to your user Settings > Developer settings > Personal Access Tokens to create a new Personal Access Token. + + ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-new-pat.png) + + Click **Generate new token** to create the token. + + + Fill in the Personal Access Token details: + - **Token name:** A descriptive name for the token (e.g., "infisical-connection-token") + - **Repository access:** Select the repositories you want to grant access to + - Select `All repositories` or `Only selected repositories` to be able to manage the secrets in the selected repositories. + - **Select scopes:** Add the following scopes: + - **Metadata**: Read-only + - **Environments**: Read and write + - **Secrets**: Read and write + + ![Personal Access Token Form](/images/app-connections/github/create-pat-form.png) + + Click **Generate token** to create the token. + + + Copy the generated token immediately as it won't be shown again. + + ![Personal Access Token Generated](/images/app-connections/github/create-pat-token-generated.png) + + + Keep your Personal Access Token secure and do not share it. Anyone with access to this token can access your GitHub account and repositories. + + + + + ## Setup GitHub Connection in Infisical + + + + Navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **Personal Access Token** method and fill in the **Personal Access Token** field with your Personal Access Token. + + You may optionally configure GitHub Enterprise options: + - **Gateway:** The gateway connected to your private network + - **Hostname:** The hostname at which to access your GitHub Enterprise instance + + Click **Create Connection**. + + ![Personal Access Token](/images/app-connections/github/create-pat-method.png) + + + Your **GitHub Connection** is now available for use. + ![Personal Access Token Connection](/images/app-connections/github/pat-connection.png) + + + diff --git a/docs/integrations/app-connections/laravel-forge.mdx b/docs/integrations/app-connections/laravel-forge.mdx new file mode 100644 index 000000000..67ce55270 --- /dev/null +++ b/docs/integrations/app-connections/laravel-forge.mdx @@ -0,0 +1,107 @@ +--- +title: "Laravel Forge Connection" +description: "Learn how to configure a Laravel Forge Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://forge.laravel.com/docs/api#create-a-new-api-token) to connect with Laravel Forge. + +## Create Laravel Forge API Token + + + + ![Laravel Forge User Settings](/images/app-connections/laravel-forge/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/laravel-forge/app-connection-create-api-token.png) + + + Provide a name for your token and select the following permissions: + - `user:view` + - `organization:view` + - `server:view` + - `site:manage-environment` + + Then click 'Add token'. + + ![Token Form](/images/app-connections/laravel-forge/api-token-create-form.png) + + + + Make sure to copy the token now—you won’t be able to access it again. + + ![Token Generated](/images/app-connections/laravel-forge/api-token-generated.png) + + + + +## Create a Laravel Forge Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Laravel Forge** Connection from the list of integrations. + ![Select Laravel Forge Connection](/images/app-connections/laravel-forge/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + ![Laravel Forge Connection Modal](/images/app-connections/laravel-forge/app-connection-form.png) + + + After submitting the form, your **Laravel Forge Connection** will be successfully created and ready to use with your Infisical project. + ![Laravel Forge Connection Created](/images/app-connections/laravel-forge/app-connection-generated.png) + + + + + + + To create a Laravel Forge Connection via API, send a request to the [Create Laravel Forge Connection](/api-reference/endpoints/app-connections/laravel-forge/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-connection", + "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-laravel-forge-connection", + "description": null, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-10-13T10:15:00.000Z", + "updatedAt": "2025-10-13T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "laravel-forge", + "method": "api-token", + "credentials": {} + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/laravel-forge.mdx b/docs/integrations/secret-syncs/laravel-forge.mdx new file mode 100644 index 000000000..c93abb94a --- /dev/null +++ b/docs/integrations/secret-syncs/laravel-forge.mdx @@ -0,0 +1,157 @@ +--- +title: "Laravel Forge Sync" +description: "Learn how to configure a Laravel Forge Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Laravel Forge Connection](/integrations/app-connections/laravel-forge) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Laravel Forge](/images/secret-syncs/laravel-forge/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/laravel-forge/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/laravel-forge/sync-destination.png) + + - **Laravel Forge Connection**: The Laravel Forge Connection to authenticate with. + - **Organization**: The Organization in which the server and site reside. + - **Server**: The Server on which the site resides. + - **Site**: The Site for which secrets should be synced. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/laravel-forge/sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Laravel Forge when keys conflict. + - **Import Secrets (Prioritize Laravel Forge)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Laravel Forge over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Laravel Forge Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/laravel-forge/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Laravel Forge Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/laravel-forge/sync-review.png) + + + If enabled, your Laravel Forge Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/laravel-forge/sync-created.png) + + + + + + + To create a **Laravel Forge Sync**, make an API request to the [Create Laravel Forge Sync](/api-reference/endpoints/secret-syncs/laravel-forge/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to laravel forge site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isEnabled": true, + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-laravel-forge-sync", + "description": "sync to laravel forge site", + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-07-19T12:00:00Z", + "updatedAt": "2025-07-19T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "job-1234", + "lastSyncMessage": null, + "lastSyncedAt": "2025-07-19T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "laravel-forge", + "name": "my-laravel-forge-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "destination": "laravel-forge", + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx index fd98fac41..c1c51d452 100644 --- a/docs/integrations/secret-syncs/netlify.mdx +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -78,6 +78,7 @@ description: "Learn how to configure a Netlify Sync for Infisical." ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + @@ -157,5 +158,6 @@ description: "Learn how to configure a Netlify Sync for Infisical." } } ``` + diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 3a4dd11e6..0f4736d1a 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -291,6 +291,16 @@ Supports conditions and permission inversion | `create` | Issue new certificates | | `delete` | Revoke or remove certificates | +#### Subject: `certificate-profiles` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View certificate profiles | +| `create` | Create new certificate profiles | +| `edit` | Modify profile configurations | +| `delete` | Remove certificate profiles | +| `issue-cert` | Issue new certificates | + #### Subject: `certificate-templates` | Action | Description | diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 7ead4d3dd..f6ddaad58 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -105,6 +105,22 @@ sdk.Auth().UniversalAuthLogin( - `clientId` (string): The client ID of your Machine Identity. - `clientSecret` (string): The client secret of your Machine Identity. +### AWS Auth + +```java +public void AwsAuthLogin( + String identityId +) +throws InfisicalException +``` + +```java +sdk.Auth().AwsAuthLogin(""); +``` + +**Parameters:** +- `identityId` (String): The ID of the machine identity to authenticate with. + ### LDAP Auth ```java diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 551c79184..edebf1670 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -116,6 +116,27 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. Use the following command to encode your certificate: `echo "" | base64` + + Many cloud providers provide a CA certificate for their data regions that you can use to secure your connection with SSL. + + + + If you're hosting your database on AWS RDS, you can use their publicly available CA certificate as the database root certificate. + + You can find all the available CA certificates for AWS RDS on the official [AWS RDS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html). + + As an example, if your RDS cluster is hosted in `us-east-1` _(US East, N. Virginia)_, you can use the following root certificate: https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem. + + All the available CA certificates can be found in the AWS RDS documentation linked above. + + Remember to base64 encode the certificate before setting it as the `DB_ROOT_CERT` environment variable. `cat /path/to/certificate.pem | base64`. + + ```bash + DB_ROOT_CERT=LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1 # .... (base64 encoded certificate) + DB_CONNECTION_URI=?sslmode=verify-ca # or verify-full depending on your security policies + ``` + + diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index f8a2a6864..f4ef67e1e 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -1,8 +1,10 @@ --- title: "Kubernetes via Helm Chart" description: "Learn how to use Helm chart to install Infisical on your Kubernetes cluster." ---- +--- + **Prerequisites** + - 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 @@ -12,7 +14,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete ```bash helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' ``` - ``` + ```bash helm repo update ``` @@ -61,6 +63,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete 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 @@ -74,6 +77,10 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete DB_CONNECTION_URI: <> SITE_URL: <> ``` + + + If you need to configure the SSL certificate for your production Postgres instance, you can use the `DB_ROOT_CERT` environment variable. [Learn more about configuring the SSL certificate](/self-hosting/configuration/envars#aws-rds). + diff --git a/docs/self-hosting/deployment-options/native/linux-package/installation.mdx b/docs/self-hosting/deployment-options/native/linux-package/installation.mdx index 30ef83242..2a799cb6b 100644 --- a/docs/self-hosting/deployment-options/native/linux-package/installation.mdx +++ b/docs/self-hosting/deployment-options/native/linux-package/installation.mdx @@ -25,35 +25,46 @@ Please ensure you have the following before beginning installation of Infisical: Select your Linux distribution to get started. Only AMD64-based systems are supported at this time, ARM support is coming soon. + - - Add the Infisical repository: - ```bash - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-core/setup.deb.sh' | sudo -E bash - ``` + + As of October 10, 2025, all future releases for Debian/Ubuntu will be distributed via the official Infisical repository at https://artifacts-infisical-core.infisical.com. + No new releases will be published for Debian/Ubuntu on Cloudsmith going forward. + - Install Infisical: - ```bash - sudo apt-get update && sudo apt-get install -y infisical-core - ``` + Add the Infisical repository: + ```bash + curl -1sLf 'https://artifacts-infisical-core.infisical.com/setup.deb.sh' | sudo -E bash + ``` + + Install Infisical: + ```bash + sudo apt-get update && sudo apt-get install -y infisical-core + ``` + + > **Note**: For production use, we recommend locking to a specific version to ensure consistency. [View available versions](https://github.com/Infisical/infisical/releases). All versions from `infisical-core-0.150.0~nightly~20251005` and above are supported. - > **Note**: For production use, we recommend locking to a specific version to ensure consistency. [View available versions](https://cloudsmith.io/~infisical/repos/infisical-core/packages/). + - - Add the Infisical repository: - ```bash - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-core/setup.rpm.sh' | sudo -E bash - ``` + + As of October 10, 2025, all future releases for Red Hat/CentOS/Amazon Linux will be distributed via the official Infisical repository at https://artifacts-infisical-core.infisical.com. + No new releases will be published for Red Hat/CentOS/Amazon Linux on Cloudsmith going forward. + - Install Infisical: - ```bash - sudo yum install infisical-core - ``` + Add the Infisical repository: + ```bash + curl -1sLf 'https://artifacts-infisical-core.infisical.com/setup.rpm.sh' | sudo -E bash + ``` - > **Note**: For production use, we recommend locking to a specific version to ensure consistency. [View available versions](https://cloudsmith.io/~infisical/repos/infisical-core/packages/). - + Install Infisical: + ```bash + sudo yum install infisical-core + ``` + > **Note**: For production use, we recommend locking to a specific version to ensure consistency. [View available versions](https://github.com/Infisical/infisical/releases). All versions from `infisical-core-0.150.0~nightly~20251005` and above are supported. + + Verify the installation: diff --git a/docs/self-hosting/guides/replication.mdx b/docs/self-hosting/guides/replication.mdx new file mode 100644 index 000000000..4767da6fa --- /dev/null +++ b/docs/self-hosting/guides/replication.mdx @@ -0,0 +1,162 @@ +--- + +title: "Replication" +description: "Learn how Infisical supports multi-region replication" + +--- + + + Infisical replication 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. + + +Multi-region replication is available in Infisical Enterprise to support globally distributed deployments. Understanding the architecture, use cases, and operational considerations is essential before implementing this feature in production environments. + +Infisical uses a primary/secondary (1:N) architecture with asynchronous PostgreSQL replication. This design prioritizes high availability and minimal read latency for applications deployed across multiple geographic regions. + +## Use cases + +- **Multi-Region Deployments**: Serving secrets to applications distributed across continents from a single region introduces unacceptable latency. A centralized deployment also creates a single point of failure: regional outages can render secrets inaccessible globally, and network connectivity issues impact availability. + +- **Geographic Data Locality**: Global organizations need to minimize the time it takes for applications to retrieve secrets and configurations. Regional replicas enable applications to fetch data from nearby instances rather than making cross-continental requests. + +- **Disaster Recovery**: Organizations need resilience against primary region failures. Secondary regions with read replicas can be promoted to primary status when needed, maintaining operations during outages or disasters. + +## Design Goals + +In order to address the common use cases, the implementation reflects several core goals: + +- **Optimized Read Performance**: Applications need fast access to secrets regardless of their location. Regional instances use Redis for aggressive caching and read from local PostgreSQL replicas, eliminating cross-region round trips for most read operations. + +- **Conflict-Free Architecture**: All mutations flow through the primary instance exclusively. This prevents write conflicts and split-brain scenarios that plague multi-master systems. The trade-off ensures data integrity without requiring conflict resolution strategies. + +- **Zero Client Changes**: Existing Infisical integrations, SDKs, and CLI tools work without modification. Regional instances route write operations to the primary while handling reads locally. Authentication tokens and API keys function identically across all instances. + +- **Operational Simplicity**: Deploying additional regions requires minimal configuration. PostgreSQL handles replication complexity, and the stateless application tier scales horizontally without coordination overhead. + +# Architecture + +Infisical distinguishes between _primary_ and _secondary_ instances. The primary holds write authority and is the sole instance permitted to modify the PostgreSQL database. Secondary instances handle read traffic locally and proxy write operations to the primary. + +## Infrastructure components + +Two data stores form Infisical's persistence layer: + +- **PostgreSQL** maintains the authoritative dataset including secrets with their version history, authentication credentials, user identities, project configurations, access policies, audit trails, and integration settings. All persistent state lives in PostgreSQL. + +- **Redis** accelerates read operations through caching and manages asynchronous job queues. Each regional deployment maintains an independent Redis instance optimized for local access patterns. + +The Infisical application servers are stateless and therefore hold no persistent data internally. This design simplifies regional deployment and horizontal scaling. + + + + + A primary deployment consists of three core components: + + - **Application Servers**: Process all API requests directly, handling both read and write operations without forwarding + - **PostgreSQL Primary Database**: Accepts read and write queries, serving as the authoritative source of truth + - **Redis Cache**: Stores frequently accessed data and executes all background jobs including secret synchronization, scheduled tasks, and audit log processing + + + + Each secondary deployment mirrors the primary structure with key differences: + + - **Application Servers**: Service read requests from local infrastructure but forward any write requests to the primary region + - **PostgreSQL Read Replica**: Continuously streams changes from the primary database via PostgreSQL replication + - **PostgreSQL Primary Database**: Connection string to the primary database for write forwarding + - **Redis Cache**: Maintains a local cache but processes only audit logs (other background jobs remain disabled) + + Configuring a secondary region requires four main environment variables: + + 1. `INFISICAL_PRIMARY_INSTANCE_URL`: The primary region's Infisical API endpoint + 2. Postgres primary instance connection details. View related [environment variables](/self-hosting/configuration/envars#postgresql). + 3. Postgres read replica connection details. View related [environment variables](/self-hosting/configuration/envars#postgresql). + 4. Redis connection details. View related [environment variables](/self-hosting/configuration/envars#redis). + + + + +## How requests are processed + +When a client sends a read request to a secondary instance, the application first checks the local Redis cache for the requested data. If the data exists in cache, it's returned immediately to the client. Otherwise, the application queries the local PostgreSQL read replica, caches the result in Redis for future requests, and returns the response to the client. + +Write operations follow a different path. When a secondary receives a write request, it forwards the complete request to the primary instance URL. The primary processes the mutation against the authoritative database and returns a response, which the secondary then forwards back to the client. PostgreSQL subsequently streams these changes to all replicas asynchronously. + +Operations against the primary instance are more straightforward, as both reads and writes execute directly against local infrastructure without any forwarding. + +## Replication mechanism + +PostgreSQL streaming replication handles all data synchronization. When transactions commit on the primary, changes are written to the write-ahead log (WAL) and streamed to all configured replicas, which apply the entries to maintain consistency. Replication lag typically remains under one second. + +This approach replicates all data stored in PostgreSQL: secrets and their version histories, user accounts and permissions, authentication tokens, project configurations, access policies, audit logs, integration settings, and all other application metadata. Replicas are eventually consistent. This means that all replicas eventually converge to the same state, typically under 1 second. The application layer remains unaware of replication mechanics and operates identically across all instances. + +## Caching behavior + +Redis caches are regional and independent (no coordination occurs between instances): + +- Secondary instances populate caches on demand from read requests +- Cache hits serve data without touching PostgreSQL +- Cache misses fetch from the local replica and populate the cache +- Each region maintains its own hot dataset based on local access patterns + +Secrets use versioned caching. When a secret changes, its version identifier changes, causing automatic cache misses. This ensures subsequent reads fetch the updated value from PostgreSQL without requiring active cache invalidation. + +# Technical Details + +Understanding the implementation details can help evaluate whether Infisical's replication characteristics align with your requirements. +The following sections provide deeper insight into performance behavior, failure modes, and the underlying mechanisms that drive the replication system. + +### PostgreSQL streaming replication + +Infisical relies on PostgreSQL's native replication, which provides: + +- **Asynchronous operation**: The primary commits transactions immediately without waiting for replicas to confirm receipt. Replicas receive and apply changes continuously with typical lag measured in milliseconds to low seconds, depending on network conditions and write volume. + +- **Binary-level consistency**: Replication occurs at the storage layer using write-ahead logs, guaranteeing replicas are byte-for-byte identical to the primary at the block level. + +- **Promotion capability**: Read replicas can be promoted to primary during disaster recovery. Promotion requires updating Infisical configuration to designate the promoted instance as primary and reconfiguring other secondaries. + +Consult PostgreSQL's official documentation for replication setup instructions specific to your hosting environment (RDS, Cloud SQL, self-managed, etc.). + +### Version management + +All Infisical instances must run identical versions (mixing versions risks database schema mismatches or incompatible API behavior). Database migrations execute only on the primary and replicate to secondaries through standard PostgreSQL mechanisms. + +During upgrades: +1. Upgrade the primary instance (migrations run automatically) +2. Upgrade secondary instances to match +3. All instances can continue running during the upgrade process since database migrations don't immediately drop tables/columns + +### Request proxying + +When a secondary receives a mutation request (POST, PUT, PATCH, DELETE), it functions as a transparent proxy: + +1. Preserve the original request completely (headers, authentication context, request body) +2. Forward to the primary instance URL specified in configuration +3. Primary processes the request as a direct client request +4. Return the primary's response unmodified to the client + +### Cache management + +Infisical uses versioned caching rather than active invalidation: + +1. Secrets and other cached entities include version identifiers +2. When data mutates, its version changes in the database +3. Cache lookups include the version in the cache key +4. Version changes cause automatic cache misses +5. Cache misses fetch updated data from PostgreSQL +6. Fresh data populates the cache with the new version + +This strategy ensures correctness without requiring cross-region cache invalidation protocols. + +### Background job processing + +Secondary instances run with restricted background job capabilities: + +**Active**: Audit log processing +**Disabled**: Secret synchronization to third-party systems, scheduled tasks, cron jobs, time-triggered operations + +Limiting background jobs to the primary prevents duplicate processing and ensures integrations execute once. + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 951a643dd..cfc65d1fd 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -45,7 +45,8 @@ export const AppConnectionsBrowser = () => { {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} + {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index d32f5ac04..3598bf68e 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -36,7 +36,8 @@ export const SecretSyncsBrowser = () => { {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"}, {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, - {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"} + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36d..f52343a92 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +*storybook.log +storybook-static diff --git a/frontend/.storybook/decorators/DocumentDecorator.tsx b/frontend/.storybook/decorators/DocumentDecorator.tsx new file mode 100644 index 000000000..13f4dc8c7 --- /dev/null +++ b/frontend/.storybook/decorators/DocumentDecorator.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import type { Decorator } from "@storybook/react-vite"; + +export const DocumentDecorator: Decorator = (Story) => { + useEffect(() => { + const root = document.getElementsByTagName("html")[0]; + + root.setAttribute("class", "overflow-visible"); + }, []); + + return ; +}; diff --git a/frontend/.storybook/decorators/RouterDecorator.tsx b/frontend/.storybook/decorators/RouterDecorator.tsx new file mode 100644 index 000000000..a559c5cd1 --- /dev/null +++ b/frontend/.storybook/decorators/RouterDecorator.tsx @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import type { Decorator } from "@storybook/react-vite"; +import { createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router"; + +export const RouterDecorator: Decorator = (Story) => { + const router = useMemo(() => { + const routeTree = createRootRoute({ + component: Story + }); + + return createRouter({ + routeTree + }); + }, [Story]); + + return ; +}; diff --git a/frontend/.storybook/decorators/index.ts b/frontend/.storybook/decorators/index.ts new file mode 100644 index 000000000..7bbb10f15 --- /dev/null +++ b/frontend/.storybook/decorators/index.ts @@ -0,0 +1,2 @@ +export * from "./DocumentDecorator"; +export * from "./RouterDecorator"; diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..0a7b7adb5 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,14 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: [ + "../src/components/v3/**/*.mdx", + "../src/components/v3/**/*.stories.@(js|jsx|mjs|ts|tsx)" + ], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], + framework: { + name: "@storybook/react-vite", + options: {} + } +}; +export default config; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..d2aaca582 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,36 @@ +import type { Preview } from "@storybook/react-vite"; + +import { DocumentDecorator, RouterDecorator } from "./decorators"; + +import "../src/index.css"; + +const preview: Preview = { + decorators: [DocumentDecorator, RouterDecorator], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i + } + }, + docs: { + backgroundColor: "var(--background)" + }, + a11y: { + test: "todo" + }, + backgrounds: { + default: "dark", + options: { + dark: { name: "Dark", value: "var(--background)" } + } + } + }, + initialGlobals: { + backgrounds: { + value: "dark" + } + } +}; + +export default preview; diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 00c024056..843a9602e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,3 +1,6 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; + import js from "@eslint/js"; import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; @@ -133,5 +136,6 @@ export default tseslint.config( rules: Object.fromEntries( Object.keys(stylisticPlugin.configs["all-flat"].rules ?? {}).map((key) => [key, "off"]) ) - } + }, + storybook.configs["flat/recommended"] ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3c73d9fe3..6e20b6d35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,9 @@ "@radix-ui/react-popper": "^1.2.1", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.3", @@ -55,8 +57,9 @@ "@ucast/mongo2js": "^1.3.4", "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", - "axios": "^1.11.0", + "axios": "^1.12.0", "classnames": "^2.5.1", + "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.1", "date-fns": "^4.1.0", "dompurify": "^3.2.4", @@ -65,10 +68,11 @@ "i18next": "^24.1.0", "i18next-browser-languagedetector": "^8.0.2", "i18next-http-backend": "^3.0.1", - "jspdf": "^2.5.2", + "jspdf": "^3.0.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", "lexical": "^0.29.0", + "lucide-react": "^0.544.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -98,7 +102,11 @@ "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.15.0", "@kesills/eslint-config-airbnb-typescript": "^20.0.0", + "@storybook/addon-a11y": "^9.1.9", + "@storybook/addon-docs": "^9.1.9", + "@storybook/react-vite": "^9.1.9", "@stylistic/eslint-plugin": "^2.12.1", + "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/typography": "^0.5.15", "@tanstack/eslint-plugin-router": "^1.87.6", "@tanstack/router-devtools": "^1.87.9", @@ -113,7 +121,6 @@ "@types/react-dom": "^18.3.1", "@types/react-helmet": "^6.1.11", "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^9.1.0", @@ -123,11 +130,12 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-storybook": "^9.1.9", "globals": "^15.12.0", "postcss": "^8.4.49", "prettier": "3.4.2", - "prettier-plugin-tailwindcss": "^0.6.9", - "tailwindcss": "^3.4.16", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^4.1.14", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", "vite": "^5.4.18", @@ -137,6 +145,14 @@ "vite-tsconfig-paths": "^5.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -150,38 +166,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -189,22 +191,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -243,15 +245,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -259,14 +261,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -275,16 +277,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -295,29 +287,38 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -337,27 +338,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -365,26 +366,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.10" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -438,54 +439,45 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1473,9 +1465,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -1486,9 +1478,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -1501,18 +1493,106 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "license": "MIT", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "minipass": "^7.0.4" }, "engines": { - "node": ">=6.0.0" + "node": ">=18.0.0" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz", + "integrity": "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "magic-string": "^0.30.0", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1524,25 +1604,16 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1845,6 +1916,24 @@ "integrity": "sha512-01d+UjJ8NG7ZStYQxtb8FPzknzGmauG7gEkcH+wHfSdiSQJY9PoBNVSTB9V6F5hAnmFqOxaocTtd7TIEEnzMnA==", "license": "MIT" }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2288,6 +2377,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", @@ -2397,6 +2504,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -2463,6 +2588,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -2686,6 +2829,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.3.tgz", @@ -2723,6 +2884,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", @@ -2826,6 +3005,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", @@ -2913,6 +3110,171 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.3.tgz", @@ -2956,7 +3318,7 @@ } } }, - "node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", @@ -2974,6 +3336,39 @@ } } }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-switch": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", @@ -3101,6 +3496,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -3639,6 +4052,297 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@storybook/addon-a11y": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.13.tgz", + "integrity": "sha512-4enIl1h2XSZnFKUQJJoZbp1X40lzdj7f5JE15ZhU1al4z6hHWp7i2zD7ySyDpEbMypBCz1xnLvyiyw79m1fp7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.13.tgz", + "integrity": "sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "9.1.13", + "@storybook/icons": "^1.4.0", + "@storybook/react-dom-shim": "9.1.13", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/builder-vite": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.13.tgz", + "integrity": "sha512-pmtIjU02ASJOZKdL8DoxWXJgZnpTDgD5WmMnjKJh9FaWmc2YiCW2Y6VRxPox96OM655jYHQe5+UIbk3Cwtwb4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "9.1.13", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.13.tgz", + "integrity": "sha512-EMpzYuyt9FDcxxfBChWzfId50y8QMpdenviEQ8m+pa6c+ANx3pC5J6t7y0khD8TQu815sTy+nc6cc8PC45dPUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz", + "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" + } + }, + "node_modules/@storybook/react": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.13.tgz", + "integrity": "sha512-B0UpYikKf29t8QGcdmumWojSQQ0phSDy/Ne2HYdrpNIxnUvHHUVOlGpq4lFcIDt52Ip5YG5GuAwJg3+eR4LCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "9.1.13" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.13", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.13.tgz", + "integrity": "sha512-/tMr9TmV3+98GEQO0S03k4gtKHGCpv9+k9Dmnv+TJK3TBz7QsaFEzMwe3gCgoTaebLACyVveDiZkWnCYAWB6NA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/react-vite": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.13.tgz", + "integrity": "sha512-mV1bZ1bpkNQygnuDo1xMGAS5ZXuoXFF0WGmr/BzNDGmRhZ1K1HQh42kC0w3PklckFBUwCFxmP58ZwTFzf+/dJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "9.1.13", + "@storybook/react": "9.1.13", + "find-up": "^7.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^8.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.13", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/react-vite/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/react-vite/node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@stylistic/eslint-plugin": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.13.0.tgz", @@ -3903,6 +4607,282 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tailwindcss/node": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", + "integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.0", + "lightningcss": "1.30.1", + "magic-string": "^0.30.19", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.14" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz", + "integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.5.1" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.14", + "@tailwindcss/oxide-darwin-arm64": "4.1.14", + "@tailwindcss/oxide-darwin-x64": "4.1.14", + "@tailwindcss/oxide-freebsd-x64": "4.1.14", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", + "@tailwindcss/oxide-linux-x64-musl": "4.1.14", + "@tailwindcss/oxide-wasm32-wasi": "4.1.14", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz", + "integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz", + "integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz", + "integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz", + "integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz", + "integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz", + "integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz", + "integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz", + "integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz", + "integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz", + "integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.5", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz", + "integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz", + "integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.14.tgz", + "integrity": "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.14", + "@tailwindcss/oxide": "4.1.14", + "postcss": "^8.4.41", + "tailwindcss": "4.1.14" + } + }, "node_modules/@tailwindcss/typography": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", @@ -4199,6 +5179,82 @@ "zod": "^3.23.8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/argon2-browser": { "version": "1.18.4", "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.4.tgz", @@ -4206,6 +5262,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4242,13 +5306,25 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/d3-color": { @@ -4314,6 +5390,21 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -4365,6 +5456,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -4387,6 +5485,12 @@ "integrity": "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -4462,6 +5566,13 @@ "@types/react": "*" } }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4784,6 +5895,107 @@ "vite": "^4 || ^5 || ^6" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.4.4", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz", @@ -4906,13 +6118,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4940,13 +6145,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argon2-browser": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", @@ -5190,6 +6388,31 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5204,61 +6427,10 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -5276,15 +6448,14 @@ "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==", "dev": true, "license": "MPL-2.0", - "peer": true, "engines": { "node": ">=4" } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5378,12 +6549,36 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz", + "integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/before-after-hook": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", "license": "Apache-2.0" }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5594,9 +6789,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -5614,10 +6809,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -5626,18 +6822,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5681,7 +6865,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -5697,9 +6880,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5710,14 +6893,13 @@ } }, "node_modules/call-bound": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.2.tgz", - "integrity": "sha512-0lk0PHFe/uz0vl527fG9CgdE9WdafjDbCXvBbs+LUv000TVt2Jjhqbs4Jwm8gz070w8xXyEAxrPOMullsxXeGg==", - "dev": true, + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "get-intrinsic": "^1.2.5" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -5744,20 +6926,10 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001688", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001688.tgz", - "integrity": "sha512-Nmqpru91cuABu/DTCXbM2NSRHzM2uVHfPnhJ/1zEAJx/ILBRVmz3pzH4N7DZqbdG0gWClsCC05Oj0mJ/1AWMbA==", + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "dev": true, "funding": [ { @@ -5811,6 +6983,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5864,6 +7054,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5902,6 +7103,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cipher-base": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz", @@ -6026,16 +7237,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6085,7 +7286,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cosmiconfig": { @@ -6241,6 +7441,14 @@ "utrie": "^1.0.2" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6488,6 +7696,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6499,7 +7718,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -6513,6 +7731,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -6559,6 +7788,16 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -6577,13 +7816,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", @@ -6609,13 +7841,6 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6629,6 +7854,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6661,12 +7894,12 @@ } }, "node_modules/dunder-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", - "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", + "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" }, @@ -6682,9 +7915,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.73", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.73.tgz", - "integrity": "sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==", + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", "dev": true, "license": "ISC" }, @@ -6719,9 +7952,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -6860,9 +8093,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6953,6 +8186,20 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -7423,6 +8670,23 @@ "eslint": ">=5.0.0" } }, + "node_modules/eslint-plugin-storybook": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-9.1.13.tgz", + "integrity": "sha512-kPuhbtGDiJLB5OLZuwFZAxgzWakNDw64sJtXUPN8g0+VAeXfHyZEmsE28qIIETHxtal71lPKVm8QNnERaJHPJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^9.1.13" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -7547,6 +8811,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -7709,6 +8988,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -7826,20 +9122,19 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.1.3" } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -7865,20 +9160,6 @@ "node": ">= 6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/framer-motion": { "version": "11.14.1", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.14.1.tgz", @@ -7986,21 +9267,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", - "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "dunder-proto": "^1.0.0", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", - "math-intrinsics": "^1.0.0" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8018,6 +9299,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -8181,7 +9475,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -8234,19 +9527,62 @@ } }, "node_modules/hash-base": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", - "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", "license": "MIT", "dependencies": { "inherits": "^2.0.4", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" }, "engines": { - "node": ">=4" + "node": ">= 0.8" } }, + "node_modules/hash-base/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/hash-base/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hash-base/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hash-base/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/hash-base/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -8581,6 +9917,17 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8628,6 +9975,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -8775,7 +10128,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8842,6 +10194,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9090,13 +10459,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "dev": true, + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.14" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -9148,11 +10516,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -9219,13 +10600,13 @@ } }, "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/js-tokens": { @@ -9306,29 +10687,22 @@ } }, "node_modules/jspdf": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", - "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz", + "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", - "atob": "^2.1.2", - "btoa": "^1.2.1", + "@babel/runtime": "^7.26.9", + "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { - "canvg": "^3.0.6", + "canvg": "^3.0.11", "core-js": "^3.6.0", - "dompurify": "^2.5.4", + "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, - "node_modules/jspdf/node_modules/dompurify": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", - "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", - "optional": true - }, "node_modules/jsrp": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsrp/-/jsrp-0.2.4.tgz", @@ -9440,17 +10814,243 @@ "url": "https://github.com/sponsors/dmonad" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lines-and-columns": { @@ -9517,27 +11117,58 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "ISC" + "license": "MIT", + "peer": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } }, "node_modules/magic-string": { - "version": "0.30.15", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.15.tgz", - "integrity": "sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz", - "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -10205,6 +11836,17 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -10252,6 +11894,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/motion-dom": { "version": "11.14.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.1.tgz", @@ -10270,18 +11925,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -10329,9 +11972,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -10391,16 +12034,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", @@ -10416,16 +12049,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", @@ -10562,6 +12185,25 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10797,6 +12439,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10806,21 +12455,33 @@ "node": ">=8" } }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbkdf2": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", "dev": true, "license": "MIT", "dependencies": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" }, "engines": { - "node": ">=0.12" + "node": ">= 0.10" } }, "node_modules/performance-now": { @@ -10848,26 +12509,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/pkg-dir": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", @@ -10894,7 +12535,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10929,127 +12569,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/posthog-js": { "version": "1.198.0", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.198.0.tgz", @@ -11118,9 +12637,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.9.tgz", - "integrity": "sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==", + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", "dev": true, "license": "MIT", "engines": { @@ -11128,10 +12647,12 @@ }, "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", - "@zackad/prettier-plugin-twig-melody": "*", + "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", @@ -11149,6 +12670,12 @@ "@ianvs/prettier-plugin-sort-imports": { "optional": true }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, "@prettier/plugin-pug": { "optional": true }, @@ -11158,7 +12685,7 @@ "@trivago/prettier-plugin-sort-imports": { "optional": true }, - "@zackad/prettier-plugin-twig-melody": { + "@zackad/prettier-plugin-twig": { "optional": true }, "prettier-plugin-astro": { @@ -11196,6 +12723,44 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11219,7 +12784,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/prop-types": { @@ -11477,6 +13041,38 @@ "react": ">=16.8.0" } }, + "node_modules/react-docgen": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.2.tgz", + "integrity": "sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -11735,20 +13331,11 @@ "react-dom": ">=16.6.0" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, "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, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -11785,12 +13372,70 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/redaxios": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/redaxios/-/redaxios-0.5.1.tgz", "integrity": "sha512-FSD2AmfdbkYwl7KDExYQlVvIrFz6Yd83pGfaGjBzM9F6rpq8g652Q4Yq5QD4c+nf4g2AgeElv1y+8ajUPiOYMg==", "license": "Apache-2.0" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -11980,13 +13625,16 @@ } }, "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", "license": "MIT", "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" } }, "node_modules/rollup": { @@ -12142,7 +13790,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -12180,16 +13827,23 @@ "license": "MIT" }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/shebang-command": { @@ -12349,6 +14003,43 @@ "node": ">=0.1.14" } }, + "node_modules/storybook": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz", + "integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@storybook/global": "^5.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/spy": "3.2.4", + "better-opn": "^3.0.2", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", + "esbuild-register": "^3.5.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "bin/index.cjs" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -12377,6 +14068,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -12424,9 +14116,9 @@ "license": "MIT" }, "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -12437,9 +14129,9 @@ } }, "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -12616,6 +14308,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12643,76 +14348,6 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12776,42 +14411,11 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", + "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.1", @@ -12823,6 +14427,33 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -12840,29 +14471,6 @@ "dev": true, "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -12888,6 +14496,42 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12938,12 +14582,15 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", "dev": true, - "license": "Apache-2.0" + "license": "MIT", + "engines": { + "node": ">=6.10" + } }, "node_modules/tsconfck": { "version": "3.1.4", @@ -13501,15 +15148,14 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -13638,6 +15284,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -13740,9 +15399,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -13761,7 +15420,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -14219,7 +15878,6 @@ "version": "1.1.16", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -14305,9 +15963,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -14318,9 +15976,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -14331,9 +15989,9 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -14353,6 +16011,29 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1bc55c1c7..9c375190e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,12 @@ "preview": "vite preview", "lint": "eslint ./src", "lint:fix": "eslint --fix ./src", - "type:check": "tsc --noEmit --project ./tsconfig.app.json" + "type:check": "tsc --noEmit --project ./tsconfig.app.json", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "overrides": { + "sha.js": "2.4.12" }, "dependencies": { "@casl/ability": "^6.7.2", @@ -44,7 +49,9 @@ "@radix-ui/react-popper": "^1.2.1", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.3", @@ -59,8 +66,9 @@ "@ucast/mongo2js": "^1.3.4", "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", - "axios": "^1.11.0", + "axios": "^1.12.0", "classnames": "^2.5.1", + "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.1", "date-fns": "^4.1.0", "dompurify": "^3.2.4", @@ -69,10 +77,11 @@ "i18next": "^24.1.0", "i18next-browser-languagedetector": "^8.0.2", "i18next-http-backend": "^3.0.1", - "jspdf": "^2.5.2", + "jspdf": "^3.0.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", "lexical": "^0.29.0", + "lucide-react": "^0.544.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -102,7 +111,11 @@ "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.15.0", "@kesills/eslint-config-airbnb-typescript": "^20.0.0", + "@storybook/addon-a11y": "^9.1.9", + "@storybook/addon-docs": "^9.1.9", + "@storybook/react-vite": "^9.1.9", "@stylistic/eslint-plugin": "^2.12.1", + "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/typography": "^0.5.15", "@tanstack/eslint-plugin-router": "^1.87.6", "@tanstack/router-devtools": "^1.87.9", @@ -117,7 +130,6 @@ "@types/react-dom": "^18.3.1", "@types/react-helmet": "^6.1.11", "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^9.1.0", @@ -127,11 +139,12 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-storybook": "^9.1.9", "globals": "^15.12.0", "postcss": "^8.4.49", "prettier": "3.4.2", - "prettier-plugin-tailwindcss": "^0.6.9", - "tailwindcss": "^3.4.16", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^4.1.14", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", "vite": "^5.4.18", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b7f..a7f73a2d1 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + '@tailwindcss/postcss': {}, }, } diff --git a/frontend/public/fonts/AllianceNo2-Regular.otf b/frontend/public/fonts/AllianceNo2-Regular.otf new file mode 100644 index 000000000..a51796093 Binary files /dev/null and b/frontend/public/fonts/AllianceNo2-Regular.otf differ diff --git a/frontend/public/images/integrations/IBM.png b/frontend/public/images/integrations/IBM.png new file mode 100644 index 000000000..7fafbb6c3 Binary files /dev/null and b/frontend/public/images/integrations/IBM.png differ diff --git a/frontend/public/images/integrations/RDP.png b/frontend/public/images/integrations/RDP.png new file mode 100644 index 000000000..1336888b7 Binary files /dev/null and b/frontend/public/images/integrations/RDP.png differ diff --git a/frontend/public/images/integrations/SSH.png b/frontend/public/images/integrations/SSH.png new file mode 100644 index 000000000..ff7708f9f Binary files /dev/null and b/frontend/public/images/integrations/SSH.png differ diff --git a/frontend/public/images/logotransparent_trimmed.png b/frontend/public/images/logotransparent_trimmed.png new file mode 100644 index 000000000..32911fb83 Binary files /dev/null and b/frontend/public/images/logotransparent_trimmed.png differ diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index fdfefcf93..de3b99f11 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -270,7 +270,7 @@ "description": "This page shows the members of the selected project, and allows you to modify their permissions." }, "org": { - "title": "Organization Settings", + "title": "Settings", "description": "Manage members of your organization. These users could afterwards be formed into projects." }, "personal": { @@ -290,7 +290,7 @@ } }, "project": { - "title": "Project Settings", + "title": "Settings", "description": "These settings only apply to the currently selected Project.", "danger-zone": "Danger Zone", "delete-project": "Delete Project", diff --git a/frontend/public/lotties/infisical_loading_white.json b/frontend/public/lotties/infisical_loading_white.json new file mode 100644 index 000000000..ee2b15c3b --- /dev/null +++ b/frontend/public/lotties/infisical_loading_white.json @@ -0,0 +1 @@ +{"v":"5.7.5","fr":100,"ip":0,"op":300,"w":800,"h":420,"nm":"Comp 1","ddd":0,"metadata":{},"assets":[{"id":"0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector (Stroke)","sr":1,"ks":{"p":{"a":0,"k":[-205,-431],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","nm":"Vector (Stroke)","it":[{"ty":"gr","nm":"Path 1","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[20.447,-130.148],[-77.721,-88.116],[-139.588,-16.199],[-148.936,-1.772],[-139.006,12.261],[-71.202,92.517],[20.447,130.148],[148.936,0],[20.447,-130.148]],"i":[[0,0],[29.018,-24.696],[16.327,-25.197],[0,0],[0,0],[-23.672,-20.706],[-35.524,0],[0,71.767],[71.074,0]],"o":[[-35.542,0],[-24.403,20.768],[0,0],[0,0],[22.425,31.688],[26.864,23.498],[71.074,0],[0,-71.768],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","nm":"Path 2","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-61.273,-68.789],[-77.721,-88.116],[-61.273,-68.789]],"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","nm":"Path 3","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-44.825,-49.463],[20.447,-79.392],[98.18,0],[20.447,79.392],[-37.785,54.313],[-87.278,-2.751],[-44.825,-49.463],[-44.825,-49.463]],"i":[[0,0],[-18.899,0],[0,-44.371],[42.411,0],[20.197,17.666],[17.78,24.244],[-15.565,13.246],[0,0]],"o":[[24.072,-20.487],[42.411,0],[0,44.371],[-20.186,0],[-15.87,-13.881],[12.425,-17.11],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,1,0.5,0.5,1,0],"ix":2}},"s":{"a":0,"k":[-86.28473663330078,1.4994840621948242],"ix":2},"e":{"a":0,"k":[462.44384765625,-5.23858642578125],"ix":2},"t":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[747.4509887695312,615.6649780273438],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[140.5150055885315,140.5150055885315],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":301,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector (Stroke)","sr":1,"ks":{"p":{"a":0,"k":[-205,-431],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","nm":"Vector (Stroke)","it":[{"ty":"gr","nm":"Path 1","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-98.468,0],[-20.444,-79.392],[46.888,-43.887],[46.888,-43.887],[87.062,-0.165],[48.675,41.472],[65.75,60.248],[48.675,41.472],[-20.444,79.392],[-98.468,0]],"i":[[0,0],[-42.675,0],[-27.224,-24.453],[0,0],[-11.056,-14.336],[14.986,-13.628],[0,0],[0,0],[15.764,0],[0,44.267]],"o":[[0,-44.266],[16.468,0],[0,0],[15.303,13.745],[-10.159,13.06],[0,0],[0,0],[-28.618,26.025],[-42.675,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","nm":"Path 2","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-20.444,-130.149],[-149.224,0],[-20.444,130.149],[82.824,79.024],[82.824,79.023],[139.462,14.105],[149.224,-0.255],[139.377,-14.556],[80.805,-81.647],[-20.444,-130.149]],"i":[[0,0],[0,-71.764],[-71.238,0],[-30.616,27.842],[0,0],[-12.465,18.334],[0,0],[0,0],[24.224,21.758],[35.661,0]],"o":[[-71.238,0],[0,71.764],[35.674,0],[0,0],[24.139,-21.952],[0,0],[0,0],[-13.86,-20.129],[-29.959,-26.91],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,1,0.5,0.5,1,0],"ix":2}},"s":{"a":0,"k":[62.10511016845703,5.982990741729736],"ix":2},"e":{"a":0,"k":[-461.3863220214844,-3.3000035285949707],"ix":2},"t":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[414.8349914550781,614.260009765625],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[140.5150055885315,140.5150055885315],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":301,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"shapes":[{"ty":"gr","nm":"Shape Layer 2","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[64.219,60.955],[75.017,50.459],[64.706,38.974],[56.737,41.937],[52.529,45.908],[46.994,53.295],[43.203,57.056],[35.02,60.825],[24.169,49.937],[34.908,38.936],[42.707,42.246],[47.079,46.558],[64.219,60.955]],"i":[[0,0],[-0.155,3.832],[5.611,0.221],[2.488,-1.995],[1.254,-1.476],[2.012,-2.367],[1.531,-1.317],[2.721,0.003],[0,5.642],[-6.195,0.163],[-2.204,-1.755],[-1.435,-1.717],[-7.72,0.139]],"o":[[7.629,-0.137],[0.248,-6.111],[-2.688,-0.106],[-1.511,1.212],[-2.012,2.368],[-1.153,1.356],[-3.063,2.455],[-6.495,-0.007],[0,-5.642],[2.954,-0.078],[1.264,1.149],[4.489,5.371],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":20,"ix":2},"o":{"a":1,"k":[{"t":0,"s":[61.00000000000001],"i":{"x":[0.833],"y":[0.904]},"o":{"x":[0.167],"y":[0.167]}},{"t":50,"s":[149],"i":{"x":[0.833],"y":[0.715]},"o":{"x":[0.167],"y":[0.258]}},{"t":153,"s":[216.99999999999997],"i":{"x":[0.833],"y":[0.889]},"o":{"x":[0.167],"y":[0.092]}},{"t":207,"s":[325.00000000000006],"i":{"x":[0.833],"y":[0.81]},"o":{"x":[0.167],"y":[0.25]}},{"t":287,"s":[397.00000000000006],"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.095]}},{"t":300,"s":[421.00000000000006],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}}],"ix":2},"m":1},{"ty":"gs","o":{"a":0,"k":100,"ix":2},"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,1,0.5,0.5,1,0],"ix":2}},"s":{"a":0,"k":[49.759,50.221],"ix":2},"e":{"a":1,"k":[{"t":0,"s":[120.553,-22.385],"i":{"x":[0.692],"y":[0.616]},"o":{"x":[0.362],"y":[0]}},{"t":67,"s":[65.727,-39.14],"i":{"x":[0.682],"y":[1]},"o":{"x":[0.359],"y":[0.551]}},{"t":150,"s":[8.856,-27.571],"i":{"x":[0.653],"y":[0.47]},"o":{"x":[0.329],"y":[0]}},{"t":227,"s":[65.202,-32.204],"i":{"x":[0.637],"y":[1]},"o":{"x":[0.31],"y":[0.455]}},{"t":300,"s":[120.553,-22.385],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}}],"ix":2},"t":1,"w":{"a":0,"k":5.4,"ix":2},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"a":0,"k":[400.3317565917969,210.40078735351562],"ix":2},"a":{"a":0,"k":[49.59747009478117,49.94493849689434],"ix":2},"s":{"a":0,"k":[1339.9999618530273,1339.9999618530273],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":301,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"","sr":1,"ks":{"p":{"a":0,"k":[400.0003356933594,210.00125122070312],"ix":2},"a":{"a":0,"k":[580.941,614.963],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"ip":0,"op":301,"st":0,"bm":0},{"ddd":0,"refId":"0","w":752,"h":368,"ind":5,"ty":0,"nm":"Vector (Stroke) :M","sr":1,"ks":{"p":{"a":0,"k":[205,431],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":33,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"ip":0,"op":301,"st":0,"bm":0,"parent":4}],"markers":[]} \ No newline at end of file diff --git a/frontend/src/components/app-connections/AppConnectionOption.tsx b/frontend/src/components/app-connections/AppConnectionOption.tsx index 2978dc9f0..30a37425c 100644 --- a/frontend/src/components/app-connections/AppConnectionOption.tsx +++ b/frontend/src/components/app-connections/AppConnectionOption.tsx @@ -26,8 +26,8 @@ export const AppConnectionOption = ({

{children}

{!props.data.projectId && ( -
- +
+ Organization diff --git a/frontend/src/components/auth/CodeInputStep.tsx b/frontend/src/components/auth/CodeInputStep.tsx index f992c8da6..297a9f883 100644 --- a/frontend/src/components/auth/CodeInputStep.tsx +++ b/frontend/src/components/auth/CodeInputStep.tsx @@ -91,7 +91,7 @@ export default function CodeInputStep({ return (

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

-

{email}

+

{email}

@@ -111,11 +111,11 @@ export default function CodeInputStep({ fields={6} onChange={setCode} {...propsPhone} - className="mb-2 mt-2" + className="mt-2 mb-2" />
{codeError && } -
+
-
+