diff --git a/.env.example b/.env.example index 44fe998e43ef..c8185bb7437d 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ WEBAPP_URL=http://localhost:3000 # If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth NEXTAUTH_URL=http://localhost:3000 +# Optional server-only Better Auth JWKS endpoint for MCP OAuth verification. +# Use this when the runtime cannot reach its public URL; public issuer and redirect URLs remain unchanged. +# MCP_OAUTH_JWKS_URL=http://formbricks:3000/api/auth/jwks + # Can be used to deploy the application under a sub-path of a domain. This can only be set at build time # BASE_PATH= @@ -57,6 +61,27 @@ LOG_LEVEL=info DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public' +######################### +# AUTHZED / SPICEDB # +######################### +# The local development stack starts a persistent SpiceDB instance backed by +# the bundled PostgreSQL server. pnpm dev:setup generates and preserves both +# secrets below. +AUTHZED_ENABLED=true +AUTHZED_ENDPOINT=localhost:50051 +AUTHZED_TOKEN= +AUTHZED_SYSTEM_KEY=formbricks +AUTHZED_INSECURE=true +AUTHZED_CONSISTENCY=minimize_latency +AUTHZED_DATABASE_PASSWORD= + +# Optional image and localhost port overrides for docker-compose.dev.yml. +# SPICEDB_IMAGE_REF=authzed/spicedb:v1.52.0 +# ZED_IMAGE_REF=authzed/zed:v1.1.1 +# GRPCUI_IMAGE_REF=fullstorydev/grpcui:v1.5.2 +# SPICEDB_GRPC_PORT=50051 +# AUTHZED_GRPCUI_PORT=50052 + ################# # HUB (DEV) # ################# diff --git a/.github/workflows/api-v3-contract-tests.yml b/.github/workflows/api-v3-contract-tests.yml index 98fa700a619e..cef91c9c156e 100644 --- a/.github/workflows/api-v3-contract-tests.yml +++ b/.github/workflows/api-v3-contract-tests.yml @@ -115,6 +115,7 @@ jobs: # A burst of cases would otherwise trip the limiter and turn most operations into # documented-but-uninteresting 429s. echo "RATE_LIMITING_DISABLED=1" >> .env + sed -i "s|AUTHZED_CONSISTENCY=.*|AUTHZED_CONSISTENCY=fully_consistent|" .env # Not about running Playwright: this is the flag that stops the app sending an instanceId # alongside the shared licence key (license.ts — "Skip instance ID during E2E tests to avoid # license key conflicts"). Without it the licence server answers 403 "bound to another @@ -133,6 +134,14 @@ jobs: run: pnpm build --filter=@formbricks/web... shell: bash + # Keep the fixture offline during the build: a successful build still proves importing the + # authorization runtime does not perform an RPC. Runtime contract requests are authoritative, + # however, so install the canonical schema before projecting the seeded PostgreSQL grants. + - name: Start AuthZed CI fixture + if: steps.harness.outputs.present == 'true' + run: bash scripts/start-authzed-ci.sh + shell: bash + - name: Apply Prisma migrations if: steps.harness.outputs.present == 'true' # @formbricks/database is already built by the build step, so run the migration runner @@ -156,6 +165,19 @@ jobs: run: pnpm --filter=@formbricks/database db:seed:contract shell: bash + - name: Project seeded authorization relationships + if: steps.harness.outputs.present == 'true' + shell: bash + run: | + set +e + pnpm authzed:backfill --apply > /tmp/authzed-backfill.json + backfill_exit=$? + set -e + + # Aggregate evidence only: the detailed report contains disposable fixture identifiers. + jq -c '{status, code, counters, truncated}' /tmp/authzed-backfill.json || true + exit "${backfill_exit}" + - name: Run App if: steps.harness.outputs.present == 'true' shell: bash diff --git a/.github/workflows/docker-build-validation.yml b/.github/workflows/docker-build-validation.yml index 926df270f349..582f5a02a886 100644 --- a/.github/workflows/docker-build-validation.yml +++ b/.github/workflows/docker-build-validation.yml @@ -13,9 +13,60 @@ permissions: contents: read jobs: + validate-authzed-compose: + name: Validate AuthZed Compose + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + env: + DATABASE_URL: postgresql://test:test@127.0.0.1:5432/formbricks + ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + REDIS_URL: redis://127.0.0.1:6379 + CUBEJS_API_URL: http://127.0.0.1:4000 + CUBEJS_API_SECRET: build-time-placeholder + HUB_API_URL: http://127.0.0.1:4000 + HUB_API_KEY: build-time-placeholder + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Node.js 22.x + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.x + + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - name: Install dependencies + run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 + + - name: Build web workspace dependencies + run: pnpm build --filter=@formbricks/web^... + + - name: Test AuthZed client and schema lifecycle + run: pnpm --dir apps/web test lib/authzed + + - name: Validate Compose contracts + run: bash docker/authzed-compose-contract.sh + + - name: Run AuthZed application and persistence smoke test + run: bash docker/authzed-smoke.sh + validate-docker-build: name: Validate Docker Build runs-on: ubuntu-latest + permissions: + contents: read # Add PostgreSQL and Redis service containers services: @@ -49,12 +100,16 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Verify Valkey Image Platforms shell: bash + env: + AUTHZED_DATABASE_PASSWORD: 0000000000000000000000000000000000000000000000000000000000000002 + AUTHZED_TOKEN: 0000000000000000000000000000000000000000000000000000000000000001 run: | set -euo pipefail @@ -108,6 +163,56 @@ jobs: node --version ' + - name: Verify Release-Matched AuthZed Operator CLI + shell: bash + env: + GITHUB_SHA: ${{ github.sha }} + DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} + run: | + set -euo pipefail + + IMAGE="formbricks-test:$GITHUB_SHA" + docker run --rm --entrypoint sh "$IMAGE" -c ' + set -eu + test "$(id -u)" != "0" + test -x /usr/local/bin/formbricks-authzed + test -r /home/nextjs/authzed-cli/index.mjs + test -r /home/nextjs/authzed-cli/schema.zed + ' + + AUTHZED_CLI_ENV=( + -e DATABASE_URL="postgresql://test:test@192.0.2.1:5432/formbricks" + -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" + -e REDIS_URL="redis://192.0.2.1:6379" + -e HUB_API_URL="http://192.0.2.1:4000" + -e HUB_API_KEY="build-time-placeholder" + -e CUBEJS_API_URL="http://192.0.2.1:4000" + -e CUBEJS_API_SECRET="build-time-placeholder" + -e AUTHZED_ENABLED="false" + ) + + set +e + output="$(docker run --rm \ + --entrypoint formbricks-authzed \ + "${AUTHZED_CLI_ENV[@]}" \ + "$IMAGE" health 2>&1)" + status=$? + set -e + + test "$status" -eq 1 + test "$output" = '{"status":"disabled"}' + + set +e + upgrade_output="$(docker run --rm \ + --entrypoint formbricks-authzed \ + "${AUTHZED_CLI_ENV[@]}" \ + "$IMAGE" upgrade check 2>&1)" + upgrade_status=$? + set -e + + test "$upgrade_status" -eq 1 + test "$upgrade_output" = '{"code":"authzed_disabled","retryable":false,"status":"failed"}' + - name: Reject Invalid Environment Before Database Setup shell: bash env: @@ -221,6 +326,8 @@ jobs: "BETTER_AUTH_SECRET=$DUMMY_ENCRYPTION_KEY" \ 'HUB_API_KEY=build-time-placeholder' \ 'CUBEJS_API_SECRET=build-time-placeholder' \ + 'AUTHZED_TOKEN=compose-authzed-token-placeholder' \ + 'AUTHZED_DATABASE_PASSWORD=compose-authzed-database-placeholder' \ 'AI_PROVIDER=compose-provider-secret-sentinel' \ > docker/.env diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9ce188bd50ba..d87bc58df248 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -132,6 +132,7 @@ jobs: run: | sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env + sed -i "s|AUTHZED_CONSISTENCY=.*|AUTHZED_CONSISTENCY=fully_consistent|" .env echo "" >> .env echo "E2E_TESTING=1" >> .env echo "S3_REGION=us-east-1" >> .env @@ -142,6 +143,10 @@ jobs: echo "S3_FORCE_PATH_STYLE=1" >> .env shell: bash + - name: Start AuthZed CI fixture + run: bash scripts/start-authzed-ci.sh + shell: bash + - name: Start RustFS Server run: | set -euo pipefail @@ -343,6 +348,39 @@ jobs: sleep 10 done + # Playwright fixtures seed authorization source rows directly through Prisma. That intentionally + # bypasses product services, so run the real durable delivery processor at a test-only cadence + # rather than duplicating relationship writes in fixture code or waiting for the 5-second schedule. + - name: Start AuthZed CI outbox worker + id: authzed-worker-start + run: | + heartbeat_path="${RUNNER_TEMP}/formbricks-authzed-outbox-worker.heartbeat" + rm -f "${heartbeat_path}" + cd apps/web + AUTHZED_CI_OUTBOX_HEARTBEAT_PATH="${heartbeat_path}" \ + node --env-file=../../.env --conditions=react-server --import tsx \ + scripts/authzed-ci-outbox-worker.ts \ + > ../../authzed-outbox.log 2>&1 & + worker_pid="$!" + cd ../.. + echo "AUTHZED_CI_OUTBOX_HEARTBEAT_PATH=${heartbeat_path}" >> "$GITHUB_ENV" + echo "AUTHZED_CI_OUTBOX_PID=${worker_pid}" >> "$GITHUB_ENV" + + for attempt in {1..600}; do + if ! kill -0 "${worker_pid}" 2>/dev/null; then + echo "AuthZed CI outbox worker exited before becoming ready" + exit 1 + fi + if [ -f "${heartbeat_path}" ]; then + exit 0 + fi + sleep 0.1 + done + + echo "AuthZed CI outbox worker did not publish its readiness heartbeat after ${attempt} attempts" + exit 1 + shell: bash + - name: Run E2E Tests (Playwright Service) if: env.PW_MODE == 'service' timeout-minutes: 15 @@ -350,7 +388,8 @@ jobs: PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }} PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }} CI: true - run: pnpm test-e2e:azure + run: | + pnpm test-e2e:azure - name: Run E2E Tests (Local) if: env.PW_MODE == 'local' @@ -360,6 +399,44 @@ jobs: run: | pnpm test:e2e + - name: Verify AuthZed CI outbox worker + if: always() && steps.authzed-worker-start.outcome == 'success' + run: | + status=0 + worker_pid="${AUTHZED_CI_OUTBOX_PID:-}" + heartbeat_path="${AUTHZED_CI_OUTBOX_HEARTBEAT_PATH:-}" + + if [ -z "${worker_pid}" ] || ! kill -0 "${worker_pid}" 2>/dev/null; then + echo "AuthZed CI outbox worker is not running" + status=1 + else + worker_command="$(ps -p "${worker_pid}" -o command= || true)" + if [[ "${worker_command}" != *"authzed-ci-outbox-worker.ts"* ]]; then + echo "AuthZed CI outbox worker PID no longer identifies the expected process" + status=1 + fi + fi + + if [ -z "${heartbeat_path}" ] || [ ! -f "${heartbeat_path}" ]; then + echo "AuthZed CI outbox worker heartbeat is missing" + status=1 + else + heartbeat_age="$(( $(date +%s) - $(stat -c %Y "${heartbeat_path}") ))" + # The worker emits a heartbeat every 100 ms. Allow 100 missed heartbeats so a loaded + # runner is not mistaken for a dead worker while still catching a stalled loop. + if [ "${heartbeat_age}" -gt 10 ]; then + echo "AuthZed CI outbox worker heartbeat is stale" + status=1 + fi + fi + + if [ -n "${worker_pid}" ]; then + kill "${worker_pid}" 2>/dev/null || true + fi + + exit "${status}" + shell: bash + - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 if: always() with: @@ -382,3 +459,12 @@ jobs: else echo "app.log not found because the Run App step did not execute or failed before log creation." fi + + - name: Output AuthZed logs + if: failure() + run: | + docker logs formbricks-authzed-ci 2>&1 || true + if [ -f authzed-outbox.log ]; then + cat authzed-outbox.log + fi + shell: bash diff --git a/.github/workflows/helm-chart-validation.yml b/.github/workflows/helm-chart-validation.yml index b06fa73f5254..ab84cfe23a81 100644 --- a/.github/workflows/helm-chart-validation.yml +++ b/.github/workflows/helm-chart-validation.yml @@ -10,6 +10,8 @@ jobs: validate: name: Validate Helm Chart runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -60,6 +62,7 @@ jobs: } helm lint "$chart" --set formbricks.webappUrl=https://qa.example.com + bash charts/formbricks/tests/authzed-operations.sh helm template qa "$chart" \ --set formbricks.webappUrl=https://qa.example.com \ @@ -94,6 +97,29 @@ jobs: grep -q '^ preStop:$' "$render_dir/deployment-lifecycle.yaml" grep -q -- '- sleep 30' "$render_dir/deployment-lifecycle.yaml" + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --show-only templates/deployment.yaml > "$render_dir/formbricks-default.yaml" + if grep -q 'name: MCP_OAUTH_JWKS_URL' "$render_dir/formbricks-default.yaml"; then + echo "MCP_OAUTH_JWKS_URL must remain opt-in" + exit 1 + fi + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set formbricks.mcpOauthJwksUrl=http://auth-internal:3000/custom/api/auth/jwks \ + --show-only templates/deployment.yaml > "$render_dir/formbricks-jwks-value.yaml" + grep -A1 'name: MCP_OAUTH_JWKS_URL' "$render_dir/formbricks-jwks-value.yaml" \ + | grep -q 'value: "http://auth-internal:3000/custom/api/auth/jwks"' + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set-string deployment.env.MCP_OAUTH_JWKS_URL=http://explicit:3000/api/auth/jwks \ + --show-only templates/deployment.yaml > "$render_dir/formbricks-jwks-env.yaml" + test "$(grep -c 'name: MCP_OAUTH_JWKS_URL' "$render_dir/formbricks-jwks-env.yaml")" -eq 1 + grep -A1 'name: MCP_OAUTH_JWKS_URL' "$render_dir/formbricks-jwks-env.yaml" \ + | grep -q 'value: "http://explicit:3000/api/auth/jwks"' + helm template qa "$chart" \ --set formbricks.webappUrl=https://qa.example.com \ --show-only templates/cube-deployment.yaml > "$render_dir/cube-default.yaml" @@ -529,6 +555,10 @@ jobs: --set formbricks.webappUrl=https://qa.example.com \ --set postgresql.enabled=false \ --set-string postgresql.externalDatabaseUrl=postgresql://user:password@external-postgresql:5432/formbricks \ + --set authzed.mode=external \ + --set authzed.operator.install=false \ + --set authzed.endpoint=grpc.authzed.com:443 \ + --set authzed.auth.existingSecret=formbricks-authzed \ --show-only templates/migration-job.yaml > "$render_dir/external.yaml" grep -q 'packages/database/dist/scripts/wait-for-database.js' "$render_dir/external.yaml" @@ -543,3 +573,12 @@ jobs: --set formbricks.webappUrl=https://qa.example.com \ --show-only charts/postgresql/templates/primary/statefulset.yaml > "$render_dir/postgresql.yaml" grep -q 'argocd.argoproj.io/sync-wave: "-2"' "$render_dir/postgresql.yaml" + + # The render tests cannot execute the bootstrap SQL, and the SQL is where ENG-2390 actually + # broke: an administrator holding the documented CREATEROLE and CREATEDB could not run + # `CREATE DATABASE ... OWNER`. This runs the *rendered* script against real PostgreSQL, on both + # sides of the 16 CREATEROLE change. It skips itself when Docker is unavailable, so the suite + # stays runnable locally. + - name: Validate the AuthZed bootstrap against real PostgreSQL + shell: bash + run: bash charts/formbricks/tests/authzed-bootstrap-runtime.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ca7314025a81..343967d9e89b 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -121,6 +121,12 @@ jobs: # PASSWORD_RESET_DISABLED=1 (the .env.example default). Override to 0 so the # reset-related test coverage still runs. sed -i "s|PASSWORD_RESET_DISABLED=.*|PASSWORD_RESET_DISABLED=0|" .env + sed -i "s|AUTHZED_CONSISTENCY=.*|AUTHZED_CONSISTENCY=fully_consistent|" .env + shell: bash + + - name: Start AuthZed CI fixture + if: steps.harness.outputs.present == 'true' + run: bash scripts/start-authzed-ci.sh shell: bash # Build the workspace packages the tests import (@formbricks/logger, cache, types, email, …) so @@ -166,3 +172,8 @@ jobs: env: TEST_DB_PROVISIONED: "1" shell: bash + + - name: Output AuthZed logs + if: failure() && steps.harness.outputs.present == 'true' + run: docker logs formbricks-authzed-ci 2>&1 || true + shell: bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c9d283e4467f..3dc0a556955b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -53,6 +53,24 @@ jobs: uses: ./.github/workflows/e2e.yml secrets: inherit + validate-authzed-schema: + name: Validate AuthZed Schema + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 + with: + egress-policy: audit + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Validate schema assertions + run: bash authzed/validate.sh + # No `paths:` filter, deliberately: `required` below fails on a skipped job, and a required check # that never reports would keep every PR out of the merge queue. It runs concurrently with e2e-test, # which is the gate's critical path, so it costs runner minutes rather than wall-clock. @@ -74,6 +92,7 @@ jobs: helm-chart-validation, coderabbit-config-validation, e2e-test, + validate-authzed-schema, api-v3-contract-tests, ] if: always() diff --git a/.gitignore b/.gitignore index 8c7401da8841..2f44b9935fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,10 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat .cursorrules i18n.cache stats.html + +# authzed:perf sample logs — multi-MB JSONL written next to the tracked authzed/ assets +authzed/perf-samples.jsonl +authzed/*.perf.jsonl # next-agents-md .next-docs/ diff --git a/.prettierignore b/.prettierignore index c3a205c58012..38b0507b5dd9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,8 @@ pnpm-lock.yaml docs/api-v3-reference/openapi.yml docs/api-v3-reference/.redocly.lint-ignore.yaml charts/**/README.md +charts/spicedb-operator/crds/authzed.com_spicedbclusters.yaml +charts/spicedb-operator/files/update-graph.yaml # Helm templates are Go templates that only look like YAML — Prettier's YAML # parser cannot parse them. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 7ca509277dc5..93196f611c8d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -107,6 +107,14 @@ RUN chmod 644 ./package.json COPY --from=installer /app/apps/web/dist/docker/validate-env.mjs ./validate-env.mjs RUN chown nextjs:nextjs ./validate-env.mjs && chmod 444 ./validate-env.mjs +COPY --from=installer /app/apps/web/dist/authzed-cli ./authzed-cli +RUN chown -R nextjs:nextjs ./authzed-cli \ + && find ./authzed-cli -type d -exec chmod 555 {} + \ + && find ./authzed-cli -type f -exec chmod 444 {} + + +COPY --from=installer /app/apps/web/scripts/docker/formbricks-authzed /usr/local/bin/formbricks-authzed +RUN chown root:root /usr/local/bin/formbricks-authzed && chmod 555 /usr/local/bin/formbricks-authzed + COPY --from=installer /app/prisma.config.mjs ./prisma.config.mjs RUN chmod 644 ./prisma.config.mjs diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.test.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.test.ts index 64c0161ff9d6..8a8bd7562c1c 100644 --- a/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.test.ts +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.test.ts @@ -1,18 +1,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; -import { getAccessFlags } from "@/lib/membership/utils"; +import { can } from "@/lib/authorization"; import { getOrganization, updateOrganization } from "@/lib/organization/service"; import { getUserWorkspaces, getWorkspaces } from "@/lib/workspace/service"; import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getOnboardingWorkspaceContext, selectOldestWorkspace } from "./onboarding-workspace"; -vi.mock("@/lib/membership/service", () => ({ - getMembershipByUserIdOrganizationId: vi.fn(), -})); - -vi.mock("@/lib/membership/utils", () => ({ - getAccessFlags: vi.fn(), +vi.mock("@/lib/authorization", () => ({ + can: vi.fn(), })); vi.mock("@/lib/organization/service", () => ({ @@ -80,18 +75,7 @@ const olderWorkspace = { describe("onboarding-workspace", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ - organizationId: "org1", - userId: "user1", - accepted: true, - role: "owner", - }); - vi.mocked(getAccessFlags).mockReturnValue({ - isOwner: true, - isManager: false, - isBilling: false, - isMember: false, - }); + vi.mocked(can).mockResolvedValue(true); vi.mocked(getOrganization).mockResolvedValue(mockOrganization); vi.mocked(getIsAISmartToolsEnabled).mockResolvedValue(true); vi.mocked(updateOrganization).mockResolvedValue({ @@ -172,16 +156,16 @@ describe("onboarding-workspace", () => { expect(updateOrganization).not.toHaveBeenCalled(); }); - test("throws when user is not owner or manager", async () => { - vi.mocked(getAccessFlags).mockReturnValueOnce({ - isOwner: false, - isManager: false, - isBilling: false, - isMember: false, - }); + test("throws when the user cannot manage the organization", async () => { + vi.mocked(can).mockResolvedValue(false); await expect(getOnboardingWorkspaceContext({ userId: "user1", organizationId: "org1" })).rejects.toThrow( AuthorizationError ); + + expect(can).toHaveBeenCalledWith({ type: "user", id: "user1" }, "organization.manage", { + type: "organization", + id: "org1", + }); }); }); diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.ts index 56bf0018b135..33cb21e4ada3 100644 --- a/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.ts +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding-workspace.ts @@ -2,8 +2,7 @@ import "server-only"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TOrganization } from "@formbricks/types/organizations"; import { TWorkspace } from "@formbricks/types/workspace"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; -import { getAccessFlags } from "@/lib/membership/utils"; +import { can } from "@/lib/authorization"; import { getOrganization, updateOrganization } from "@/lib/organization/service"; import { getUserWorkspaces, getWorkspaces } from "@/lib/workspace/service"; import { getIsAISmartToolsEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -17,10 +16,14 @@ export const selectOldestWorkspace = (workspaces: TWorkspace[]): TWorkspace | un }; const assertCanManageOnboardingWorkspace = async (userId: string, organizationId: string): Promise => { - const membership = await getMembershipByUserIdOrganizationId(userId, organizationId); - const { isOwner, isManager } = getAccessFlags(membership?.role); - - if (!isOwner && !isManager) { + // Owner-or-manager, expressed as the capability rather than the role pair: `organization.manage` + // is defined as exactly owner + manager. The message is kept, so the caller sees no change. + const canManage = await can({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, + }); + + if (!canManage) { throw new AuthorizationError("User is not authorized to create a workspace in this organization"); } }; diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx index 152afaacfa34..04430676899e 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.tsx @@ -1,5 +1,6 @@ import { notFound, redirect } from "next/navigation"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { getUserWorkspaces } from "@/lib/workspace/service"; import { getSession } from "@/modules/auth/lib/session"; @@ -16,9 +17,18 @@ const LandingLayout = async (props: { return redirect(`/auth/login`); } - const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId); - - if (!membership) { + // ENG-2388: was a direct `getMembershipByUserIdOrganizationId` truthiness check. `organization.read` + // is the same set — the schema grants it to every membership role (owner, manager, member, billing) + // and to nobody else — so a non-member still gets `notFound()` and every member still passes. Routing + // it here is what puts the decision on the shadow-comparison path. + const isMember = await withAuthorizationSurface("page", () => + can({ type: "user", id: session.user.id }, "organization.read", { + type: "organization", + id: params.organizationId, + }) + ); + + if (!isMember) { return notFound(); } diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx index b0476542baac..da8ae67579db 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.tsx @@ -1,5 +1,6 @@ import { redirect } from "next/navigation"; import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { canUserAccessOrganization } from "@/lib/organization/auth"; import { getOrganization } from "@/lib/organization/service"; import { getUser } from "@/lib/user/service"; @@ -27,7 +28,14 @@ const WorkspaceOnboardingLayout = async (props: { throw new AuthenticationError(t("common.not_authenticated")); } - const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); + // ENG-2388: `canUserAccessOrganization` already resolves through `can()` (`organization.read`), so + // this needed only a surface. It matters more than it looks: this is the parent of the `landing` + // and `workspaces/new` layouts wrapped in this same change, and a parent layout renders in its own + // async context — the child's surface does not extend upward. Without this the org-level decision + // guarding both of them stayed invisible to the rollout while its two children were comparable. + const isAuthorized = await withAuthorizationSurface("page", () => + canUserAccessOrganization(session.user.id, params.organizationId) + ); if (!isAuthorized) { throw new AuthorizationError(t("common.not_authorized")); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/layout.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/layout.tsx index 255f3693dcfe..81943a18e7fc 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/layout.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/layout.tsx @@ -1,8 +1,8 @@ import { notFound, redirect } from "next/navigation"; import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; -import { getAccessFlags } from "@/lib/membership/utils"; import { getOrganization } from "@/lib/organization/service"; import { getTranslate } from "@/lingodotdev/server"; import { getSession } from "@/modules/auth/lib/session"; @@ -22,9 +22,29 @@ const OnboardingLayout = async (props: { return redirect(`/auth/login`); } - const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId); - const { isMember, isBilling } = getAccessFlags(membership?.role); - if (isMember || isBilling) return notFound(); + // ENG-2388: was `getAccessFlags(membership?.role)` then `if (isMember || isBilling) return notFound()`. + // + // That is a denylist, and it named only the two roles to reject — so a user with NO membership in + // this organization produced all-false flags and fell straight through it. `organization.manage` + // (`owner + manager` in the schema) is the allowlist the check was reaching for: it admits exactly + // the roles the denylist intended to leave, and denies the non-member the denylist missed. + // + // That non-member is defense-in-depth, not a new denial: the parent onboarding layout already + // refuses them via `canUserAccessOrganization`, verified at runtime (it throws `AuthorizationError` + // before this subtree completes). What the allowlist adds is that this layout no longer *depends* + // on that parent — RSC renders a parent and its child concurrently, so a child that admits everyone + // and then invalidates the billing cache is relying on render interleaving to stay correct. + // + // The roles whose treatment this line actually decides are `member` and `billing`, exactly as the + // denylist intended. The difference is that the intent is now stated directly instead of inferred + // from which roles were named for rejection. + const canCreateWorkspaces = await withAuthorizationSurface("page", () => + can({ type: "user", id: session.user.id }, "organization.manage", { + type: "organization", + id: params.organizationId, + }) + ); + if (!canCreateWorkspaces) return notFound(); const organization = await getOrganization(params.organizationId); if (!organization) { diff --git a/apps/web/app/(app)/organizations/[organizationId]/settings/enterprise/page.tsx b/apps/web/app/(app)/organizations/[organizationId]/settings/enterprise/page.tsx index 35a2a9d2fa08..50d947cfb7dc 100644 --- a/apps/web/app/(app)/organizations/[organizationId]/settings/enterprise/page.tsx +++ b/apps/web/app/(app)/organizations/[organizationId]/settings/enterprise/page.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable"; import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; @@ -16,8 +18,12 @@ import { PageHeader } from "@/modules/ui/components/page-header"; const Page = async (props: Readonly<{ params: Promise<{ organizationId: string }> }>) => { const params = await props.params; const t = await getTranslate(); - const { session, isBilling, isMember } = await getOrganizationAuth(params.organizationId); + const { session, isBilling } = await getOrganizationAuth(params.organizationId); + // Not routed through can(), deliberately: this line does not decide access. On Cloud the next + // block refuses everyone anyway, so all it chooses is whether a billing-role user gets a 302 to + // their billing home or a 404. Expressing it centrally would need a "billing role only" + // capability, and inventing one would encode a role name as a permission. if (isBilling && IS_FORMBRICKS_CLOUD) { redirect(getOrganizationBillingPath(params.organizationId, IS_FORMBRICKS_CLOUD)); } @@ -26,7 +32,23 @@ const Page = async (props: Readonly<{ params: Promise<{ organizationId: string } return notFound(); } - if (isMember) { + // ENG-2409: was `isMember -> notFound()`. + // + // `organization.manage_billing` (owner + manager + billing) is exactly `!isMember` — the caller + // has a membership by now, since getOrganizationAuth throws otherwise, so the four roles partition + // cleanly. It is NOT `organization.manage`, which is the mapping this gate invites: on self-hosted + // `getOrganizationBillingPath(orgId, false)` resolves to this very page, so this is where the + // billing role gets redirected TO. Gating on owner + manager would 404 that role on its own + // landing page, and billing-role-access.spec.ts would not catch it — it asserts on URL, and a 404 + // keeps the URL. + const hasBillingAccess = await withAuthorizationSurface("page", () => + can({ type: "user", id: session.user.id }, "organization.manage_billing", { + type: "organization", + id: params.organizationId, + }) + ); + + if (!hasBillingAccess) { return notFound(); } diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts index fbcc313f23c5..2f8aae299443 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/actions.ts @@ -2,18 +2,13 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { - AuthorizationError, - OperationNotAllowedError, - ResourceNotFoundError, -} from "@formbricks/types/errors"; +import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { assertCan } from "@/lib/authorization"; import { getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog"; import { updateUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationWorkspacesCount } from "@/lib/workspace/service"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { @@ -35,17 +30,9 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea const organizationId = parsedInput.organizationId; - await checkAuthorizationUpdated({ - userId: user.id, - organizationId: parsedInput.organizationId, - access: [ - { - data: parsedInput.data, - schema: ZWorkspaceUpdateInput, - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); const organization = await getOrganization(organizationId); @@ -112,15 +99,9 @@ const ZGetOrganizationsForSwitcherAction = z.object({ export const getOrganizationsForSwitcherAction = authenticatedActionClient .inputSchema(ZGetOrganizationsForSwitcherAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "member", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.read", { + type: "organization", + id: parsedInput.organizationId, }); return await getOrganizationsByUserId(ctx.user.id); @@ -137,24 +118,12 @@ const ZGetWorkspacesForSwitcherAction = z.object({ export const getWorkspacesForSwitcherAction = authenticatedActionClient .inputSchema(ZGetWorkspacesForSwitcherAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "member", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.read", { + type: "organization", + id: parsedInput.organizationId, }); - // Need membership for getWorkspacesByUserId (1 DB query) - const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); - if (!membership) { - throw new AuthorizationError("Membership not found"); - } - - return await getWorkspacesByUserId(ctx.user.id, membership); + return await getWorkspacesByUserId(ctx.user.id, parsedInput.organizationId); }); const ZGetWritableWorkspacesAction = z.object({ @@ -168,21 +137,10 @@ const ZGetWritableWorkspacesAction = z.object({ export const getWritableWorkspacesAction = authenticatedActionClient .inputSchema(ZGetWritableWorkspacesAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "member"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.read_access", { + type: "organization", + id: parsedInput.organizationId, }); - const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); - if (!membership) { - throw new AuthorizationError("Membership not found"); - } - - return await getWritableWorkspacesByUserId(ctx.user.id, membership); + return await getWritableWorkspacesByUserId(ctx.user.id, parsedInput.organizationId); }); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.test.ts index d5ece4de0d64..5fb6bab06911 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.test.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { lookupAuthorizedOrganizationIds } from "@/lib/authorization/resource-list"; import { getOrganizationsByUserId } from "./organization"; vi.mock("@formbricks/database", () => ({ @@ -11,10 +12,16 @@ vi.mock("@formbricks/database", () => ({ }, }, })); +vi.mock("@/lib/authorization/resource-list", () => ({ lookupAuthorizedOrganizationIds: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue(["org1", "org2"]); +}); describe("Organization", () => { describe("getOrganizationsByUserId", () => { - test("should return organizations when found", async () => { + test("returns only organizations allowed by the central authorization lookup", async () => { const mockOrganizations = [ { id: "org1", name: "Organization 1" }, { id: "org2", name: "Organization 2" }, @@ -26,11 +33,7 @@ describe("Organization", () => { expect(prisma.organization.findMany).toHaveBeenCalledWith({ where: { - memberships: { - some: { - userId: "user1", - }, - }, + id: { in: ["org1", "org2"] }, }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { @@ -41,6 +44,13 @@ describe("Organization", () => { expect(result).toEqual(mockOrganizations); }); + test("should skip PostgreSQL when authorization returns no organizations", async () => { + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue([]); + + await expect(getOrganizationsByUserId("user-without-organizations")).resolves.toEqual([]); + expect(prisma.organization.findMany).not.toHaveBeenCalled(); + }); + test("should throw ResourceNotFoundError when organizations is null", async () => { vi.mocked(prisma.organization.findMany).mockResolvedValue(null as any); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.ts b/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.ts index db40b86bace7..8c58d66f9aa5 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/lib/organization.ts @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { ZString } from "@formbricks/types/common"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { lookupAuthorizedOrganizationIds } from "@/lib/authorization/resource-list"; import { validateInputs } from "@/lib/utils/validate"; export const getOrganizationsByUserId = reactCache( @@ -10,13 +11,12 @@ export const getOrganizationsByUserId = reactCache( validateInputs([userId, ZString]); try { + const organizationIds = await lookupAuthorizedOrganizationIds({ type: "user", id: userId }); + if (organizationIds.length === 0) return []; + const organizations = await prisma.organization.findMany({ where: { - memberships: { - some: { - userId, - }, - }, + id: { in: [...organizationIds] }, }, // Deterministic order so callers that take organizations[0] (e.g. account settings' // default org) always resolve the same organization for a given user. id breaks ties when diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.test.ts index b533107b636f..b58920e04985 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.test.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.test.ts @@ -1,298 +1,80 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError } from "@formbricks/types/errors"; -import { TMembership } from "@formbricks/types/memberships"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; import { getWorkspacesByUserId, getWritableWorkspacesByUserId } from "./workspace"; vi.mock("@formbricks/database", () => ({ - prisma: { - workspace: { - findMany: vi.fn(), - }, - }, + prisma: { workspace: { findMany: vi.fn() } }, })); +vi.mock("@/lib/authorization/resource-list", () => ({ lookupAuthorizedWorkspaceIds: vi.fn() })); -describe("Workspace", () => { - describe("getUserWorkspaces", () => { - const mockAdminMembership: TMembership = { - role: "manager", - organizationId: "org1", - userId: "user1", - accepted: true, - }; - - const mockMemberMembership: TMembership = { - role: "member", - organizationId: "org1", - userId: "user1", - accepted: true, - }; - - test("should return workspaces for admin role", async () => { - const mockWorkspaces = [ - { id: "workspace1", name: "Workspace 1" }, - { id: "workspace2", name: "Workspace 2" }, - ]; - - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWorkspacesByUserId("user1", mockAdminMembership); - - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org1", - }, - select: { - id: true, - name: true, - }, - orderBy: { createdAt: "asc" }, - }); - expect(result).toEqual(mockWorkspaces); - }); - - test("should return workspaces for member role with team restrictions", async () => { - const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; - - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWorkspacesByUserId("user1", mockMemberMembership); - - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org1", - workspaceTeams: { - some: { - team: { - teamUsers: { - some: { - userId: "user1", - }, - }, - }, - }, - }, - }, - select: { - id: true, - name: true, - }, - orderBy: { createdAt: "asc" }, - }); - expect(result).toEqual(mockWorkspaces); - }); - - test("should return empty array when no workspaces found", async () => { - vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); - - const result = await getWorkspacesByUserId("user1", mockAdminMembership); - - expect(result).toEqual([]); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { - code: "P2002", - clientVersion: "5.0.0", - }); - - vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError); - - await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow( - new DatabaseError("Database error") - ); - }); - - test("should re-throw unknown errors", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError); - - await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError); - }); - - test("should validate inputs correctly", async () => { - await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow(); - }); - - test("should validate membership input correctly", async () => { - const invalidMembership = {} as TMembership; - await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow(); - }); - - test("should handle owner role like manager", async () => { - const mockOwnerMembership: TMembership = { - role: "owner", - organizationId: "org1", - userId: "user1", - accepted: true, - }; - - const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWorkspacesByUserId("user1", mockOwnerMembership); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(["workspace1", "workspace2"]); +}); - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org1", - }, - select: { - id: true, - name: true, - }, - orderBy: { createdAt: "asc" }, - }); - expect(result).toEqual(mockWorkspaces); +describe("authoritative workspace switcher lists", () => { + test("resolves readable workspaces through SpiceDB and scopes the data query to the organization", async () => { + const workspaces = [ + { id: "workspace1", name: "Workspace 1" }, + { id: "workspace2", name: "Workspace 2" }, + ]; + vi.mocked(prisma.workspace.findMany).mockResolvedValue(workspaces as never); + + await expect(getWorkspacesByUserId("user1", "org1")).resolves.toEqual(workspaces); + + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith( + { id: "user1", type: "user" }, + "read" + ); + expect(prisma.workspace.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: "asc" }, + select: { id: true, name: true }, + where: { id: { in: ["workspace1", "workspace2"] }, organizationId: "org1" }, }); }); - describe("getWritableWorkspacesByUserId", () => { - const mockOwnerMembership: TMembership = { - role: "owner", - organizationId: "org1", - userId: "user1", - accepted: true, - }; + test("uses workspace.write for writable destination lists", async () => { + vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); - const mockManagerMembership: TMembership = { - role: "manager", - organizationId: "org1", - userId: "user1", - accepted: true, - }; + await expect(getWritableWorkspacesByUserId("user1", "org1")).resolves.toEqual([]); - const mockMemberMembership: TMembership = { - role: "member", - organizationId: "org1", - userId: "user1", - accepted: true, - }; - - test("should return all workspaces in org for owner role without team filter", async () => { - const mockWorkspaces = [ - { id: "workspace1", name: "Workspace 1" }, - { id: "workspace2", name: "Workspace 2" }, - ]; - - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWritableWorkspacesByUserId("user1", mockOwnerMembership); - - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org1", - }, - select: { - id: true, - name: true, - }, - orderBy: { createdAt: "asc" }, - }); - expect(result).toEqual(mockWorkspaces); - }); - - test("should return all workspaces in org for manager role without team filter", async () => { - const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; - - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWritableWorkspacesByUserId("user1", mockManagerMembership); - - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org1", - }, - select: { - id: true, - name: true, - }, - orderBy: { createdAt: "asc" }, - }); - expect(result).toEqual(mockWorkspaces); - }); - - test("should filter to readWrite or manage team workspaces for member role", async () => { - const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; - - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWritableWorkspacesByUserId("user1", mockMemberMembership); - - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org1", - workspaceTeams: { - some: { - permission: { in: ["readWrite", "manage"] }, - team: { - teamUsers: { - some: { - userId: "user1", - }, - }, - }, - }, - }, - }, - select: { - id: true, - name: true, - }, - orderBy: { createdAt: "asc" }, - }); - expect(result).toEqual(mockWorkspaces); - }); - - test("should include workspaces where member has manage permission", async () => { - const mockWorkspaces = [{ id: "workspace-manage", name: "Managed Workspace" }]; - - vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); - - const result = await getWritableWorkspacesByUserId("user1", mockMemberMembership); - - const callArgs = vi.mocked(prisma.workspace.findMany).mock.calls.at(-1)?.[0]; - const permissionFilter = (callArgs?.where as any)?.workspaceTeams?.some?.permission; - expect(permissionFilter).toEqual({ in: ["readWrite", "manage"] }); - expect(permissionFilter.in).toContain("manage"); - expect(result).toEqual(mockWorkspaces); - }); - - test("should return empty array when member has no readWrite or manage team access", async () => { - vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith( + { id: "user1", type: "user" }, + "write" + ); + }); - const result = await getWritableWorkspacesByUserId("user1", mockMemberMembership); + test("does not query PostgreSQL when SpiceDB returns no workspaces", async () => { + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([]); - expect(result).toEqual([]); - }); + await expect(getWorkspacesByUserId("user1", "org1")).resolves.toEqual([]); - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { - code: "P2002", - clientVersion: "5.0.0", - }); - - vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); + }); - await expect(getWritableWorkspacesByUserId("user1", mockOwnerMembership)).rejects.toThrow( - new DatabaseError("Database error") - ); + test("translates Prisma failures without converting them into an empty list", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { + clientVersion: "5.0.0", + code: "P2002", }); + vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError); - test("should re-throw unknown errors", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError); + await expect(getWorkspacesByUserId("user1", "org1")).rejects.toBeInstanceOf(DatabaseError); + }); - await expect(getWritableWorkspacesByUserId("user1", mockOwnerMembership)).rejects.toThrow(unknownError); - }); + test("propagates AuthZed lookup failures", async () => { + const unavailable = new Error("AuthZed unavailable"); + vi.mocked(lookupAuthorizedWorkspaceIds).mockRejectedValue(unavailable); - test("should validate inputs correctly", async () => { - await expect(getWritableWorkspacesByUserId(123 as any, mockOwnerMembership)).rejects.toThrow(); - }); + await expect(getWorkspacesByUserId("user1", "org1")).rejects.toBe(unavailable); + }); - test("should validate membership input correctly", async () => { - const invalidMembership = {} as TMembership; - await expect(getWritableWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow(); - }); + test("validates actor and organization inputs before authorization lookup", async () => { + await expect(getWorkspacesByUserId(123 as never, "org1")).rejects.toThrow(); + await expect(getWorkspacesByUserId("user1", {} as never)).rejects.toThrow(); + expect(lookupAuthorizedWorkspaceIds).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.ts b/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.ts index 644e9ed0afe1..65a97fb55721 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/lib/workspace.ts @@ -3,40 +3,27 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; -import { TMembership, ZMembership } from "@formbricks/types/memberships"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; import { validateInputs } from "@/lib/utils/validate"; -const findWorkspacesForMembership = async ( +const findWorkspacesForOrganization = async ( userId: string, - orgMembership: TMembership, + organizationId: string, { writableOnly }: { writableOnly: boolean } ): Promise<{ id: string; name: string }[]> => { - validateInputs([userId, ZId], [orgMembership, ZMembership]); - - let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; - - if (orgMembership.role === "member") { - workspaceWhereClause = { - workspaceTeams: { - some: { - ...(writableOnly && { permission: { in: ["readWrite", "manage"] } }), - team: { - teamUsers: { - some: { - userId, - }, - }, - }, - }, - }, - }; - } + validateInputs([userId, ZId], [organizationId, ZId]); try { + const workspaceIds = await lookupAuthorizedWorkspaceIds( + { type: "user", id: userId }, + writableOnly ? "write" : "read" + ); + if (workspaceIds.length === 0) return []; + const workspaces = await prisma.workspace.findMany({ where: { - organizationId: orgMembership.organizationId, - ...workspaceWhereClause, + id: { in: [...workspaceIds] }, + organizationId, }, select: { id: true, @@ -57,11 +44,11 @@ const findWorkspacesForMembership = async ( }; export const getWorkspacesByUserId = reactCache( - async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => - findWorkspacesForMembership(userId, orgMembership, { writableOnly: false }) + async (userId: string, organizationId: string): Promise<{ id: string; name: string }[]> => + findWorkspacesForOrganization(userId, organizationId, { writableOnly: false }) ); export const getWritableWorkspacesByUserId = reactCache( - async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => - findWorkspacesForMembership(userId, orgMembership, { writableOnly: true }) + async (userId: string, organizationId: string): Promise<{ id: string; name: string }[]> => + findWorkspacesForOrganization(userId, organizationId, { writableOnly: true }) ); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.test.ts index 9f2e595d5e6e..5273e0552550 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.test.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; -import { updateOrganizationAISettingsAction, updateOrganizationDisplayTimeZoneAction } from "./actions"; -import { ZOrganizationAISettingsInput, ZOrganizationDisplayTimeZoneInput } from "./schemas"; +import { + deleteOrganizationAction, + updateOrganizationAISettingsAction, + updateOrganizationNameAction, +} from "./actions"; +import { ZOrganizationAISettingsInput } from "./schemas"; const mocks = vi.hoisted(() => ({ isInstanceAIConfigured: vi.fn(), - checkAuthorizationUpdated: vi.fn(), + assertCan: vi.fn(), deleteOrganization: vi.fn(), getOrganization: vi.fn(), getIsMultiOrgEnabled: vi.fn(), @@ -21,8 +25,8 @@ vi.mock("@/lib/utils/action-client", () => ({ }, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mocks.checkAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, })); vi.mock("@/lib/organization/service", () => ({ @@ -53,7 +57,7 @@ describe("organization AI settings actions", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); mocks.getOrganization.mockResolvedValue({ id: organizationId, isAISmartToolsEnabled: false, @@ -79,7 +83,7 @@ describe("organization AI settings actions", () => { }); }); - test("passes owner and manager roles to the authorization check and updates organization settings", async () => { + test("requires organization.manage and updates organization settings", async () => { const ctx = { user: { id: "user_1", locale: "en-US" }, auditLoggingCtx: {}, @@ -93,17 +97,9 @@ describe("organization AI settings actions", () => { const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any); - expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId, - access: [ - { - type: "organization", - schema: ZOrganizationAISettingsInput, - data: parsedInput.data, - roles: ["owner", "manager"], - }, - ], + expect(mocks.assertCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: organizationId, }); expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId); expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data); @@ -125,7 +121,7 @@ describe("organization AI settings actions", () => { }); test("propagates authorization failures so members cannot update AI settings", async () => { - mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized")); + mocks.assertCan.mockRejectedValueOnce(new AuthorizationError("Not authorized")); await expect( updateOrganizationAISettingsAction({ @@ -145,6 +141,44 @@ describe("organization AI settings actions", () => { expect(mocks.updateOrganization).not.toHaveBeenCalled(); }); + test("requires organization.write for organization name updates", async () => { + const ctx = { + user: { id: "user_owner", locale: "en-US" }, + auditLoggingCtx: {}, + }; + + await updateOrganizationNameAction({ + ctx, + parsedInput: { + organizationId, + data: { name: "Renamed organization" }, + }, + } as never); + + expect(mocks.assertCan).toHaveBeenCalledWith({ type: "user", id: "user_owner" }, "organization.write", { + type: "organization", + id: organizationId, + }); + }); + + test("requires organization.write for organization deletion", async () => { + const ctx = { + user: { id: "user_owner", locale: "en-US" }, + auditLoggingCtx: {}, + }; + + await deleteOrganizationAction({ + ctx, + parsedInput: { organizationId }, + } as never); + + expect(mocks.assertCan).toHaveBeenCalledWith({ type: "user", id: "user_owner" }, "organization.write", { + type: "organization", + id: organizationId, + }); + expect(mocks.deleteOrganization).toHaveBeenCalledWith(organizationId); + }); + test("rejects enabling AI when the instance AI provider is not configured", async () => { mocks.isInstanceAIConfigured.mockReturnValueOnce(false); @@ -185,93 +219,6 @@ describe("organization AI settings actions", () => { }); }); - test("accepts a valid IANA display time zone", () => { - expect( - ZOrganizationDisplayTimeZoneInput.parse({ - displayTimeZone: "Asia/Manila", - }) - ).toEqual({ - displayTimeZone: "Asia/Manila", - }); - }); - - test("accepts null as display time zone (UTC default)", () => { - expect( - ZOrganizationDisplayTimeZoneInput.parse({ - displayTimeZone: null, - }) - ).toEqual({ - displayTimeZone: null, - }); - }); - - test("rejects an invalid display time zone", () => { - const result = ZOrganizationDisplayTimeZoneInput.safeParse({ - displayTimeZone: "Manila", - }); - - expect(result.success).toBe(false); - }); - - test("passes owner and manager roles to the authorization check and updates the display time zone", async () => { - mocks.updateOrganization.mockResolvedValueOnce({ - id: organizationId, - displayTimeZone: "Asia/Manila", - }); - - const ctx = { - user: { id: "user_1", locale: "en-US" }, - auditLoggingCtx: {}, - }; - const parsedInput = { - organizationId, - data: { - displayTimeZone: "Asia/Manila", - }, - }; - - const result = await updateOrganizationDisplayTimeZoneAction({ ctx, parsedInput } as any); - - expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId, - access: [ - { - type: "organization", - schema: ZOrganizationDisplayTimeZoneInput, - data: parsedInput.data, - roles: ["owner", "manager"], - }, - ], - }); - expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data); - expect(result).toEqual({ - id: organizationId, - displayTimeZone: "Asia/Manila", - }); - }); - - test("propagates authorization failures so members cannot update the display time zone", async () => { - mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized")); - - await expect( - updateOrganizationDisplayTimeZoneAction({ - ctx: { - user: { id: "user_member", locale: "en-US" }, - auditLoggingCtx: {}, - }, - parsedInput: { - organizationId, - data: { - displayTimeZone: "Asia/Manila", - }, - }, - } as any) - ).rejects.toThrow(AuthorizationError); - - expect(mocks.updateOrganization).not.toHaveBeenCalled(); - }); - test("allows disabling AI when the instance configuration later becomes invalid", async () => { mocks.getOrganization.mockResolvedValueOnce({ id: organizationId, diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.ts index be09383b411f..29ec20583746 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions.ts @@ -3,19 +3,17 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; -import type { TOrganizationRole } from "@formbricks/types/memberships"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { isInstanceAIConfigured } from "@/lib/ai/service"; +import { type TAuthorizationAction, assertCan } from "@/lib/authorization"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getTranslate } from "@/lingodotdev/server"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { ZOrganizationAISettingsInput, - ZOrganizationDisplayTimeZoneInput, ZUpdateOrganizationAISettingsAction, ZUpdateOrganizationDisplayTimeZoneAction, } from "./schemas"; @@ -23,21 +21,15 @@ import { async function updateOrganizationAction({ ctx, organizationId, - schema, data, - roles, + action, }: { ctx: AuthenticatedActionClientCtx; organizationId: string; - schema: z.ZodObject; data: z.infer>; - roles: TOrganizationRole[]; + action: Extract; }) { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [{ type: "organization", schema, data, roles }], - }); + await assertCan({ type: "user", id: ctx.user.id }, action, { type: "organization", id: organizationId }); ctx.auditLoggingCtx.organizationId = organizationId; const oldObject = await getOrganization(organizationId); const result = await updateOrganization(organizationId, data); @@ -67,9 +59,8 @@ export const updateOrganizationNameAction = authenticatedActionClient updateOrganizationAction({ ctx, organizationId: parsedInput.organizationId, - schema: ZOrganizationUpdateInput.pick({ name: true }), data: parsedInput.data, - roles: ["owner"], + action: "organization.write", }) ) ); @@ -149,9 +140,8 @@ export const updateOrganizationAISettingsAction = authenticatedActionClient return updateOrganizationAction({ ctx, organizationId: parsedInput.organizationId, - schema: ZOrganizationAISettingsInput, data: parsedInput.data, - roles: ["owner", "manager"], + action: "organization.manage", }); } ) @@ -173,9 +163,8 @@ export const updateOrganizationDisplayTimeZoneAction = authenticatedActionClient updateOrganizationAction({ ctx, organizationId: parsedInput.organizationId, - schema: ZOrganizationDisplayTimeZoneInput, data: parsedInput.data, - roles: ["owner", "manager"], + action: "organization.manage", }) ) ); @@ -194,15 +183,9 @@ export const deleteOrganizationAction = authenticatedActionClient throw new OperationNotAllowedError(t("workspace.settings.general.organization_deletion_disabled")); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.write", { + type: "organization", + id: parsedInput.organizationId, }); ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; const oldObject = await getOrganization(parsedInput.organizationId); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts index 2e7e7f07ba05..07e550acda33 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZIntegrationInput } from "@formbricks/types/integration"; +import { assertCan } from "@/lib/authorization"; import { withStoredIntegrationKey } from "@/lib/integration/redact-credentials"; import { createOrUpdateIntegration, @@ -11,7 +12,6 @@ import { } from "@/lib/integration/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromIntegrationId, getOrganizationIdFromWorkspaceId, @@ -36,20 +36,9 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); ctx.auditLoggingCtx.organizationId = organizationId; @@ -102,20 +91,9 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId), }); ctx.auditLoggingCtx.organizationId = organizationId; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.test.ts index 49a83f3f6474..33d094a0141a 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.test.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { ResourceNotFoundError } from "@formbricks/types/errors"; const mocks = vi.hoisted(() => ({ - checkAuthorizationUpdated: vi.fn(), + assertCan: vi.fn(), getOrganizationIdFromWorkspaceId: vi.fn(), getSpreadsheetNameById: vi.fn(), getIntegrationByType: vi.fn(), @@ -14,8 +14,8 @@ vi.mock("@/lib/utils/action-client", () => ({ }, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mocks.checkAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, })); vi.mock("@/lib/utils/helper", () => ({ @@ -57,7 +57,7 @@ const storedIntegration = { describe("getSpreadsheetNameByIdAction", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org1"); mocks.getSpreadsheetNameById.mockResolvedValue("My Sheet"); mocks.getIntegrationByType.mockResolvedValue(storedIntegration); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.ts index 9daa201afca8..fdb543e48ffe 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions.ts @@ -4,11 +4,10 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet"; +import { assertCan } from "@/lib/authorization"; import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service"; import { getIntegrationByType } from "@/lib/integration/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; const ZValidateGoogleSheetsConnectionAction = z.object({ workspaceId: ZId, @@ -17,20 +16,9 @@ const ZValidateGoogleSheetsConnectionAction = z.object({ export const validateGoogleSheetsConnectionAction = authenticatedActionClient .inputSchema(ZValidateGoogleSheetsConnectionAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); const integration = await getIntegrationByType(parsedInput.workspaceId, "googleSheets"); @@ -50,20 +38,9 @@ const ZGetSpreadsheetNameByIdAction = z.object({ export const getSpreadsheetNameByIdAction = authenticatedActionClient .inputSchema(ZGetSpreadsheetNameByIdAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); // The integration is read from the database rather than accepted from the client. The settings page diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/actions.ts index 27d317c2d3bc..f08f15f189af 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/actions.ts @@ -2,10 +2,9 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; +import { assertCan } from "@/lib/authorization"; import { getSlackChannels } from "@/lib/slack/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; const ZGetSlackChannelsAction = z.object({ workspaceId: ZId, @@ -14,20 +13,9 @@ const ZGetSlackChannelsAction = z.object({ export const getSlackChannelsAction = authenticatedActionClient .inputSchema(ZGetSlackChannelsAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); return await getSlackChannels(parsedInput.workspaceId); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/actions.ts index 1d415f283a69..a853c20ca013 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/actions.ts @@ -3,11 +3,11 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZResponseFilterCriteria } from "@formbricks/types/responses"; +import { assertCan } from "@/lib/authorization"; import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service"; import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; +import { getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { getSurveySummary } from "./summary/lib/surveySummary"; const ZGetResponsesAction = z.object({ @@ -20,22 +20,9 @@ const ZGetResponsesAction = z.object({ export const getResponsesAction = authenticatedActionClient .inputSchema(ZGetResponsesAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - schema: ZResponseFilterCriteria, - data: parsedInput.filterCriteria, - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); return getResponses( @@ -54,22 +41,9 @@ const ZGetSurveySummaryAction = z.object({ export const getSurveySummaryAction = authenticatedActionClient .inputSchema(ZGetSurveySummaryAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - schema: ZResponseFilterCriteria, - data: parsedInput.filterCriteria, - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); return getSurveySummary(parsedInput.surveyId, parsedInput.filterCriteria); }); @@ -82,22 +56,9 @@ const ZGetResponseCountAction = z.object({ export const getResponseCountAction = authenticatedActionClient .inputSchema(ZGetResponseCountAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - schema: ZResponseFilterCriteria, - data: parsedInput.filterCriteria, - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria); @@ -112,20 +73,9 @@ const ZGetDisplaysWithContactAction = z.object({ export const getDisplaysWithContactAction = authenticatedActionClient .inputSchema(ZGetDisplaysWithContactAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts index 9aa24b06dce0..d2b874c707d2 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -11,12 +11,12 @@ import { } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/example-responses"; import { createResponseWithQuotaEvaluation } from "@/app/api/v1/client/[workspaceId]/responses/lib/response"; import { assertOrganizationAIConfigured } from "@/lib/ai/service"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { addTagToRespone } from "@/lib/tagOnResponse/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { convertToCsv } from "@/lib/utils/file-conversion"; import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; @@ -39,20 +39,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); const survey = await getSurvey(parsedInput.surveyId); @@ -85,20 +74,9 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); ctx.auditLoggingCtx.organizationId = organizationId; @@ -153,20 +131,9 @@ export const generateExampleResponsesAction = authenticatedActionClient const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); // Throws OperationNotAllowedError if AI is unentitled, disabled, or @@ -253,20 +220,9 @@ const ZGetEmailHtmlAction = z.object({ export const getEmailHtmlAction = authenticatedActionClient .inputSchema(ZGetEmailHtmlAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale); @@ -288,20 +244,9 @@ export const generatePersonalLinksAction = authenticatedActionClient throw new OperationNotAllowedError("Contacts are not enabled for this workspace"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); // Get contacts and generate personal links @@ -374,20 +319,9 @@ const ZUpdateSingleUseLinksAction = z.object({ export const updateSingleUseLinksAction = authenticatedActionClient .inputSchema(ZUpdateSingleUseLinksAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); const survey = await getSurvey(parsedInput.surveyId); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/actions.ts index ad442f68645a..749a4393b4e5 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/actions.ts @@ -4,12 +4,12 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZResponseFilterCriteria } from "@formbricks/types/responses"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { getTagsByWorkspaceId } from "@/lib/tag/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; @@ -25,24 +25,13 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient .inputSchema(ZGetResponsesDownloadUrlAction) .action(async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); + const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: workspaceId, }); - const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); const result = await getResponseDownloadFile( parsedInput.surveyId, parsedInput.format, @@ -80,20 +69,9 @@ export const getSurveyFilterDataAction = authenticatedActionClient const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: survey.workspaceId, }); const organizationBilling = await getOrganizationBilling(organizationId); @@ -103,10 +81,8 @@ export const getSurveyFilterDataAction = authenticatedActionClient const isQuotasAllowed = await getIsQuotasEnabled(organizationId); - const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([ - getTagsByWorkspaceId(workspaceId), + getTagsByWorkspaceId(survey.workspaceId), getResponseFilteringValues(parsedInput.surveyId), isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [], ]); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx index 74d54cc9513a..2aa7363f0e74 100755 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -80,7 +80,7 @@ const getDateRangeLabel = (dateRange: DateRange, t: TFunction) => { return matched ? matched.getLabel(t) : getFilterDropDownLabels(t).CUSTOM_RANGE; }; -export const CustomFilter = ({ survey }: CustomFilterProps) => { +export const CustomFilter = ({ survey }: Readonly) => { const { t } = useTranslation(); const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter(); const [filterRange, setFilterRange] = useState( diff --git a/apps/web/app/(redirects)/environments/[environmentId]/[...path]/route.ts b/apps/web/app/(redirects)/environments/[environmentId]/[...path]/route.ts index 20a58cdd448e..e45e98e3cffe 100644 --- a/apps/web/app/(redirects)/environments/[environmentId]/[...path]/route.ts +++ b/apps/web/app/(redirects)/environments/[environmentId]/[...path]/route.ts @@ -1,7 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserNavigateWorkspace } from "@/lib/workspace/auth"; import { getSession } from "@/modules/auth/lib/session"; export const GET = async ( @@ -19,7 +19,7 @@ export const GET = async ( const workspace = await findWorkspaceByIdOrLegacyEnvId(environmentId); if (!workspace) return notFound(); - const hasAccess = await hasUserWorkspaceAccess(session.user.id, workspace.id); + const hasAccess = await canUserNavigateWorkspace(session.user.id, workspace); if (!hasAccess) throw new AuthorizationError("Unauthorized"); return redirect(`/workspaces/${workspace.id}/${path.join("/")}`); diff --git a/apps/web/app/(redirects)/environments/[environmentId]/route.ts b/apps/web/app/(redirects)/environments/[environmentId]/route.ts index 69f4622500ef..9d4ffb92fc3b 100644 --- a/apps/web/app/(redirects)/environments/[environmentId]/route.ts +++ b/apps/web/app/(redirects)/environments/[environmentId]/route.ts @@ -1,7 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors"; import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserNavigateWorkspace } from "@/lib/workspace/auth"; import { getSession } from "@/modules/auth/lib/session"; export const GET = async (_: Request, context: { params: Promise<{ environmentId: string }> }) => { @@ -16,7 +16,7 @@ export const GET = async (_: Request, context: { params: Promise<{ environmentId const workspace = await findWorkspaceByIdOrLegacyEnvId(environmentId); if (!workspace) return notFound(); - const hasAccess = await hasUserWorkspaceAccess(session.user.id, workspace.id); + const hasAccess = await canUserNavigateWorkspace(session.user.id, workspace); if (!hasAccess) throw new AuthorizationError("Unauthorized"); return redirect(`/workspaces/${workspace.id}/`); diff --git a/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.test.ts b/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.test.ts new file mode 100644 index 000000000000..9bc8e76e2086 --- /dev/null +++ b/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; +import { assertFeedbackSourceDirectoryAccess } from "@/lib/feedback-source/access"; +import { importCsvFile } from "@/lib/feedback-source/csv-file-import"; +import { getFeedbackSourceWithMappingsById } from "@/lib/feedback-source/service"; +import { getUser } from "@/lib/user/service"; +import { getSession } from "@/modules/auth/lib/session"; +import { POST } from "./route"; + +vi.mock("@formbricks/logger", () => ({ + logger: { error: vi.fn() }, +})); +vi.mock("@/lib/feedback-source/access", () => ({ + assertFeedbackSourceDirectoryAccess: vi.fn(), +})); +vi.mock("@/lib/feedback-source/csv-file-import", () => ({ + CsvImportValidationError: class CsvImportValidationError extends Error {}, + importCsvFile: vi.fn(), +})); +vi.mock("@/lib/feedback-source/service", () => ({ + getFeedbackSourceWithMappingsById: vi.fn(), +})); +vi.mock("@/lib/user/service", () => ({ getUser: vi.fn() })); +vi.mock("@/lib/authorization", () => ({ assertCan: vi.fn() })); +vi.mock("@/modules/auth/lib/session", () => ({ getSession: vi.fn() })); + +const userId = "user_1"; +const workspaceId = "workspace_1"; +const feedbackSourceId = "source_1"; +const feedbackDirectoryId = "directory_1"; + +const makeRequest = (): Request => { + const body = new FormData(); + body.set("workspaceId", workspaceId); + body.set("feedbackSourceId", feedbackSourceId); + body.set("file", new File(["value\nexample"], "feedback.csv", { type: "text/csv" })); + return new Request("http://localhost/api/unify-feedback/sources/csv/import", { method: "POST", body }); +}; + +describe("CSV feedback source import authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue({ user: { id: userId } } as never); + vi.mocked(getUser).mockResolvedValue({ id: userId } as never); + vi.mocked(assertCan).mockResolvedValue(undefined); + vi.mocked(getFeedbackSourceWithMappingsById).mockResolvedValue({ feedbackDirectoryId } as never); + vi.mocked(assertFeedbackSourceDirectoryAccess).mockResolvedValue(undefined); + vi.mocked(importCsvFile).mockResolvedValue({ imported: 1 } as never); + }); + + test("requires exact assignment write access before importing", async () => { + const response = await POST(makeRequest()); + + expect(response.status).toBe(200); + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: userId }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); + expect(assertFeedbackSourceDirectoryAccess).toHaveBeenCalledWith( + userId, + feedbackDirectoryId, + workspaceId, + "write" + ); + expect(importCsvFile).toHaveBeenCalledWith({ feedbackSourceId, workspaceId, file: expect.any(File) }); + }); + + test("preserves the existing forbidden response for a denied assignment", async () => { + vi.mocked(assertFeedbackSourceDirectoryAccess).mockRejectedValue( + new AuthorizationError("Not authorized") + ); + + const response = await POST(makeRequest()); + + expect(response.status).toBe(403); + expect(importCsvFile).not.toHaveBeenCalled(); + }); + + test("does not log identifiers or raw operational errors", async () => { + vi.mocked(assertFeedbackSourceDirectoryAccess).mockRejectedValue( + new Error(`evaluator failed for ${feedbackDirectoryId} and ${feedbackSourceId}`) + ); + + const response = await POST(makeRequest()); + + expect(response.status).toBe(500); + const logOutput = JSON.stringify(vi.mocked(logger.error).mock.calls); + expect(logOutput).not.toContain(feedbackDirectoryId); + expect(logOutput).not.toContain(feedbackSourceId); + expect(logOutput).not.toContain("evaluator failed"); + }); +}); diff --git a/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.ts b/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.ts index b73e5d910e9d..1b29c49e6ee9 100644 --- a/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.ts +++ b/apps/web/app/api/(internal)/unify-feedback/sources/csv/import/route.ts @@ -6,10 +6,11 @@ import { InvalidInputError, ResourceNotFoundError, } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; +import { assertFeedbackSourceDirectoryAccess } from "@/lib/feedback-source/access"; import { CsvImportValidationError, importCsvFile } from "@/lib/feedback-source/csv-file-import"; +import { getFeedbackSourceWithMappingsById } from "@/lib/feedback-source/service"; import { getUser } from "@/lib/user/service"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getOrganizationIdFromFeedbackSourceId } from "@/lib/utils/helper"; import { getSession } from "@/modules/auth/lib/session"; import { CSV_FILE_TOO_LARGE_ERROR_CODE, @@ -77,22 +78,18 @@ export const POST = async (request: Request) => { throw new InvalidInputError("workspaceId, feedbackSourceId, and file are required"); } - const organizationId = await getOrganizationIdFromFeedbackSourceId(feedbackSourceId); - await checkAuthorizationUpdated({ - userId: user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], - }); + await assertCan({ type: "user", id: user.id }, "workspace.write", { type: "workspace", id: workspaceId }); + + const feedbackSource = await getFeedbackSourceWithMappingsById(feedbackSourceId, workspaceId); + if (!feedbackSource) { + throw new ResourceNotFoundError("FeedbackSource", feedbackSourceId); + } + await assertFeedbackSourceDirectoryAccess( + user.id, + feedbackSource.feedbackDirectoryId, + workspaceId, + "write" + ); const result = await importCsvFile({ feedbackSourceId, workspaceId, file }); @@ -121,7 +118,10 @@ export const POST = async (request: Request) => { return buildCsvImportErrorResponse(error.message, 400); } - logger.error({ error }, "Failed to import CSV feedback source data"); + logger.error( + { errorName: error instanceof Error ? error.name : "unknown" }, + "Failed to import CSV feedback source data" + ); return buildCsvImportErrorResponse(CSV_IMPORT_FAILED_ERROR_CODE, 500); } }; diff --git a/apps/web/app/api/internal/feedback-datasets/lib/access.test.ts b/apps/web/app/api/internal/feedback-datasets/lib/access.test.ts index 272c069f6e49..f1b8eb7eb9c1 100644 --- a/apps/web/app/api/internal/feedback-datasets/lib/access.test.ts +++ b/apps/web/app/api/internal/feedback-datasets/lib/access.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; import { getOrganizationIdFromDirectoryId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { requireFeedbackDatasetMutationAccess } from "./access"; vi.mock("server-only", () => ({})); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: vi.fn(), +vi.mock("@/lib/authorization", () => ({ + can: vi.fn(), })); vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ @@ -26,7 +26,7 @@ const args = [datasetId, "req_1", "/api/internal/feedback-datasets/x/purge"] as beforeEach(() => { vi.mocked(getOrganizationIdFromDirectoryId).mockResolvedValue("org_1"); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true as never); + vi.mocked(can).mockResolvedValue(true); }); describe("requireFeedbackDatasetMutationAccess", () => { @@ -44,15 +44,14 @@ describe("requireFeedbackDatasetMutationAccess", () => { await requireFeedbackDatasetMutationAccess(session, ...args); expect(getOrganizationIdFromDirectoryId).toHaveBeenCalledWith(datasetId); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: "org_other", - access: [{ type: "organization", roles: ["owner", "manager"] }], + expect(can).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: "org_other", }); }); test("rejects a caller who is not an owner or manager", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("nope")); + vi.mocked(can).mockResolvedValue(false); const result = await requireFeedbackDatasetMutationAccess(session, ...args); @@ -76,7 +75,7 @@ describe("requireFeedbackDatasetMutationAccess", () => { const missing = (await requireFeedbackDatasetMutationAccess(session, ...args)) as Response; vi.mocked(getOrganizationIdFromDirectoryId).mockResolvedValue("org_1"); - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("nope")); + vi.mocked(can).mockResolvedValue(false); const forbidden = (await requireFeedbackDatasetMutationAccess(session, ...args)) as Response; expect(missing.status).toBe(403); @@ -87,7 +86,7 @@ describe("requireFeedbackDatasetMutationAccess", () => { // The entitlement message names the org's plan, so a non-member must never reach it. test("checks the caller's role before revealing the organization's license state", async () => { vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(false); - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("nope")); + vi.mocked(can).mockResolvedValue(false); const result = (await requireFeedbackDatasetMutationAccess(session, ...args)) as Response; @@ -110,7 +109,7 @@ describe("requireFeedbackDatasetMutationAccess", () => { // An unexpected failure must not read as "allowed". test("rethrows an unexpected authorization error", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new Error("db down")); + vi.mocked(can).mockRejectedValue(new Error("db down")); await expect(requireFeedbackDatasetMutationAccess(session, ...args)).rejects.toThrow("db down"); }); diff --git a/apps/web/app/api/internal/feedback-datasets/lib/access.ts b/apps/web/app/api/internal/feedback-datasets/lib/access.ts index 8939048d497c..d9e6478ff4b7 100644 --- a/apps/web/app/api/internal/feedback-datasets/lib/access.ts +++ b/apps/web/app/api/internal/feedback-datasets/lib/access.ts @@ -1,8 +1,8 @@ import "server-only"; -import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { problemForbidden, problemUnauthorized } from "@/app/api/v3/lib/response"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { can } from "@/lib/authorization"; import { getOrganizationIdFromDirectoryId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -55,17 +55,13 @@ export async function requireFeedbackDatasetMutationAccess( // Authorization before entitlement, so a non-member never learns anything about the owning // organization — including whether it holds an Enterprise license. - try { - await checkAuthorizationUpdated({ - userId, - organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], - }); - } catch (err) { - if (err instanceof AuthorizationError) { - return denied; - } - throw err; + if ( + !(await can({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, + })) + ) { + return denied; } if (!(await getIsFeedbackDirectoriesEnabled(organizationId))) { diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index 23b8bcd19631..29eb933cfcd8 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -50,6 +50,7 @@ vi.mock("@/modules/auth/lib/oauth-urls", async (importOriginal) => ({ ...(await importOriginal()), getAuthIssuerUrl: () => "http://localhost/api/auth", getMcpOrigin: () => "http://localhost", + getMcpOAuthJwksUrl: () => "http://formbricks:3000/api/auth/jwks", getMcpProtectedResourceMetadataUrl: () => "http://localhost/.well-known/oauth-protected-resource/api/mcp", getMcpResourceUrl: () => "http://localhost/api/mcp", })); @@ -453,6 +454,7 @@ describe("POST /api/mcp", () => { expect(verifyBearerTokenMock).toHaveBeenCalledWith( "eyJhbGciOiJFZERTQSJ9.payload.signature", expect.objectContaining({ + jwksUrl: "http://formbricks:3000/api/auth/jwks", verifyOptions: expect.objectContaining({ audience: "http://localhost/api/mcp", issuer: "http://localhost/api/auth", diff --git a/apps/web/app/api/v1/auth.test.ts b/apps/web/app/api/v1/auth.test.ts index 4c5a20bf08b8..17e28d0dfc75 100644 --- a/apps/web/app/api/v1/auth.test.ts +++ b/apps/web/app/api/v1/auth.test.ts @@ -1,6 +1,5 @@ import { NextRequest } from "next/server"; import { describe, expect, test, vi } from "vitest"; -import { TAPIKeyWorkspacePermission } from "@formbricks/types/auth"; import { DatabaseError, InvalidInputError, @@ -8,7 +7,6 @@ import { UniqueConstraintError, } from "@formbricks/types/errors"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { authenticateRequest, handleErrorResponse } from "./auth"; vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({ @@ -52,48 +50,6 @@ describe("getApiKeyWithPermissions", () => { }); }); -describe("hasPermission", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - permission: "manage", - workspaceId: "workspace-1", - workspaceName: "Workspace 1", - }, - { - permission: "write", - workspaceId: "workspace-2", - workspaceName: "Workspace 2", - }, - { - permission: "read", - workspaceId: "workspace-3", - workspaceName: "Workspace 3", - }, - ]; - - test("returns true for manage permission with any method", () => { - expect(hasPermission(permissions, "workspace-1", "GET")).toBe(true); - expect(hasPermission(permissions, "workspace-1", "POST")).toBe(true); - expect(hasPermission(permissions, "workspace-1", "DELETE")).toBe(true); - }); - - test("handles write permission correctly", () => { - expect(hasPermission(permissions, "workspace-2", "GET")).toBe(true); - expect(hasPermission(permissions, "workspace-2", "POST")).toBe(true); - expect(hasPermission(permissions, "workspace-2", "DELETE")).toBe(false); - }); - - test("handles read permission correctly", () => { - expect(hasPermission(permissions, "workspace-3", "GET")).toBe(true); - expect(hasPermission(permissions, "workspace-3", "POST")).toBe(false); - expect(hasPermission(permissions, "workspace-3", "DELETE")).toBe(false); - }); - - test("returns false for non-existent workspace", () => { - expect(hasPermission(permissions, "workspace-4", "GET")).toBe(false); - }); -}); - describe("authenticateRequest", () => { test("should return authentication data for valid API key", async () => { const request = new NextRequest("http://localhost", { diff --git a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts index 736ea89ebace..8545391b49bf 100644 --- a/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts +++ b/apps/web/app/api/v1/management/action-classes/[actionClassId]/route.ts @@ -8,7 +8,8 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; const fetchAndAuthorizeActionClass = async ( authentication: TAuthenticationApiKey, @@ -22,7 +23,13 @@ const fetchAndAuthorizeActionClass = async ( } // Check if API key has permission to access this workspace with appropriate permissions - if (!hasPermission(authentication.workspacePermissions, actionClass.workspaceId, method)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(method), + { type: "workspace", id: actionClass.workspaceId } + )) + ) { throw new Error("Unauthorized"); } @@ -98,7 +105,7 @@ export const PUT = withV1ApiWrapper({ } // Accept workspaceId as alternative to environmentId — resolve to production environment - const resolved = await resolveBodyIds(actionClassUpdate, authentication.workspacePermissions, "PUT"); + const resolved = await resolveBodyIds(actionClassUpdate, authentication, "PUT"); if (!resolved.ok) return { response: resolved.response }; const inputValidation = ZActionClassInput.safeParse(resolved.body); @@ -113,7 +120,11 @@ export const PUT = withV1ApiWrapper({ if ( !resolved.alreadyAuthorized && - !hasPermission(authentication.workspacePermissions, inputValidation.data.workspaceId, "PUT") + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("PUT"), + { type: "workspace", id: inputValidation.data.workspaceId } + )) ) { return { response: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/management/action-classes/route.ts b/apps/web/app/api/v1/management/action-classes/route.ts index 5c772aeb8dee..f28d47ffcc71 100644 --- a/apps/web/app/api/v1/management/action-classes/route.ts +++ b/apps/web/app/api/v1/management/action-classes/route.ts @@ -7,7 +7,8 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { createActionClass } from "@/lib/actionClass/service"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getActionClasses } from "./lib/action-classes"; export const GET = withV1ApiWrapper({ @@ -56,7 +57,7 @@ export const POST = withV1ApiWrapper({ } // Validate workspace-level permission - const resolved = await resolveBodyIds(actionClassInput, authentication.workspacePermissions, "POST"); + const resolved = await resolveBodyIds(actionClassInput, authentication, "POST"); if (!resolved.ok) return { response: resolved.response }; const inputValidation = ZActionClassInput.safeParse(resolved.body); @@ -72,7 +73,11 @@ export const POST = withV1ApiWrapper({ if ( !resolved.alreadyAuthorized && - !hasPermission(authentication.workspacePermissions, inputValidation.data.workspaceId, "POST") + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: inputValidation.data.workspaceId } + )) ) { return { response: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/management/lib/workspace-resolver.ts b/apps/web/app/api/v1/management/lib/workspace-resolver.ts index 1c8164f7bb06..17e2850a8269 100644 --- a/apps/web/app/api/v1/management/lib/workspace-resolver.ts +++ b/apps/web/app/api/v1/management/lib/workspace-resolver.ts @@ -1,7 +1,8 @@ -import { TAPIKeyWorkspacePermission } from "@formbricks/types/auth"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; import { responses } from "@/app/lib/api/response"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -14,7 +15,7 @@ type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; */ export const resolveBodyIds = async >( body: T, - permissions: TAPIKeyWorkspacePermission[], + authentication: TAuthenticationApiKey, method: HttpMethod ): Promise< | { ok: true; body: T & { workspaceId: string }; alreadyAuthorized: boolean } @@ -42,7 +43,13 @@ export const resolveBodyIds = async >( const workspaceId = workspace.id; - if (!hasPermission(permissions, workspaceId, method)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(method), + { type: "workspace", id: workspaceId } + )) + ) { return { ok: false, response: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/management/responses/[responseId]/route.ts b/apps/web/app/api/v1/management/responses/[responseId]/route.ts index 5afad95a4300..b26f766e281a 100644 --- a/apps/web/app/api/v1/management/responses/[responseId]/route.ts +++ b/apps/web/app/api/v1/management/responses/[responseId]/route.ts @@ -6,11 +6,12 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { TApiV1Authentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { deleteResponse, getResponse } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service"; import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules/storage/utils"; import { updateResponseWithQuotaEvaluation } from "./lib/response"; @@ -38,7 +39,13 @@ async function fetchAndAuthorizeResponse( return { error: responses.notFoundResponse("Survey", response.surveyId, true) }; } - if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, requiredPermission)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(requiredPermission), + { type: "workspace", id: survey.workspaceId } + )) + ) { return { error: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/management/responses/route.ts b/apps/web/app/api/v1/management/responses/route.ts index fd7fac2e61da..564f1f3fb658 100644 --- a/apps/web/app/api/v1/management/responses/route.ts +++ b/apps/web/app/api/v1/management/responses/route.ts @@ -7,10 +7,11 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getSurvey } from "@/lib/survey/service"; import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service"; import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules/storage/utils"; import { createResponseWithQuotaEvaluation, getResponses, getResponsesByWorkspaceIds } from "./lib/response"; @@ -35,7 +36,13 @@ export const GET = withV1ApiWrapper({ response: responses.notFoundResponse("Survey", surveyId, true), }; } - if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, "GET")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("GET"), + { type: "workspace", id: survey.workspaceId } + )) + ) { return { response: responses.unauthorizedResponse(), }; @@ -103,7 +110,7 @@ export const POST = withV1ApiWrapper({ } // Accept workspaceId as alternative to environmentId — resolve to production environment - const resolved = await resolveBodyIds(jsonInput, authentication.workspacePermissions, "POST"); + const resolved = await resolveBodyIds(jsonInput, authentication, "POST"); if (!resolved.ok) return { response: resolved.response }; const inputValidation = ZResponseInput.safeParse(resolved.body); @@ -121,7 +128,11 @@ export const POST = withV1ApiWrapper({ if ( !resolved.alreadyAuthorized && - !hasPermission(authentication.workspacePermissions, responseInput.workspaceId, "POST") + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: responseInput.workspaceId } + )) ) { return { response: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/management/storage/lib/utils.test.ts b/apps/web/app/api/v1/management/storage/lib/utils.test.ts index b8a19c02f353..82f8d48750d9 100644 --- a/apps/web/app/api/v1/management/storage/lib/utils.test.ts +++ b/apps/web/app/api/v1/management/storage/lib/utils.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session, TAuthenticationApiKey } from "@formbricks/types/auth"; import { responses } from "@/app/lib/api/response"; -import { hasUserWorkspaceAccessForAction } from "@/lib/workspace/auth"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; import { checkAuth } from "./utils"; // Create mock response objects @@ -10,13 +9,7 @@ const mockBadRequestResponse = new Response("Bad Request", { status: 400 }); const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 }); const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 }); -vi.mock("@/lib/workspace/auth", () => ({ - hasUserWorkspaceAccessForAction: vi.fn(), -})); - -vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({ - hasPermission: vi.fn(), -})); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); vi.mock("@/app/lib/api/response", () => ({ responses: { @@ -60,15 +53,14 @@ describe("checkAuth", () => { }, }; - vi.mocked(hasPermission).mockReturnValue(false); + vi.mocked(can).mockResolvedValue(false); const result = await checkAuth(mockAuthentication, workspaceId); - expect(hasPermission).toHaveBeenCalledWith( - mockAuthentication.workspacePermissions, - "workspace-123", - "POST" - ); + expect(can).toHaveBeenCalledWith({ type: "apiKey", id: "hashed-key" }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); expect(responses.unauthorizedResponse).toHaveBeenCalled(); expect(result).toBe(mockUnauthorizedResponse); }); @@ -90,15 +82,14 @@ describe("checkAuth", () => { }, }; - vi.mocked(hasPermission).mockReturnValue(true); + vi.mocked(can).mockResolvedValue(true); const result = await checkAuth(mockAuthentication, workspaceId); - expect(hasPermission).toHaveBeenCalledWith( - mockAuthentication.workspacePermissions, - "workspace-123", - "POST" - ); + expect(can).toHaveBeenCalledWith({ type: "apiKey", id: "hashed-key" }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); expect(result).toBeUndefined(); }); @@ -110,11 +101,14 @@ describe("checkAuth", () => { expires: "2024-12-31T23:59:59.999Z", }; - vi.mocked(hasUserWorkspaceAccessForAction).mockResolvedValue(false); + vi.mocked(can).mockResolvedValue(false); const result = await checkAuth(mockSession, workspaceId); - expect(hasUserWorkspaceAccessForAction).toHaveBeenCalledWith("user-123", workspaceId, "POST"); + expect(can).toHaveBeenCalledWith({ type: "user", id: "user-123" }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); expect(responses.unauthorizedResponse).toHaveBeenCalled(); expect(result).toBe(mockUnauthorizedResponse); }); @@ -127,11 +121,14 @@ describe("checkAuth", () => { expires: "2024-12-31T23:59:59.999Z", }; - vi.mocked(hasUserWorkspaceAccessForAction).mockResolvedValue(true); + vi.mocked(can).mockResolvedValue(true); const result = await checkAuth(mockSession, workspaceId); - expect(hasUserWorkspaceAccessForAction).toHaveBeenCalledWith("user-123", workspaceId, "POST"); + expect(can).toHaveBeenCalledWith({ type: "user", id: "user-123" }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); expect(result).toBeUndefined(); }); diff --git a/apps/web/app/api/v1/management/storage/lib/utils.ts b/apps/web/app/api/v1/management/storage/lib/utils.ts index 048a21f8e7b8..330af541bea9 100644 --- a/apps/web/app/api/v1/management/storage/lib/utils.ts +++ b/apps/web/app/api/v1/management/storage/lib/utils.ts @@ -1,7 +1,6 @@ import { responses } from "@/app/lib/api/response"; import { TApiV1Authentication } from "@/app/lib/api/with-api-logging"; -import { hasUserWorkspaceAccessForAction } from "@/lib/workspace/auth"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; export const checkAuth = async (authentication: TApiV1Authentication | undefined, workspaceId: string) => { if (!authentication) { @@ -9,16 +8,20 @@ export const checkAuth = async (authentication: TApiV1Authentication | undefined } if ("user" in authentication) { - const isUserAuthorized = await hasUserWorkspaceAccessForAction( - authentication.user.id, - workspaceId, - "POST" - ); + const isUserAuthorized = await can({ type: "user", id: authentication.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); if (!isUserAuthorized) { return responses.unauthorizedResponse(); } } else if ("apiKeyId" in authentication) { - if (!hasPermission(authentication.workspacePermissions, workspaceId, "POST")) { + if ( + !(await can({ type: "apiKey", id: authentication.apiKeyId }, "workspace.write", { + type: "workspace", + id: workspaceId, + })) + ) { return responses.unauthorizedResponse(); } } else { diff --git a/apps/web/app/api/v1/management/storage/route.ts b/apps/web/app/api/v1/management/storage/route.ts index d78fed079938..76a2bce300f9 100644 --- a/apps/web/app/api/v1/management/storage/route.ts +++ b/apps/web/app/api/v1/management/storage/route.ts @@ -37,7 +37,7 @@ export const POST = withV1ApiWrapper({ // Accept workspaceId if (authentication && "apiKeyId" in authentication) { // API key auth: resolveBodyIds handles resolution + permission check - const resolved = await resolveBodyIds(storageInput, authentication.workspacePermissions, "POST"); + const resolved = await resolveBodyIds(storageInput, authentication, "POST"); if (!resolved.ok) return { response: resolved.response }; storageInput = resolved.body; } else if (!storageInput.workspaceId) { diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts index a43410ebc869..b751ca7f1f62 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts @@ -22,9 +22,10 @@ import { } from "@/app/lib/api/survey-transformation"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; type TSurveyUpdateBody = Record & { @@ -42,7 +43,13 @@ const fetchAndAuthorizeSurvey = async ( if (!survey) { return { error: responses.notFoundResponse("Survey", surveyId) }; } - if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, requiredPermission)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(requiredPermission), + { type: "workspace", id: survey.workspaceId } + )) + ) { return { error: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts index bc0500f98cd8..59354f222ff2 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts @@ -1,10 +1,11 @@ import { handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getPublicDomain } from "@/lib/getPublicUrl"; import { getSurvey } from "@/lib/survey/service"; import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const GET = withV1ApiWrapper({ handler: async ({ @@ -29,7 +30,13 @@ export const GET = withV1ApiWrapper({ // unauthenticated POST /api/v1/client/{workspaceId}/responses accepts, so at "read" a // reporting-only key — the level you would hand an external analyst or BI tool — could generate // thousands of valid submission links and inject responses with them. - if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, "POST")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: survey.workspaceId } + )) + ) { return { response: responses.unauthorizedResponse(), }; diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index b83ea2dda63a..769b4c4a39b5 100644 --- a/apps/web/app/api/v1/management/surveys/route.ts +++ b/apps/web/app/api/v1/management/surveys/route.ts @@ -21,9 +21,10 @@ import { } from "@/app/lib/api/survey-transformation"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getOrganizationByWorkspaceId } from "@/lib/organization/service"; import { createSurvey } from "@/lib/survey/service"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; import { getSurveys } from "./lib/surveys"; @@ -89,7 +90,7 @@ export const POST = withV1ApiWrapper({ surveyInput = normaliseProjectOverwritesToWorkspace(surveyInput); // Accept workspaceId as alternative to environmentId — resolve to production environment - const resolved = await resolveBodyIds(surveyInput, authentication.workspacePermissions, "POST"); + const resolved = await resolveBodyIds(surveyInput, authentication, "POST"); if (!resolved.ok) return { response: resolved.response }; surveyInput = resolved.body; @@ -109,7 +110,11 @@ export const POST = withV1ApiWrapper({ if ( !resolved.alreadyAuthorized && - !hasPermission(authentication.workspacePermissions, workspaceId, "POST") + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: workspaceId } + )) ) { return { response: responses.unauthorizedResponse() }; } diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts index 7325424c26ae..211096c100f9 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts @@ -6,7 +6,8 @@ import { } from "@/app/lib/api/legacy-environment-id"; import { responses } from "@/app/lib/api/response"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; export const GET = withV1ApiWrapper({ handler: async ({ props, authentication }: THandlerParams<{ params: Promise<{ webhookId: string }> }>) => { @@ -22,7 +23,13 @@ export const GET = withV1ApiWrapper({ response: responses.notFoundResponse("Webhook", params.webhookId), }; } - if (!hasPermission(authentication.workspacePermissions, webhook.workspaceId, "GET")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("GET"), + { type: "workspace", id: webhook.workspaceId } + )) + ) { return { response: responses.unauthorizedResponse(), }; @@ -56,7 +63,13 @@ export const DELETE = withV1ApiWrapper({ response: responses.notFoundResponse("Webhook", params.webhookId), }; } - if (!hasPermission(authentication.workspacePermissions, webhook.workspaceId, "DELETE")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("DELETE"), + { type: "workspace", id: webhook.workspaceId } + )) + ) { return { response: responses.unauthorizedResponse(), }; diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index a033d15f1443..8cf75e20d5fc 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -10,7 +10,8 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; export const GET = withV1ApiWrapper({ handler: async ({ authentication }: THandlerParams) => { @@ -54,7 +55,7 @@ export const POST = withV1ApiWrapper({ } // Accept workspaceId as alternative to environmentId - const resolved = await resolveBodyIds(webhookInput, authentication.workspacePermissions, "POST"); + const resolved = await resolveBodyIds(webhookInput, authentication, "POST"); if (!resolved.ok) return { response: resolved.response }; webhookInput = resolved.body; @@ -74,7 +75,11 @@ export const POST = withV1ApiWrapper({ if ( !resolved.alreadyAuthorized && - !hasPermission(authentication.workspacePermissions, workspaceId, "POST") + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: workspaceId } + )) ) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v3/feedbackRecords/lib/access.ts b/apps/web/app/api/v3/feedbackRecords/lib/access.ts index f180282960bb..cac21f99aeab 100644 --- a/apps/web/app/api/v3/feedbackRecords/lib/access.ts +++ b/apps/web/app/api/v3/feedbackRecords/lib/access.ts @@ -1,11 +1,12 @@ import "server-only"; import type { logger } from "@formbricks/logger"; import type { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { AuthorizationError } from "@formbricks/types/errors"; +import { getV3AuthorizationActor } from "@/app/api/v3/lib/auth"; import { requireUnifyFeedbackWorkspaceAccess } from "@/app/api/v3/lib/feedback-access"; import { problemBadRequest, problemForbidden, problemUnprocessableContent } from "@/app/api/v3/lib/response"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { can } from "@/lib/authorization"; +import { getFeedbackDirectoryAssignmentAuthorizationAction } from "@/lib/authorization/permission-action"; import { getFeedbackDirectoriesByWorkspaceId, getFeedbackDirectoryAuthContext, @@ -49,6 +50,22 @@ export type TResolvedFeedbackTenant = { type TResolveResult = ({ ok: true } & TResolvedFeedbackTenant) | { ok: false; response: Response }; +const canAccessFeedbackDirectoryAssignment = async ( + authentication: TV3Authentication, + feedbackDirectoryId: string, + workspaceId: string, + minPermission: TTeamPermission +): Promise => { + const actor = getV3AuthorizationActor(authentication); + if (!actor) return false; + + return can(actor, getFeedbackDirectoryAssignmentAuthorizationAction(minPermission), { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId, + workspaceId, + }); +}; + /** * Resolve (and authorize) the Hub tenant for a feedback-records request. This is the single tenant- * isolation choke point for every tool: workspace access → feedback-directories license gate → @@ -97,6 +114,23 @@ export async function resolveWorkspaceFeedbackTenant({ ), }; } + if ( + !(await canAccessFeedbackDirectoryAssignment( + authentication, + requested.id.trim(), + resolvedWorkspaceId, + minPermission + )) + ) { + return { + ok: false, + response: problemForbidden( + requestId, + "You are not authorized to access this feedback dataset", + instance + ), + }; + } return { ok: true, workspaceId: resolvedWorkspaceId, @@ -130,6 +164,24 @@ export async function resolveWorkspaceFeedbackTenant({ }; } + if ( + !(await canAccessFeedbackDirectoryAssignment( + authentication, + directories[0].id.trim(), + resolvedWorkspaceId, + minPermission + )) + ) { + return { + ok: false, + response: problemForbidden( + requestId, + "You are not authorized to access this feedback dataset", + instance + ), + }; + } + return { ok: true, workspaceId: resolvedWorkspaceId, @@ -250,21 +302,16 @@ async function requireOrganizationOwnerOrManager({ return { ok: false, response: forbidFeedbackRecordMutation(requestId, instance) }; } - try { - await checkAuthorizationUpdated({ - userId, - organizationId: resolution.organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], - }); - return { ok: true }; - } catch (error) { - if (error instanceof AuthorizationError) { - log.warn({ statusCode: 403 }, "Feedback record mutation denied: not an organization owner or manager"); - return { ok: false, response: forbidFeedbackRecordMutation(requestId, instance) }; - } - - throw error; + const allowed = await can({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: resolution.organizationId, + }); + if (!allowed) { + log.warn({ statusCode: 403 }, "Feedback record mutation denied: not an organization owner or manager"); + return { ok: false, response: forbidFeedbackRecordMutation(requestId, instance) }; } + + return { ok: true }; } /** The single 403 for "you may not change records here", so every refusal reads identically. */ diff --git a/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts b/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts index 4b62324d37c7..2640d662d908 100644 --- a/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts +++ b/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { getV3AuthorizationActor, requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; import type { V3WorkspaceContext } from "@/app/api/v3/lib/workspace-context"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { can } from "@/lib/authorization"; import { getFeedbackDirectoriesByWorkspaceId, getFeedbackDirectoryAuthContext, @@ -40,10 +39,11 @@ vi.mock("@formbricks/logger", () => ({ logger: { withContext: vi.fn(() => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn() })) }, })); -vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: vi.fn() })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: vi.fn(), +vi.mock("@/app/api/v3/lib/auth", () => ({ + getV3AuthorizationActor: vi.fn(), + requireV3WorkspaceAccess: vi.fn(), })); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsFeedbackDirectoriesEnabled: vi.fn() })); vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ getFeedbackDirectoriesByWorkspaceId: vi.fn(), @@ -99,6 +99,13 @@ const foreignOrgApiKeyAuth = { beforeEach(() => { vi.resetAllMocks(); vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(context); + vi.mocked(getV3AuthorizationActor).mockImplementation((authentication) => { + if (authentication && "apiKeyId" in authentication && authentication.apiKeyId) { + return { type: "apiKey", id: authentication.apiKeyId }; + } + return { type: "user", id: "user_1" }; + }); + vi.mocked(can).mockResolvedValue(true); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: directoryId, name: "Support" }]); // Unshared by default, so only the tests that opt into sharing exercise the ENG-2189 rule. @@ -107,7 +114,6 @@ beforeEach(() => { workspaceIds: [workspaceId], isArchived: false, }); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); }); describe("shared authorization + tenant resolution", () => { @@ -131,6 +137,29 @@ describe("shared authorization + tenant resolution", () => { expect(listFeedbackRecords).not.toHaveBeenCalled(); }); + test("denies when the exact dataset assignment is not authorized", async () => { + vi.mocked(can).mockResolvedValue(false); + + const response = await listV3FeedbackRecords({ ...base, authentication: sessionAuth }); + + expect(response.status).toBe(403); + expect(can).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "feedbackDirectoryAssignment.read", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId, + }); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + + test("keeps central evaluator failures operational instead of converting them to denial", async () => { + vi.mocked(can).mockRejectedValue(new Error("evaluator unavailable")); + + const response = await listV3FeedbackRecords({ ...base, authentication: sessionAuth }); + + expect(response.status).toBe(500); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + // An agent can only help the user if the dead-end says who does what, and where — so the detail has // to name the role and the settings location, not just the problem. test("returns an actionable 422 when no dataset is assigned to the workspace", async () => { @@ -670,16 +699,13 @@ describe("deleteV3FeedbackRecord", () => { vi.mocked(deleteFeedbackRecord).mockResolvedValue({ data: { deleted: true }, error: null }); }); - // ENG-2083: DELETE is reserved for `manage` everywhere else in the API and record deletion is - // unrecoverable, so this path asks for `manage` rather than `readWrite`. Both delete paths moved — - // see the gateway's route table for the other one. - test("requires manage access", async () => { + test("requires assignment read access before the organization mutation gate", async () => { await deleteV3FeedbackRecord(deleteBase); expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( sessionAuth, workspaceId, - "manage", + "read", requestId, instance ); @@ -1623,13 +1649,13 @@ describe("updateV3FeedbackRecord", () => { vi.mocked(updateFeedbackRecord).mockResolvedValue({ data: updated, error: null }); }); - test("requires readWrite access", async () => { + test("requires assignment read access before the organization mutation gate", async () => { await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }); expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( sessionAuth, workspaceId, - "readWrite", + "read", requestId, instance ); @@ -1893,15 +1919,19 @@ describe("feedback record mutation role (ENG-1770)", () => { test("asks for an organization owner or manager, with no workspace-team fallback", async () => { await updateV3FeedbackRecord(updateArgs); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: context.organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + expect(can).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "feedbackDirectoryAssignment.read", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId, + }); + expect(can).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: context.organizationId, }); }); test("refuses an update from a workspace member who is not an owner or manager", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("Not authorized")); + vi.mocked(can).mockImplementation(async (_actor, action) => action !== "organization.manage"); const response = await updateV3FeedbackRecord(updateArgs); @@ -1912,7 +1942,7 @@ describe("feedback record mutation role (ENG-1770)", () => { }); test("refuses a delete from a workspace member who is not an owner or manager", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("Not authorized")); + vi.mocked(can).mockImplementation(async (_actor, action) => action !== "organization.manage"); const response = await deleteV3FeedbackRecord(deleteArgs); @@ -1935,7 +1965,12 @@ describe("feedback record mutation role (ENG-1770)", () => { const deleted = await deleteV3FeedbackRecord({ ...deleteArgs, authentication: apiKeyAuth }); expect(deleted.status).toBe(204); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(can).toHaveBeenCalledWith({ type: "apiKey", id: "key_1" }, "feedbackDirectoryAssignment.manage", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId, + }); + expect(can).not.toHaveBeenCalledWith(expect.anything(), "organization.manage", expect.anything()); }); // The positive control for update: without it, a regression refusing every API-key update would still @@ -1944,7 +1979,12 @@ describe("feedback record mutation role (ENG-1770)", () => { const updated = await updateV3FeedbackRecord({ ...updateArgs, authentication: apiKeyAuth }); expect(updated.status).toBe(200); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(can).toHaveBeenCalledWith({ type: "apiKey", id: "key_1" }, "feedbackDirectoryAssignment.write", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId, + }); + expect(can).not.toHaveBeenCalledWith(expect.anything(), "organization.manage", expect.anything()); }); describe("API key in a shared dataset (ENG-2189)", () => { diff --git a/apps/web/app/api/v3/feedbackRecords/lib/operations.ts b/apps/web/app/api/v3/feedbackRecords/lib/operations.ts index e5af5010fce9..f55182d72495 100644 --- a/apps/web/app/api/v3/feedbackRecords/lib/operations.ts +++ b/apps/web/app/api/v3/feedbackRecords/lib/operations.ts @@ -12,6 +12,7 @@ import { } from "@/app/api/v3/lib/response"; import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; import { countFeedbackRecords, createFeedbackRecord, @@ -78,6 +79,11 @@ import { const CACHE = "private, no-store" as const; +const getMutationAssignmentPermission = ( + authentication: TV3Authentication, + apiKeyPermission: Extract +): TTeamPermission => (authentication && "apiKeyId" in authentication ? apiKeyPermission : "read"); + /** * Build the Hub create payload. This field list *is* the allowlist — never a spread of the input — so * nothing the caller invents (a `tenant_id` above all) can reach the Hub. Optional fields are assigned @@ -790,7 +796,7 @@ export async function updateV3FeedbackRecord({ authentication, workspaceId, datasetId, - minPermission: "readWrite", + minPermission: getMutationAssignmentPermission(authentication, "readWrite"), requestId, instance, }); @@ -917,7 +923,7 @@ export async function deleteV3FeedbackRecord({ datasetId, // `manage`, matching the gateway's DELETE route and `methodPermissionMap` everywhere else in the // API. Both delete paths had to move or the bar would only apply to one of them (ENG-2083). - minPermission: "manage", + minPermission: getMutationAssignmentPermission(authentication, "manage"), requestId, instance, }); diff --git a/apps/web/app/api/v3/lib/api-wrapper.ts b/apps/web/app/api/v3/lib/api-wrapper.ts index a7f3f622bbbf..7f50a5d97fea 100644 --- a/apps/web/app/api/v3/lib/api-wrapper.ts +++ b/apps/web/app/api/v3/lib/api-wrapper.ts @@ -4,6 +4,7 @@ import { logger } from "@formbricks/logger"; import { TooManyRequestsError } from "@formbricks/types/errors"; import { authenticateRequest } from "@/app/api/v1/auth"; import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; import { getSession } from "@/modules/auth/lib/session"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; @@ -391,15 +392,19 @@ export const withV3ApiWrapper = + handler({ + req, + props, + authentication: authResult.authentication, + auditLog, + parsedInput: parsedInputResult.parsedInput, + requestId, + instance, + }); + const response = authResult.authentication + ? await withAuthorizationSurface("api_v3", execute) + : await execute(); if (auditLog) { if (response.ok) { diff --git a/apps/web/app/api/v3/lib/auth.test.ts b/apps/web/app/api/v3/lib/auth.test.ts index b9544ccc2715..d6d6018f674a 100644 --- a/apps/web/app/api/v3/lib/auth.test.ts +++ b/apps/web/app/api/v3/lib/auth.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { ApiKeyPermission } from "@formbricks/database/prisma"; import { AuthorizationError } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { assertCan, can } from "@/lib/authorization"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getWorkspace } from "@/lib/workspace/service"; -import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth"; +import { getV3AuthorizationActor, requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth"; +import type { TV3Authentication } from "./types"; vi.mock("@formbricks/logger", () => ({ logger: { @@ -23,12 +24,29 @@ vi.mock("@/lib/workspace/service", () => ({ getWorkspace: vi.fn(), })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: vi.fn(), -})); +vi.mock("@/lib/authorization", () => ({ assertCan: vi.fn(), can: vi.fn() })); const requestId = "req-123"; +describe("getV3AuthorizationActor", () => { + test("maps session and API-key authentication to Formbricks actors", () => { + expect(getV3AuthorizationActor({ user: { id: "user_1" } } as unknown as TV3Authentication)).toEqual({ + type: "user", + id: "user_1", + }); + expect(getV3AuthorizationActor({ apiKeyId: "key_1" } as unknown as TV3Authentication)).toEqual({ + type: "apiKey", + id: "key_1", + }); + }); + + test("rejects missing or incomplete authentication", () => { + expect(getV3AuthorizationActor(null)).toBeNull(); + expect(getV3AuthorizationActor({ user: {} } as unknown as TV3Authentication)).toBeNull(); + expect(getV3AuthorizationActor({ apiKeyId: "" } as unknown as TV3Authentication)).toBeNull(); + }); +}); + describe("requireSessionWorkspaceAccess", () => { test("returns 401 when authentication is null", async () => { const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId); @@ -40,7 +58,7 @@ describe("requireSessionWorkspaceAccess", () => { expect(body.status).toBe(401); expect(body.code).toBe("not_authenticated"); expect(getWorkspace).not.toHaveBeenCalled(); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(assertCan).not.toHaveBeenCalled(); }); test("returns 401 when authentication is API key (no user)", async () => { @@ -73,13 +91,13 @@ describe("requireSessionWorkspaceAccess", () => { expect(body.requestId).toBe(requestId); expect(body.code).toBe("forbidden"); expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent"); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(assertCan).not.toHaveBeenCalled(); }); test("returns 403 when user has no access to workspace", async () => { vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1"); - vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized")); + vi.mocked(assertCan).mockRejectedValueOnce(new AuthorizationError("Not authorized")); const result = await requireSessionWorkspaceAccess( { user: { id: "user_1" }, expires: "" } as any, "proj_abc", @@ -91,20 +109,16 @@ describe("requireSessionWorkspaceAccess", () => { const body = await (result as Response).json(); expect(body.requestId).toBe(requestId); expect(body.code).toBe("forbidden"); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: "org_1", - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", workspaceId: "proj_abc", minPermission: "read" }, - ], + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "workspace.read", { + type: "workspace", + id: "proj_abc", }); }); test("returns workspace context when session is valid and user has access", async () => { vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1"); - vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any); + vi.mocked(assertCan).mockResolvedValueOnce(undefined); const result = await requireSessionWorkspaceAccess( { user: { id: "user_1" }, expires: "" } as any, "proj_abc", @@ -116,13 +130,9 @@ describe("requireSessionWorkspaceAccess", () => { workspaceId: "proj_abc", organizationId: "org_1", }); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: "org_1", - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", workspaceId: "proj_abc", minPermission: "readWrite" }, - ], + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "workspace.write", { + type: "workspace", + id: "proj_abc", }); }); }); @@ -144,6 +154,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis describe("requireV3WorkspaceAccess", () => { beforeEach(() => { + vi.mocked(can).mockResolvedValue(true); vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k"); }); @@ -156,7 +167,7 @@ describe("requireV3WorkspaceAccess", () => { test("delegates to session flow when user is present", async () => { vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s"); - vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any); + vi.mocked(assertCan).mockResolvedValueOnce(undefined); const r = await requireV3WorkspaceAccess( { user: { id: "user_1" }, expires: "" } as any, "proj_s", @@ -195,6 +206,7 @@ describe("requireV3WorkspaceAccess", () => { }); test("returns 403 when API key permission is lower than the required permission", async () => { + vi.mocked(can).mockResolvedValue(false); const auth = { ...keyBase, workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.read)], @@ -204,6 +216,7 @@ describe("requireV3WorkspaceAccess", () => { }); test("403 when API key has no matching workspace", async () => { + vi.mocked(can).mockResolvedValue(false); const auth = { ...keyBase, workspacePermissions: [wsPerm("other_workspace")], @@ -213,6 +226,7 @@ describe("requireV3WorkspaceAccess", () => { }); test("403 when API key permission is not list-eligible (runtime value)", async () => { + vi.mocked(can).mockResolvedValue(false); const auth = { ...keyBase, workspacePermissions: [ diff --git a/apps/web/app/api/v3/lib/auth.ts b/apps/web/app/api/v3/lib/auth.ts index dca21a7ecb10..c3f5d3ae439d 100644 --- a/apps/web/app/api/v3/lib/auth.ts +++ b/apps/web/app/api/v3/lib/auth.ts @@ -1,31 +1,27 @@ /** * V3 API auth — session (browser) or API key with workspace-scoped access. */ -import { ApiKeyPermission } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import type { TAuthenticationApiKey } from "@formbricks/types/auth"; import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { type TAuthorizationActor, assertCan, can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationAction } from "@/lib/authorization/permission-action"; import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; import { problemForbidden, problemUnauthorized } from "./response"; import type { TV3Authentication } from "./types"; import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context"; -function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean { - const grantedRank = { - [ApiKeyPermission.read]: 1, - [ApiKeyPermission.write]: 2, - [ApiKeyPermission.manage]: 3, - }[permission]; +export const getV3AuthorizationActor = (authentication: TV3Authentication): TAuthorizationActor | null => { + if (authentication && "user" in authentication && authentication.user?.id) { + return { type: "user", id: authentication.user.id }; + } - const requiredRank = { - read: 1, - readWrite: 2, - manage: 3, - }[minPermission]; + if (authentication && "apiKeyId" in authentication && authentication.apiKeyId) { + return { type: "apiKey", id: authentication.apiKeyId }; + } - return grantedRank >= requiredRank; -} + return null; +}; /** * Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer. @@ -55,14 +51,9 @@ export async function requireSessionWorkspaceAccess( // Resolve workspaceId → workspaceId, organizationId (single place to change when Workspace exists). const context = await resolveV3WorkspaceContext(workspaceId); - // Org + workspace-team access; we use internal IDs from context. - await checkAuthorizationUpdated({ - userId, - organizationId: context.organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", workspaceId: context.workspaceId, minPermission }, - ], + await assertCan({ type: "user", id: userId }, getWorkspaceAuthorizationAction(minPermission), { + type: "workspace", + id: context.workspaceId, }); return context; @@ -98,11 +89,13 @@ export async function requireV3WorkspaceAccess( try { const context = await resolveV3WorkspaceContext(workspaceId); - const permission = keyAuth.workspacePermissions.find( - (workspacePermission) => workspacePermission.workspaceId === context.workspaceId + const allowed = await can( + { type: "apiKey", id: keyAuth.apiKeyId }, + getWorkspaceAuthorizationAction(minPermission), + { type: "workspace", id: context.workspaceId } ); - if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) { + if (!allowed) { log.warn({ statusCode: 403 }, "API key not allowed for workspace"); return problemForbidden(requestId, "You are not authorized to access this resource", instance); } diff --git a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.test.ts b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.test.ts index 4be6b3320448..c82e5bb733fe 100644 --- a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.test.ts +++ b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.test.ts @@ -1,26 +1,19 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { getV3AuthorizationActor, requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; import type { V3WorkspaceContext } from "@/app/api/v3/lib/workspace-context"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { can } from "@/lib/authorization"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { getSessionUserId, requireUnifyDirectoryAccess, requireUnifyDirectoryMutationAccess } from "./access"; vi.mock("server-only", () => ({})); vi.mock("@/app/api/v3/lib/auth", () => ({ + getV3AuthorizationActor: vi.fn(), requireV3WorkspaceAccess: vi.fn(), })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: vi.fn(), -})); - -vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ - getFeedbackDirectoriesByWorkspaceId: vi.fn(), -})); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsFeedbackDirectoriesEnabled: vi.fn(), @@ -35,13 +28,30 @@ describe("requireUnifyDirectoryAccess", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(context); + vi.mocked(getV3AuthorizationActor).mockImplementation((authentication) => + authentication && "user" in authentication && authentication.user?.id + ? { type: "user", id: authentication.user.id } + : null + ); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: directoryId, name: "Dataset" }]); + vi.mocked(can).mockResolvedValue(true); }); test("returns the workspace context when all checks pass", async () => { - const result = await requireUnifyDirectoryAccess(null, workspaceId, directoryId, "read", "req_1", "/x"); + const result = await requireUnifyDirectoryAccess( + session, + workspaceId, + directoryId, + "read", + "req_1", + "/x" + ); expect(result).toEqual(context); + expect(can).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "feedbackDirectoryAssignment.read", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId, + }); }); test("short-circuits with the auth Response and skips the extra checks when workspace access is denied", async () => { @@ -52,7 +62,7 @@ describe("requireUnifyDirectoryAccess", () => { expect(result).toBe(denied); expect(getIsFeedbackDirectoriesEnabled).not.toHaveBeenCalled(); - expect(getFeedbackDirectoriesByWorkspaceId).not.toHaveBeenCalled(); + expect(can).not.toHaveBeenCalled(); }); test("returns 403 when the feedbackDirectories entitlement is off", async () => { @@ -62,21 +72,33 @@ describe("requireUnifyDirectoryAccess", () => { expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(403); - expect(getFeedbackDirectoriesByWorkspaceId).not.toHaveBeenCalled(); + expect(can).not.toHaveBeenCalled(); }); test("returns 403 when the directory is not assigned to the workspace", async () => { - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: "other", name: "Other" }]); + vi.mocked(can).mockResolvedValue(false); - const result = await requireUnifyDirectoryAccess(null, workspaceId, directoryId, "read", "req_1", "/x"); + const result = await requireUnifyDirectoryAccess( + session, + workspaceId, + directoryId, + "read", + "req_1", + "/x" + ); expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(403); }); test("forwards the requested permission to the workspace-access check", async () => { - await requireUnifyDirectoryAccess(null, workspaceId, directoryId, "readWrite", "req_1", "/x"); - expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(null, workspaceId, "readWrite", "req_1", "/x"); + await requireUnifyDirectoryAccess(session, workspaceId, directoryId, "readWrite", "req_1", "/x"); + expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(session, workspaceId, "readWrite", "req_1", "/x"); + expect(can).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "feedbackDirectoryAssignment.write", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId, + }); }); }); @@ -86,9 +108,13 @@ describe("requireUnifyDirectoryMutationAccess", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(context); + vi.mocked(getV3AuthorizationActor).mockImplementation((authentication) => + authentication && "user" in authentication && authentication.user?.id + ? { type: "user", id: authentication.user.id } + : null + ); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: directoryId, name: "Dataset" }]); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); + vi.mocked(can).mockResolvedValue(true); }); test("returns the workspace context for an organization owner or manager", async () => { @@ -101,10 +127,9 @@ describe("requireUnifyDirectoryMutationAccess", () => { ); expect(result).toEqual(context); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: context.organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + expect(can).toHaveBeenLastCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: context.organizationId, }); }); @@ -114,7 +139,7 @@ describe("requireUnifyDirectoryMutationAccess", () => { }); test("returns 403 for a workspace readWrite member who is not an owner or manager", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("Not authorized")); + vi.mocked(can).mockResolvedValueOnce(true).mockResolvedValueOnce(false); const result = await requireUnifyDirectoryMutationAccess( session, @@ -133,11 +158,11 @@ describe("requireUnifyDirectoryMutationAccess", () => { expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(401); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(can).not.toHaveBeenCalled(); }); test("short-circuits with the directory Response and skips the role check", async () => { - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: "other", name: "Other" }]); + vi.mocked(can).mockResolvedValue(false); const result = await requireUnifyDirectoryMutationAccess( session, @@ -149,7 +174,7 @@ describe("requireUnifyDirectoryMutationAccess", () => { expect(result).toBeInstanceOf(Response); expect((result as Response).status).toBe(403); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(can).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts index d8fd6edb7a6f..b81df4989b04 100644 --- a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts +++ b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts @@ -1,11 +1,11 @@ import "server-only"; -import { AuthorizationError } from "@formbricks/types/errors"; +import { getV3AuthorizationActor } from "@/app/api/v3/lib/auth"; import { requireUnifyFeedbackWorkspaceAccess } from "@/app/api/v3/lib/feedback-access"; import { problemForbidden, problemUnauthorized } from "@/app/api/v3/lib/response"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; import type { V3WorkspaceContext } from "@/app/api/v3/lib/workspace-context"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { can } from "@/lib/authorization"; +import { getFeedbackDirectoryAssignmentAuthorizationAction } from "@/lib/authorization/permission-action"; import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; /** @@ -37,8 +37,17 @@ export async function requireUnifyDirectoryAccess( return context; } - const directories = await getFeedbackDirectoriesByWorkspaceId(context.workspaceId); - if (!directories.some((directory) => directory.id === directoryId)) { + const actor = getV3AuthorizationActor(authentication); + if (!actor) { + return problemUnauthorized(requestId, "Not authenticated", instance); + } + + const allowed = await can(actor, getFeedbackDirectoryAssignmentAuthorizationAction(minPermission), { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: directoryId, + workspaceId: context.workspaceId, + }); + if (!allowed) { return problemForbidden(requestId, "You are not authorized to access this resource", instance); } @@ -81,21 +90,16 @@ export async function requireUnifyDirectoryMutationAccess( return problemUnauthorized(requestId, "Session required", instance); } - try { - await checkAuthorizationUpdated({ - userId, - organizationId: context.organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], - }); - } catch (err) { - if (err instanceof AuthorizationError) { - return problemForbidden( - requestId, - "Only organization owners and managers can change a feedback directory's taxonomy", - instance - ); - } - throw err; + const allowed = await can({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: context.organizationId, + }); + if (!allowed) { + return problemForbidden( + requestId, + "Only organization owners and managers can change a feedback directory's taxonomy", + instance + ); } return context; diff --git a/apps/web/app/api/v3/workspaces/lib/operations.test.ts b/apps/web/app/api/v3/workspaces/lib/operations.test.ts index c5e67b682c98..b7c9fd529286 100644 --- a/apps/web/app/api/v3/workspaces/lib/operations.test.ts +++ b/apps/web/app/api/v3/workspaces/lib/operations.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; -import { getOrganizationsByUserId } from "@/lib/organization/service"; -import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; +import { getOrganizationScopedWorkspacesByIdsForUser, getWorkspacesByIds } from "@/lib/workspace/service"; import { listV3Workspaces } from "./operations"; -vi.mock("@/lib/organization/service", () => ({ getOrganizationsByUserId: vi.fn() })); -vi.mock("@/lib/workspace/service", () => ({ getUserWorkspaces: vi.fn(), getWorkspace: vi.fn() })); +const loggerMocks = vi.hoisted(() => ({ error: vi.fn() })); + +vi.mock("@/lib/workspace/service", () => ({ + getWorkspacesByIds: vi.fn(), + getOrganizationScopedWorkspacesByIdsForUser: vi.fn(), +})); +vi.mock("@/lib/authorization/resource-list", () => ({ lookupAuthorizedWorkspaceIds: vi.fn() })); vi.mock("@formbricks/logger", () => ({ - logger: { withContext: vi.fn(() => ({ error: vi.fn(), warn: vi.fn() })) }, + logger: { withContext: vi.fn(() => loggerMocks) }, })); const sessionAuth = { @@ -34,18 +39,19 @@ const ws = (id: string, name: string, organizationId: string) => }) as any; describe("listV3Workspaces", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([]); + }); test("session user: aggregates + dedupes across orgs and returns the minimal DTO only", async () => { - vi.mocked(getOrganizationsByUserId).mockResolvedValue([ - { id: "org_1", name: "Org 1" }, - { id: "org_2", name: "Org 2" }, - ] as any); - vi.mocked(getUserWorkspaces).mockImplementation(async (_userId: string, orgId: string) => - orgId === "org_1" - ? [ws("w1", "Alpha", "org_1"), ws("w2", "Beta", "org_1")] - : [ws("w2", "Beta", "org_1"), ws("w3", "Gamma", "org_2")] - ); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(["w1", "w2", "w3"]); + vi.mocked(getOrganizationScopedWorkspacesByIdsForUser).mockResolvedValue([ + ws("w1", "Alpha", "org_1"), + ws("w2", "Beta", "org_1"), + ws("w2", "Beta", "org_1"), + ws("w3", "Gamma", "org_2"), + ]); const res = await listV3Workspaces(params(sessionAuth)); expect(res.status).toBe(200); @@ -59,11 +65,17 @@ describe("listV3Workspaces", () => { expect(body.meta.totalCount).toBe(3); // Only the DTO fields — no config/styling/entity internals leak. expect(Object.keys(body.data[0])).toEqual(["id", "name", "organizationId"]); + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith({ id: "user_1", type: "user" }); + expect(getOrganizationScopedWorkspacesByIdsForUser).toHaveBeenCalledExactlyOnceWith("user_1", [ + "w1", + "w2", + "w3", + ]); }); test("returns workspaces in a deterministic order (name, then id)", async () => { - vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org_1", name: "Org 1" }] as any); - vi.mocked(getUserWorkspaces).mockResolvedValue([ + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(["w3", "w1", "w2"]); + vi.mocked(getOrganizationScopedWorkspacesByIdsForUser).mockResolvedValue([ ws("w3", "Zeta", "org_1"), ws("w1", "alpha", "org_1"), ws("w2", "Beta", "org_1"), @@ -75,47 +87,52 @@ describe("listV3Workspaces", () => { expect(body.data.map((w: { name: string }) => w.name)).toEqual(["alpha", "Beta", "Zeta"]); }); - test("session user with no orgs → empty list, no workspace lookups", async () => { - vi.mocked(getOrganizationsByUserId).mockResolvedValue([] as any); + test("session user with no authorized workspaces → empty list", async () => { + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([]); + vi.mocked(getOrganizationScopedWorkspacesByIdsForUser).mockResolvedValue([]); const res = await listV3Workspaces(params(sessionAuth)); const body = await res.json(); expect(res.status).toBe(200); expect(body.data).toEqual([]); - expect(getUserWorkspaces).not.toHaveBeenCalled(); + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith({ id: "user_1", type: "user" }); + expect(getOrganizationScopedWorkspacesByIdsForUser).toHaveBeenCalledExactlyOnceWith("user_1", []); }); - test("api key: returns only the workspaces in workspacePermissions", async () => { + test("api key: returns only same-organization workspaces in workspacePermissions", async () => { const keyAuth = { apiKeyId: "key_1", + organizationId: "org_1", workspacePermissions: [ { workspaceId: "w1", permission: "read" }, { workspaceId: "w9", permission: "write" }, ], } as unknown as TV3Authentication; - vi.mocked(getWorkspace).mockImplementation(async (id: string) => - id === "w1" ? ws("w1", "Alpha", "org_1") : id === "w9" ? ws("w9", "Zeta", "org_2") : null - ); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(["w1", "w9"]); + vi.mocked(getWorkspacesByIds).mockResolvedValue([ws("w1", "Alpha", "org_1")]); const res = await listV3Workspaces(params(keyAuth)); const body = await res.json(); - expect(body.data).toEqual([ - { id: "w1", name: "Alpha", organizationId: "org_1" }, - { id: "w9", name: "Zeta", organizationId: "org_2" }, - ]); - // API-key path must never fall back to the user/org aggregation. - expect(getOrganizationsByUserId).not.toHaveBeenCalled(); + expect(body.data).toEqual([{ id: "w1", name: "Alpha", organizationId: "org_1" }]); + expect(getWorkspacesByIds).toHaveBeenCalledExactlyOnceWith("org_1", ["w1", "w9"]); + // API-key path must never use the user membership resolver. + expect(getOrganizationScopedWorkspacesByIdsForUser).not.toHaveBeenCalled(); + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith({ + id: "key_1", + type: "apiKey", + }); }); test("no authentication → 401", async () => { const res = await listV3Workspaces(params(null)); expect(res.status).toBe(401); + expect(lookupAuthorizedWorkspaceIds).not.toHaveBeenCalled(); }); test("an unexpected service failure is logged and returned as a 500 (never thrown)", async () => { - vi.mocked(getOrganizationsByUserId).mockRejectedValue(new Error("db exploded")); + vi.mocked(lookupAuthorizedWorkspaceIds).mockRejectedValue(new Error("AuthZed exploded")); const res = await listV3Workspaces(params(sessionAuth)); expect(res.status).toBe(500); }); diff --git a/apps/web/app/api/v3/workspaces/lib/operations.ts b/apps/web/app/api/v3/workspaces/lib/operations.ts index 1cb80d6e02a7..2984e9745bcd 100644 --- a/apps/web/app/api/v3/workspaces/lib/operations.ts +++ b/apps/web/app/api/v3/workspaces/lib/operations.ts @@ -1,10 +1,13 @@ import "server-only"; import { logger } from "@formbricks/logger"; import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { getV3AuthorizationActor } from "@/app/api/v3/lib/auth"; import { problemInternalError, problemUnauthorized, successListResponse } from "@/app/api/v3/lib/response"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; -import { getOrganizationsByUserId } from "@/lib/organization/service"; -import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service"; +import type { TAuthorizationActor } from "@/lib/authorization"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; +import { AuthzedError } from "@/lib/authzed/errors"; +import { getOrganizationScopedWorkspacesByIdsForUser, getWorkspacesByIds } from "@/lib/workspace/service"; type TListV3WorkspacesParams = { authentication: TV3Authentication; @@ -19,6 +22,10 @@ type TV3WorkspaceListItem = { organizationId: string; }; +type TResolvedWorkspaceList = Readonly<{ + items: ReadonlyArray; +}>; + const serializeV3WorkspaceListItem = (workspace: { id: string; name: string; @@ -29,27 +36,31 @@ const serializeV3WorkspaceListItem = (workspace: { organizationId: workspace.organizationId, }); -/** - * Session user's accessible workspaces: every workspace across the orgs they're a member of. Scoping is - * enforced by the reused services — `getOrganizationsByUserId` limits to the user's own orgs, and - * `getUserWorkspaces` returns all of an org's workspaces for owners/managers but only team-scoped ones - * for `member`-role users. - */ -async function fetchSessionWorkspaces(userId: string): Promise { - const organizations = await getOrganizationsByUserId(userId); - const workspacesPerOrg = await Promise.all( - organizations.map((organization) => getUserWorkspaces(userId, organization.id)) - ); - return workspacesPerOrg.flat().map(serializeV3WorkspaceListItem); +/** Session user's accessible workspaces from one authoritative `LookupResources(workspace, read)`. */ +async function fetchSessionWorkspaces( + userId: string, + actor: Extract +): Promise { + const workspaceIds = await lookupAuthorizedWorkspaceIds(actor); + const workspaces = await getOrganizationScopedWorkspacesByIdsForUser(userId, [...workspaceIds]); + return { + items: workspaces.map(serializeV3WorkspaceListItem), + }; } -/** API key's accessible workspaces: exactly the ones named in its `workspacePermissions`, nothing else. */ -async function fetchApiKeyWorkspaces(keyAuth: TAuthenticationApiKey): Promise { - const workspaceIds = Array.from(new Set(keyAuth.workspacePermissions.map((p) => p.workspaceId))); - const workspaces = await Promise.all(workspaceIds.map((id) => getWorkspace(id))); - return workspaces - .filter((workspace): workspace is NonNullable => workspace !== null) - .map(serializeV3WorkspaceListItem); +/** API key's accessible workspaces: the authoritative SpiceDB `workspace.read` result in its organization. */ +async function fetchApiKeyWorkspaces( + keyAuth: TAuthenticationApiKey, + actor: Extract +): Promise { + const workspaceIds = await lookupAuthorizedWorkspaceIds(actor); + const workspaces = await getWorkspacesByIds(keyAuth.organizationId, [...workspaceIds]); + const sameOrganizationWorkspaces = workspaces.filter( + (workspace) => workspace.organizationId === keyAuth.organizationId + ); + return { + items: sameOrganizationWorkspaces.map(serializeV3WorkspaceListItem), + }; } /** @@ -72,23 +83,30 @@ export async function listV3Workspaces({ const log = logger.withContext({ requestId }); try { - let items: TV3WorkspaceListItem[]; + const actor = getV3AuthorizationActor(authentication); + if (!actor) { + return problemUnauthorized(requestId, "Not authenticated", instance); + } + + let resolved: TResolvedWorkspaceList; if ("user" in authentication && authentication.user?.id) { - items = await fetchSessionWorkspaces(authentication.user.id); + if (actor.type !== "user") return problemUnauthorized(requestId, "Not authenticated", instance); + resolved = await fetchSessionWorkspaces(authentication.user.id, actor); } else if ( "apiKeyId" in authentication && authentication.apiKeyId && Array.isArray(authentication.workspacePermissions) ) { - items = await fetchApiKeyWorkspaces(authentication); + if (actor.type !== "apiKey") return problemUnauthorized(requestId, "Not authenticated", instance); + resolved = await fetchApiKeyWorkspaces(authentication, actor); } else { return problemUnauthorized(requestId, "Not authenticated", instance); } // Dedupe by id (defensive) + a stable, deterministic order — the underlying queries have no ORDER BY, // so without this the output would vary between calls. - const deduped = Array.from(new Map(items.map((item) => [item.id, item])).values()).sort( + const deduped = Array.from(new Map(resolved.items.map((item) => [item.id, item])).values()).sort( (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id) ); @@ -101,9 +119,14 @@ export async function listV3Workspaces({ { requestId, cache: "private, no-store" } ); } catch (error) { - // Log every failure with request context (not just DatabaseError) so nothing significant is lost, - // and always return a clean 500 instead of throwing a raw error past this boundary. - log.error({ error, statusCode: 500 }, "Failed to list workspaces"); + // Keep this boundary observable without serializing raw SDK/database errors or tenant identifiers. + log.error( + { + errorCode: error instanceof AuthzedError ? error.code : "internal", + statusCode: 500, + }, + "Failed to list workspaces" + ); return problemInternalError(requestId, "An unexpected error occurred.", instance); } } diff --git a/apps/web/app/lib/api/with-api-logging.ts b/apps/web/app/lib/api/with-api-logging.ts index 5d1cf6350b88..5eb6e4147ed5 100644 --- a/apps/web/app/lib/api/with-api-logging.ts +++ b/apps/web/app/lib/api/with-api-logging.ts @@ -11,6 +11,7 @@ import { isIntegrationRoute, isManagementApiRoute, } from "@/app/middleware/endpoint-validator"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { AUDIT_LOG_ENABLED } from "@/lib/constants"; import { getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth"; import { getSession } from "@/modules/auth/lib/session"; @@ -350,7 +351,10 @@ export const withV1ApiWrapper = executeHandler(handler, req, props, auditLog, authentication); + const { result, error } = authentication + ? await withAuthorizationSurface("api_v1", execute) + : await execute(); const res = result.response; const reportedError = result.error ?? error; diff --git a/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth.ts b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth.ts index 2d1310d12c05..11dc21eef6af 100644 --- a/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth.ts +++ b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth.ts @@ -1,9 +1,9 @@ import { NextRequest } from "next/server"; import { Result, err, ok } from "@formbricks/types/error-handlers"; import { authenticateRequest } from "@/app/api/v1/auth"; -import { hasUserWorkspaceAccessForAction } from "@/lib/workspace/auth"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getSession } from "@/modules/auth/lib/session"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const authorizePrivateDownload = async ( request: NextRequest, @@ -20,7 +20,11 @@ export const authorizePrivateDownload = async ( const session = await getSession(); if (session?.user) { - const isUserAuthorized = await hasUserWorkspaceAccessForAction(session.user.id, workspaceId, action); + const isUserAuthorized = await can( + { type: "user", id: session.user.id }, + getWorkspaceAuthorizationActionForMethod(action), + { type: "workspace", id: workspaceId } + ); if (!isUserAuthorized) { return err({ unauthorized: true, @@ -40,7 +44,12 @@ export const authorizePrivateDownload = async ( }); } - if (!hasPermission(auth.workspacePermissions, workspaceId, action)) { + if ( + !(await can({ type: "apiKey", id: auth.apiKeyId }, getWorkspaceAuthorizationActionForMethod(action), { + type: "workspace", + id: workspaceId, + })) + ) { return err({ unauthorized: true, }); diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 171473c5c9b4..a5bbf93053fe 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1585,6 +1585,9 @@ checksums: workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3 + workspace/analysis/charts/area_display: eb53ad78765ddc4050d898a70f2fac68 + workspace/analysis/charts/area_display_filled: 8a5591fe0fffc8cce37cb422c0a8cf78 + workspace/analysis/charts/area_display_line: b2083b10c8f7d21284e2892b97a5e8f1 workspace/analysis/charts/bar_direction: d78ce3e340da83ec3904a31a4e01b5cb workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813 workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00 @@ -1600,10 +1603,9 @@ checksums: workspace/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413 workspace/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb workspace/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61 - workspace/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93 + workspace/analysis/charts/chart_type_area: bd189e464fe26a34d8866bc35df792be workspace/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5 workspace/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a - workspace/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4 workspace/analysis/charts/chart_type_not_supported: c25334de42fd6192ff8355158865a3e8 workspace/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c workspace/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357 @@ -1657,16 +1659,19 @@ checksums: workspace/analysis/charts/failed_to_load_dashboards: 876c54d9cc69ceda6f808231e2557eb2 workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5 workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9 + workspace/analysis/charts/field_description_count: 2b6f5cbf709a37078e593b4d872b62e5 + workspace/analysis/charts/field_description_unique_respondents: 05c3a11103d1732b51173df13d646131 + workspace/analysis/charts/field_description_unique_responses: 15960fdaa5d1051115ad21c0773e29a4 workspace/analysis/charts/field_description_value_option: 2ecc1ce47d49fde628db9dc222c3d326 workspace/analysis/charts/field_description_value_text: 25fe1780ca3a8a8edbe8b3e6a08f933a workspace/analysis/charts/field_label_anger_count: f9ecf779605526904e029876a47f119d workspace/analysis/charts/field_label_ces_average: 98e1c184a86562976de0e1b2f68eafcd - workspace/analysis/charts/field_label_ces_count: 582452b80853e0fb626ff6cbde01ae5e + workspace/analysis/charts/field_label_ces_count: ddaa91375dce90d07a40b1927b9a5a5c workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa - workspace/analysis/charts/field_label_count: 14bb6c69f906d7bbd1359f7ef1bb3c28 + workspace/analysis/charts/field_label_count: e24cf48bb6985910f4ffe5e00512d388 workspace/analysis/charts/field_label_created_at: 9ce495d7fc74e1a2ae86c07206a3e531 workspace/analysis/charts/field_label_csat_average: 342a07ae9ff87a75b60d8cdc50c9225a - workspace/analysis/charts/field_label_csat_count: 313cea12f31a432b3fac0fc1a55057c7 + workspace/analysis/charts/field_label_csat_count: c7201bda7ab4cb4a1cccfceedadc9221 workspace/analysis/charts/field_label_csat_dissatisfied_count: cffec80cb2546865fbc28fe6782464a5 workspace/analysis/charts/field_label_csat_neutral_count: 0b7da7e3cbb47d9801abb32389704c8c workspace/analysis/charts/field_label_csat_satisfied_count: b5cdd879d5bf7e2edceb724045bd9e64 @@ -1689,7 +1694,7 @@ checksums: workspace/analysis/charts/field_label_question: 0576462ce60d4263d7c482463fcc9547 workspace/analysis/charts/field_label_question_group: b007e2cfd1262272de3260f8d14d5833 workspace/analysis/charts/field_label_rating_average: 04c27b457a97c3d46a6d983d7e32d18f - workspace/analysis/charts/field_label_rating_count: 69cda69fed73f6b7344aa92b17538660 + workspace/analysis/charts/field_label_rating_count: b3e1b302d3dd4f7ac5fc5af17d9cd37a workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0 workspace/analysis/charts/field_label_sadness_count: 403dd156f90a59d1da049eda7fcd3a76 workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6 @@ -1786,7 +1791,9 @@ checksums: workspace/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84 workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d + workspace/analysis/charts/time_dimension_title_range_only: c5ddaa8d2cc006c57f027b7d3b87854d workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190 + workspace/analysis/charts/time_dimension_toggle_description_range_only: 76ad3bd3bdbf049a2083d0cee5cab8e0 workspace/analysis/charts/vertical_bars: 408174fe449ea6f5457988dd218183be workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa workspace/analysis/dashboards/chart_duplicate_failed: 90d7166c85188b52f821c9d9f53ff8c4 @@ -2407,7 +2414,6 @@ checksums: workspace/settings/billing/comparison_row_respondent_id: ce83979aa301987ae6017cf18ce02a5b workspace/settings/billing/comparison_row_responses: 8ee5a047e4b52e64bc500ae1bb013319 workspace/settings/billing/comparison_row_single_use_links: 8252955c867f070fc59d0e78de22785e - workspace/settings/billing/comparison_row_spam: e9a6afefaa7e887496f513ffd597761e workspace/settings/billing/comparison_row_teams_roles: ecde7a5204109c220a358d46210c869d workspace/settings/billing/comparison_row_topic_labeling: c7b6fe42c53b9672cf3a4d0d183866d8 workspace/settings/billing/comparison_row_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db @@ -2506,7 +2512,7 @@ checksums: workspace/settings/billing/plan_scale_feature_quota: f0b44ec6d192e8ee2fd3ff53b8ecda32 workspace/settings/billing/plan_scale_feature_rbac: ecde7a5204109c220a358d46210c869d workspace/settings/billing/plan_scale_feature_responses: f2be033ebf6c86a664b812b4a918647f - workspace/settings/billing/plan_scale_feature_security: 6671961cf8d8413d1740b13901bcc033 + workspace/settings/billing/plan_scale_feature_security: 7961f365b26d9197654580597c94dfdb workspace/settings/billing/plan_scale_feature_semantic_analysis: 1441e34cacd26f0aa27af4ebad6e5c54 workspace/settings/billing/plan_scale_feature_workflow_runs: 22007bc0ad530bfd56b8a3788bdef02f workspace/settings/billing/plan_scale_feature_workspaces: 6bd1b676b9470ca8cc4e73be3ffd4bef diff --git a/apps/web/instrumentation-jobs.test.ts b/apps/web/instrumentation-jobs.test.ts index f4b64daab8ec..9df0feb2bc51 100644 --- a/apps/web/instrumentation-jobs.test.ts +++ b/apps/web/instrumentation-jobs.test.ts @@ -7,6 +7,8 @@ const mockRemoveSurveyArchivePurge = vi.fn(); const mockUpsertSurveyArchivePurge = vi.fn(); const mockRemoveWorkflowRunReconcile = vi.fn(); const mockUpsertWorkflowRunReconcile = vi.fn(); +const mockUpsertAuthzedProjectionDelivery = vi.fn(); +const mockUpsertAuthzedReconciliationAudit = vi.fn(); const mockDebug = vi.fn(); const mockError = vi.fn(); const mockWarn = vi.fn(); @@ -17,6 +19,8 @@ const mockProcessSurveySchedulingJob = vi.fn(); const mockProcessSurveyArchivePurgeJob = vi.fn(); const mockProcessWorkflowRunJob = vi.fn(); const mockProcessWorkflowRunReconcileJob = vi.fn(); +const mockProcessAuthzedProjectionDeliveryJob = vi.fn(); +const mockProcessAuthzedScheduledReconciliationJob = vi.fn(); const TEST_TIMEOUT_MS = 15_000; const slowTest = (name: string, fn: () => Promise): void => { @@ -31,6 +35,18 @@ vi.mock("@formbricks/jobs", () => ({ workflowRun: "workflow-run.process", }, recurringJobs: { + authzedProjectionDelivery: { + name: "authzed-projection.deliver", + scheduleId: "authzed-projection-delivery", + scope: "global", + upsert: mockUpsertAuthzedProjectionDelivery, + }, + authzedReconciliationAudit: { + name: "authzed-reconciliation.audit", + scheduleId: "authzed-reconciliation-audit", + scope: "global", + upsert: mockUpsertAuthzedReconciliationAudit, + }, surveyArchivePurge: { name: "survey-archive-purge.process", remove: mockRemoveSurveyArchivePurge, @@ -90,6 +106,14 @@ vi.mock("@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job", processWorkflowRunReconcileJob: mockProcessWorkflowRunReconcileJob, })); +vi.mock("@/lib/authzed/outbox-processor", () => ({ + processAuthzedProjectionDeliveryJob: mockProcessAuthzedProjectionDeliveryJob, +})); + +vi.mock("@/lib/authzed/scheduled-reconciliation", () => ({ + processAuthzedScheduledReconciliationJob: mockProcessAuthzedScheduledReconciliationJob, +})); + describe("instrumentation-jobs", () => { beforeEach(() => { vi.resetModules(); @@ -176,6 +200,8 @@ describe("instrumentation-jobs", () => { expect(mockStartJobsRuntime).toHaveBeenCalledWith({ concurrency: 4, jobHandlerOverrides: { + "authzed-projection.deliver": expect.any(Function), + "authzed-reconciliation.audit": expect.any(Function), "response-pipeline.process": expect.any(Function), "survey-scheduling.reconcile": expect.any(Function), "survey-archive-purge.process": expect.any(Function), @@ -416,6 +442,16 @@ describe("instrumentation-jobs", () => { // The schedule identity and payload now belong to the job declaration in @formbricks/jobs (and are // asserted there); what this app owns, and what is asserted here, is the timing per job. expect(mockStartJobsRuntime).not.toHaveBeenCalled(); + expect(mockUpsertAuthzedProjectionDelivery).toHaveBeenCalledOnce(); + expect(mockUpsertAuthzedProjectionDelivery).toHaveBeenCalledWith({ + everyMs: 5_000, + kind: "every", + }); + expect(mockUpsertAuthzedReconciliationAudit).toHaveBeenCalledOnce(); + expect(mockUpsertAuthzedReconciliationAudit).toHaveBeenCalledWith({ + everyMs: 6 * 60 * 60 * 1_000, + kind: "every", + }); expect(mockUpsertSurveyScheduling).toHaveBeenCalledTimes(1); expect(mockUpsertSurveyScheduling).toHaveBeenCalledWith({ cronPattern: SURVEY_SCHEDULING_DAILY_CRON_PATTERN, diff --git a/apps/web/integration/authzed.ts b/apps/web/integration/authzed.ts new file mode 100644 index 000000000000..d2cd2361b74a --- /dev/null +++ b/apps/web/integration/authzed.ts @@ -0,0 +1,45 @@ +import "server-only"; +import { runAuthzedBackfill } from "@/lib/authzed/backfill"; +import { createAuthzedBackfillApply, createAuthzedBackfillNoopApply } from "@/lib/authzed/backfill-apply"; +import { getAuthzedClient } from "@/lib/authzed/client"; +import { AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN } from "@/lib/authzed/constants"; +import { drainAuthzedOutbox } from "@/lib/authzed/outbox-processor"; + +/** + * Converge the real integration database and the disposable SpiceDB fixture. + * + * Integration fixtures write through Prisma directly rather than product services. The database + * triggers still enqueue durable projection events, but the Vitest harness intentionally does not + * start the jobs worker. A full applying reconciliation also removes relationships left by the + * previous test file: `resetDb()` truncates PostgreSQL and the outbox, while SpiceDB is shared by the + * serial integration process. + */ +export const synchronizeAuthzedIntegrationFixture = async (): Promise => { + const client = getAuthzedClient(); + const request = { + maxPrune: AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN, + prune: true, + scope: { kind: "all" }, + } as const; + + const applied = await runAuthzedBackfill( + { ...request, mode: "apply" }, + { apply: createAuthzedBackfillApply(), client } + ); + if (applied.counters.failed > 0) { + throw new Error("AuthZed integration fixture reconciliation failed"); + } + + const drained = await drainAuthzedOutbox(); + if (drained.status !== "drained" || drained.failed > 0 || drained.deadLettered > 0) { + throw new Error("AuthZed integration fixture outbox did not drain"); + } + + const verified = await runAuthzedBackfill( + { ...request, mode: "dry_run" }, + { apply: createAuthzedBackfillNoopApply(), client } + ); + if (verified.status !== "reconciled") { + throw new Error("AuthZed integration fixture did not converge"); + } +}; diff --git a/apps/web/integration/reset-db.ts b/apps/web/integration/reset-db.ts index 283b44e02145..a7a318a0a145 100644 --- a/apps/web/integration/reset-db.ts +++ b/apps/web/integration/reset-db.ts @@ -15,6 +15,13 @@ import { prisma } from "@formbricks/database"; * INVARIANT: this relies on every table a flow writes being FK-cascade-reachable from one of these * three roots. A future flow that writes a non-descendant table (or one with `onDelete: SetNull` / * `Restrict`) must be added here, or its rows will bleed across tests. + * + * `AuthzedProjectionOutbox` is the first table to hit that invariant. It has no foreign keys at all — + * the durable projection queue records identifiers by value so a row survives the deletion it + * describes — so nothing above cascades to it, and database triggers write to it on every mutation + * the three roots cascade through. */ export const resetDb = (): Promise => - prisma.$executeRawUnsafe('TRUNCATE "User", "Organization", "Team" RESTART IDENTITY CASCADE;'); + prisma.$executeRawUnsafe( + 'TRUNCATE "User", "Organization", "Team", "AuthzedProjectionOutbox" RESTART IDENTITY CASCADE;' + ); diff --git a/apps/web/lib/auth.test.ts b/apps/web/lib/auth.test.ts index 65b34eeecf54..8d475cd093fb 100644 --- a/apps/web/lib/auth.test.ts +++ b/apps/web/lib/auth.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { prisma } from "@formbricks/database"; import { AuthenticationError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; import { hasOrganizationAccess, hasOrganizationAuthority, @@ -13,13 +13,8 @@ import { const PASSWORD_TEST_TIMEOUT_MS = 30_000; -// Mock prisma -vi.mock("@formbricks/database", () => ({ - prisma: { - membership: { - findUnique: vi.fn(), - }, - }, +vi.mock("@/lib/authorization", () => ({ + can: vi.fn(), })); describe("Password Management", () => { @@ -66,79 +61,61 @@ describe("Organization Access", () => { }); test("hasOrganizationAccess should return true when user has membership", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "member", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(true); const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId); expect(hasAccess).toBe(true); + expect(can).toHaveBeenCalledWith({ type: "user", id: mockUserId }, "organization.read", { + type: "organization", + id: mockOrgId, + }); }); test("hasOrganizationAccess should return false when user has no membership", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + vi.mocked(can).mockResolvedValue(false); const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId); expect(hasAccess).toBe(false); }); test("isManagerOrOwner should return true for manager role", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "manager", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(true); const isManager = await isManagerOrOwner(mockUserId, mockOrgId); expect(isManager).toBe(true); + expect(can).toHaveBeenCalledWith({ type: "user", id: mockUserId }, "organization.manage", { + type: "organization", + id: mockOrgId, + }); }); test("isManagerOrOwner should return true for owner role", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "owner", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(true); const isOwner = await isManagerOrOwner(mockUserId, mockOrgId); expect(isOwner).toBe(true); }); test("isManagerOrOwner should return false for member role", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "member", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(false); const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId); expect(isManagerOrOwnerRole).toBe(false); }); test("isOwner should return true only for owner role", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "owner", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(true); const isOwnerRole = await isOwner(mockUserId, mockOrgId); expect(isOwnerRole).toBe(true); + expect(can).toHaveBeenCalledWith({ type: "user", id: mockUserId }, "organization.write", { + type: "organization", + id: mockOrgId, + }); }); test("isOwner should return false for non-owner roles", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "manager", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(false); const isOwnerRole = await isOwner(mockUserId, mockOrgId); expect(isOwnerRole).toBe(false); @@ -154,59 +131,39 @@ describe("Organization Authority", () => { }); test("hasOrganizationAuthority should return true for manager", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "manager", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(true); const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId); expect(hasAuthority).toBe(true); }); test("hasOrganizationAuthority should throw for non-member", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + vi.mocked(can).mockResolvedValue(false); await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); }); test("hasOrganizationAuthority should throw for member role", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "member", - accepted: true, - }); + vi.mocked(can).mockResolvedValueOnce(true).mockResolvedValueOnce(false); await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); }); test("hasOrganizationOwnership should return true for owner", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "owner", - accepted: true, - }); + vi.mocked(can).mockResolvedValue(true); const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId); expect(hasOwnership).toBe(true); }); test("hasOrganizationOwnership should throw for non-member", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue(null); + vi.mocked(can).mockResolvedValue(false); await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); }); test("hasOrganizationOwnership should throw for non-owner roles", async () => { - vi.mocked(prisma.membership.findUnique).mockResolvedValue({ - userId: mockUserId, - organizationId: mockOrgId, - role: "manager", - accepted: true, - }); + vi.mocked(can).mockResolvedValueOnce(true).mockResolvedValueOnce(false); await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError); }); diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 3f4080e7ed7d..2ef83fc03914 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -1,6 +1,7 @@ +import "server-only"; import { compare, hash } from "bcryptjs"; -import { prisma } from "@formbricks/database"; import { AuthenticationError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; export const hashPassword = async (password: string) => { const hashedPassword = await hash(password, 12); @@ -12,52 +13,14 @@ export const verifyPassword = async (password: string, hashedPassword: string) = return isValid; }; -export const hasOrganizationAccess = async (userId: string, organizationId: string): Promise => { - const membership = await prisma.membership.findUnique({ - where: { - userId_organizationId: { - userId, - organizationId, - }, - }, - }); +export const hasOrganizationAccess = (userId: string, organizationId: string): Promise => + can({ type: "user", id: userId }, "organization.read", { type: "organization", id: organizationId }); - return !!membership; -}; - -export const isManagerOrOwner = async (userId: string, organizationId: string) => { - const membership = await prisma.membership.findUnique({ - where: { - userId_organizationId: { - userId, - organizationId, - }, - }, - }); - - if (membership && (membership.role === "owner" || membership.role === "manager")) { - return true; - } - - return false; -}; - -export const isOwner = async (userId: string, organizationId: string) => { - const membership = await prisma.membership.findUnique({ - where: { - userId_organizationId: { - userId, - organizationId, - }, - }, - }); +export const isManagerOrOwner = (userId: string, organizationId: string): Promise => + can({ type: "user", id: userId }, "organization.manage", { type: "organization", id: organizationId }); - if (membership && membership.role === "owner") { - return true; - } - - return false; -}; +export const isOwner = (userId: string, organizationId: string): Promise => + can({ type: "user", id: userId }, "organization.write", { type: "organization", id: organizationId }); export const hasOrganizationAuthority = async (userId: string, organizationId: string) => { const hasAccess = await hasOrganizationAccess(userId, organizationId); diff --git a/apps/web/lib/authorization/README.md b/apps/web/lib/authorization/README.md new file mode 100644 index 000000000000..23801648cf39 --- /dev/null +++ b/apps/web/lib/authorization/README.md @@ -0,0 +1,288 @@ +# Current Authorization Contract + +This server-only module defines the engine-independent actor, action, and +resource vocabulary enforced by Formbricks today. Product authorization code +depends on this contract; AuthZed/SpiceDB is the sole runtime evaluator of it. + +The contract deliberately contains no AuthZed SDK types, configuration, +relationship writes, or network behavior. Resource IDs are opaque strings. +Existence, tenant boundaries, and permission evaluation remain runtime +responsibilities. + +## Public contract + +Import the types from `@/lib/authorization`. Actions are namespaced by the +application resource discriminant, for example `workspace.read` and +`survey.response_export`. The `apiKey` discriminant matches the existing +Formbricks authentication type; the downstream SpiceDB schema maps it to its +`api_key` definition. + +`TAuthorizationResourceForAction` preserves the action/resource +relationship for the central authorization API. The action must be the sole +generic inference source: + +```ts +const can = async ( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction> +): Promise => { + // Implemented by ENG-1712. +}; +``` + +Using `NoInfer` on the resource argument prevents TypeScript from widening an +invalid action/resource pair into a union. + +## Current role and grant mapping + +Organization membership establishes organization capabilities: + +| Current source | Current behavior | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `Membership.role = owner` | Reads, updates, and deletes the organization; has broad product, billing, access-control, and API-key management access. | +| `Membership.role = manager` | Has broad product, billing, access-control, and API-key management access, but cannot update or delete the organization. | +| `Membership.role = member` | Can see the organization and access-control resources; receives product access only through team membership. | +| `Membership.role = billing` | Can see the organization and billing surfaces but receives no product-data access. | + +Team and workspace grants use ordered permission ladders: + +| Current source | Permission implication | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `TeamUser.role = contributor` | Team membership and team read. | +| `TeamUser.role = admin` | Contributor capabilities plus team management. Team deletion still requires organization access management. | +| `WorkspaceTeam.permission = read` | `workspace.read`. | +| `WorkspaceTeam.permission = readWrite` | `workspace.read` and `workspace.write`. | +| `WorkspaceTeam.permission = manage` | `workspace.read`, `workspace.write`, `workspace.manage`, and `workspace.share`. | +| `ApiKeyWorkspace.permission = read` | `workspace.read`. | +| `ApiKeyWorkspace.permission = write` | `workspace.read` and `workspace.write`. | +| `ApiKeyWorkspace.permission = manage` | `workspace.read`, `workspace.write`, `workspace.manage`, and `workspace.share`. | +| `ApiKey.organizationAccess.accessControl.read` | Organization access-control and team read access. | +| `ApiKey.organizationAccess.accessControl.write` | Read access plus organization access management and team management/deletion. | + +Surveys, dashboards, and responses inherit access through their workspace: + +- Survey read/write/publish follow workspace read/write. +- Survey deletion in the web application and V3 API currently requires + workspace write; legacy management deletion and language management require + workspace manage. +- Dashboard read/write follow workspace read/write, including dashboard + deletion. +- Response read and export currently both follow workspace read. They remain + separate actions so a future policy split is explicit. +- Response updates, tags, and web-application deletion follow workspace write; + legacy management deletion follows workspace manage. + +Feedback Datasets use `FeedbackDirectory` as their application resource. Organization owners and +managers administer every dataset in their organization. Team members and API keys receive dataset +read/write/manage through `FeedbackDirectoryWorkspace` and their existing permission on that exact +workspace. The `feedbackDirectoryAssignment` resource therefore requires both the directory ID and a +`workspaceId`; directory-wide gateway operations use the aggregate `feedbackDirectory` resource. + +The downstream SpiceDB schema names these definitions `feedback_directory` and +`feedback_directory_assignment`. Product code must continue using the camel-case application names and +must not depend on that downstream naming convention. + +## Configuration-sensitive policy + +`organization.manage_access` is a stable application capability, and the SpiceDB +evaluator continues honoring `USER_MANAGEMENT_MINIMUM_ROLE` when selecting the +permission checked for user actors: + +- `manager`: owners and managers may manage users. +- `owner`: only owners may manage users. +- `disabled`: no organization role may manage users through that policy. + +This deployment setting is evaluator input. It is not encoded into the static +actor/action/resource types. + +## Migration inventory and authority contract + +The pinned bridge artifact uses the legacy evaluator only while its durable +relationship graph is established. The direct-authority candidate sends every +central decision to SpiceDB, including calls outside a request surface, and +contains no runtime selector or legacy fallback. A disabled or unhealthy +AuthZed client is an operational failure rather than permission denial or a +signal to select the bridge evaluator. + +The immutable bridge and candidate artifacts, fail-closed semantics, +sandbox-first validation, environment gates, and deployment-only rollback are +defined in the [direct AuthZed cutover and rollback contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad). + +### Historical bridge capabilities added by ENG-1738 + +- A private SpiceDB evaluator behind the unchanged `can()` and `assertCan()` + interface. +- PostgreSQL actor/resource existence and tenant-boundary resolution before a + SpiceDB check. +- Post-response comparison for selected authenticated request surfaces. +- Per-surface and per-organization migration cohorts. +- Bounded, identifier-free comparison metrics and mismatch/error logs. + +These capabilities supported parity research. They are removed before direct +authority and are not a sandbox, staging, production, or self-hosted rollout +mechanism. + +### Direct-authority telemetry + +Every scalar decision and authoritative organization/workspace lookup records a +bounded outcome (`allow`, `deny`, or `operational_error`) plus latency. The +dimensions are limited to surface, actor type, action, resource type, and a +stable error code. Empty authoritative lists are aggregate denies. No actor, +resource, organization, relationship, token, or raw error is emitted. + +The request context still records the number of central operations per request, +including unscoped calls, so the sole-evaluator cutover does not trade away N+1 +visibility. Instrumentation failures are fail-safe and cannot change an +authorization result. + +Surveys, dashboards, and responses are intentionally resolved to their owning +workspace before the SpiceDB check. Their parent relationships are not yet +projected, so checking those resource definitions directly would turn every +valid legacy decision into a false denial. This remains a current-model +migration; resource-level relationships belong to the later sharing phase. + +### Migrated by ENG-1714 + +- Canonical organization, workspace-team, and team-admin patterns accepted by + the action-client compatibility adapter. +- Organization membership and role helper decisions. +- Action-aware workspace access and shared analysis/V3 session workspace + authorization. +- Organization settings, workspace settings, team operations, and + user-managed API-key settings migrated to explicit actions. + +### Migrated by ENG-1731 + +- API-key principals in V1, V2, V3, MCP, and storage authorization paths. +- `ApiKeyWorkspace` permissions and organization access-control flags. +- Organization-only API-key opt-in and API-key revocation behavior. + +### Migrated by ENG-1737 + +- The broad, non-action-aware `hasUserWorkspaceAccess` helper is gone. + `getWorkspaceAuth` asks for `workspace.read`; the four navigation callers ask + `canUserNavigateWorkspace`, which is `workspace.read` or + `organization.manage_billing` — the second disjunct exists only because + reaching a workspace URL is how the `billing` role gets to billing. +- The action-client adapter's parallel legacy evaluator is gone. Every access + shape the repository produces maps onto exactly one central action, so the + fallback could only repeat a decision already made. Its `team` shape was + removed with it, an unmapped organization role set is now refused and logged + as a caller bug, and an empty requirement list is refused rather than passing. +- The two remaining role-name gates outside the module: workspace creation + during onboarding, and the self-hosted license recheck. The latter denied the + `member` role by name, which admitted `billing`; `organization.manage` is what + its own message always claimed. + +### Migrated by ENG-2388, ENG-2409 and ENG-2444 + +ENG-2388 added the `page` surface for server-rendered routes. Until then a `can` +call from a page or layout resolved no rollout target, so the coordinator +short-circuited to the legacy evaluator and scheduled no comparison — those +decisions were correct and invisible at the same time. The surface is opened at +the choke points routes already funnel through (`getWorkspaceAuth`, +`workspaceIdLayoutChecks`, `getWorkspaceLayoutData`) rather than at one boundary, +because Next gives no RSC equivalent of the action-client wrapper. + +ENG-2444 then fixed how long that surface lasts. `AsyncLocalStorage` closes when +the awaited callback returns, so the surface used to end with the choke-point +helper — a page that awaited `getWorkspaceAuth()` and then authorized anything +else did so outside any surface, back on the unconditional legacy path. That was +not merely missing evidence: it would have bypassed enforcement invisibly, since +a check with no surface never compares and so never mismatches. Nine routes were +affected, two of them issuing one unscoped check _per feedback directory_ or _per +dashboard widget_. + +`page` is now held in a React `cache()` slot, which is scoped to the whole render +pass, so a layout and its page share one context and it outlives every helper. +Every other surface keeps its `AsyncLocalStorage` boundary; outside a React +request scope — scripts, unit tests, any non-RSC caller — `page` falls back to +that same boundary, which is the pre-ENG-2444 behaviour. + +Two things follow. A navigation now records **one** checks-per-request +observation instead of one per choke point. And **`page:user` is eligible for +`AUTHZED_ENFORCEMENT_TARGETS`** — before ENG-2444 it had to stay shadow-only. + +`formbricks_authzed_authorization_unscoped_checks_total` should stay flat for +page traffic; it is kept deliberately as the regression detector if this boundary +is ever narrowed again. + +ENG-2409 then routed the organization-side gates, which a surface alone could not +help because they never called `can` at all: + +| Gate | Was | Now | +| -------------------------------------------------------- | --------------------------------- | ------------------------------ | +| `getOrganizationAuth` tenancy check | `if (!membership) throw` | `organization.read` | +| `redirectBillingRoleFromRestrictedOrgSettings` (5 pages) | `isBilling` | `organization.read_access` | +| Enterprise settings page | `isMember` | `organization.manage_billing` | +| Feedback directories page | `isOwner \|\| isManager` | `organization.manage` | +| API keys page | `role === "owner" \|\| "manager"` | `organization.manage_api_keys` | + +Two of those deserve their reasoning recorded, because the obvious mapping is +wrong in both cases: + +- **`read_access`, not `read`,** for the billing redirect. `read_access` is the + only permission whose expansion is "holds a product-eligible membership role", + which is what "not the billing role" means here. `product_member` has the same + expansion but is deliberately absent from the permission map — it exists to + intersect into `team#member`, and giving it a second job would mean a future + edit to it silently changed team-derived workspace access. +- **`manage_billing`, not `manage`,** for the enterprise page. On self-hosted, + `getOrganizationBillingPath` resolves to that very page, so it is where the + `billing` role is redirected _to_. Gating it on owner+manager would 404 that + role on its own landing page. + +#### Retained by design + +These read a role but do not decide access, so they stay as they are: + +- **Rendering.** Navigation, sidebars, settings forms, and role pickers take + `getAccessFlags` output as props. Hiding a control is not a gate; the gate is + on the action or page behind it. +- **Context output.** `getWorkspaceAuth` and `getOrganizationAuth` _return_ the + flags for those consumers. Their own gates are `can` calls. +- **`getOrganizationAuth` gates on membership only.** Unlike `getWorkspaceAuth`, + which redirects `billing` away from product data, the organization helper asks + only `organization.read`. The asymmetry is required: the billing settings page + is the `billing` role's own page, so a billing exclusion there would lock that + role out of the one surface it exists to reach. Callers that need to exclude + it do so themselves, via `redirectBillingRoleFromRestrictedOrgSettings`. +- **Invariants and finer rules.** An owner may not leave their organization; a + manager may only assign the `member` role; `billing` is Cloud-only. The + schema comments already name these as application rules — they constrain a + request's _content_, not the principal's capability. +- **Invite fan-out.** The signup and invite paths derive from the _invited_ + role whether to create `TeamUser` rows, since owners and managers get + workspace access from the role itself. That is a statement about the invite. +- **Authoritative list scoping.** Current-model organization and workspace discovery uses one + `LookupResources` operation per resource type, followed by a tenant-scoped PostgreSQL data query. This + covers the application organization/workspace switchers, survey-list workspace navigation, API v2 `/me`, + and V3/MCP workspace discovery. PostgreSQL supplies current resource data and API-key permission labels; + it does not widen the SpiceDB allowlist. Unknown, deleted, or foreign-tenant lookup results are discarded, + and lookup or projection-freshness failures fail the list closed. Billing-role users receive no workspace + results even if a stale team membership exists, matching their lack of product-data access instead of the + former switcher-only exception. React request caching deduplicates identical lookup tuples inside an RSC + request; API handlers do not rely on that cache and invoke each required lookup helper once. V3/MCP + workspace discovery therefore performs one `LookupResources(workspace, read)` operation per list call, + regardless of list size. Generic Phase 2 list authorization remains ENG-1713. + +New authorization-sensitive code must use `can` or `assertCan`; it must not add +callers to the deprecated action-client adapter or reintroduce a role-name gate. + +## Resource coverage inventory + +`resource-inventory.ts` classifies every Prisma model and audit target exactly once. Its regression test +fails when a new model or target has not been reviewed. The inventory distinguishes direct authorization +resources, relationship/grant sources, workspace-inherited resources, parent-derived integrity data, +authentication/application concerns, and explicit public/out-of-scope data. + +Charts and workflows remain workspace-inherited. Feedback records remain protected by their +dataset/workspace authorization decision plus application-level tenant and integrity validation. + +## Explicit exclusions + +The current contract has no system/service principal, survey-level sharing, +per-dashboard ACL, audit-log permission, contextual data-policy capability, or +generic Phase 2 list-resource abstraction. Current-model organization/workspace discovery is part of the +direct cutover and must not be confused with future per-resource sharing. diff --git a/apps/web/lib/authorization/checks-per-request-dashboards.integration.test.ts b/apps/web/lib/authorization/checks-per-request-dashboards.integration.test.ts new file mode 100644 index 000000000000..116f88ef3184 --- /dev/null +++ b/apps/web/lib/authorization/checks-per-request-dashboards.integration.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import { synchronizeAuthzedIntegrationFixture } from "@/integration/authzed"; +import { resetDb } from "@/integration/reset-db"; +import { can } from "@/lib/authorization"; +import { getIssuedAuthorizationCheckCount, withAuthorizationSurface } from "@/lib/authorization/context"; +import { getDashboards } from "@/modules/ee/analysis/dashboards/lib/dashboards"; + +/** + * ENG-1739 follow-up: the same N+1 proof `checks-per-request.integration.test.ts` gives the survey + * list, for the dashboard list. `getDashboards` runs no authorization of its own — the gate is the + * workspace check a dashboard page resolves once — so the claim under test is the same one: fetching + * many dashboards costs the same single check as fetching few. + */ +const scenario: { organizationId: string; userId: string; workspaceId: string } = { + organizationId: "", + userId: "", + workspaceId: "", +}; + +const DASHBOARD_COUNTS = [10, 2_000] as const; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ data: { name: "Dashboard Checks Org" } }); + const user = await prisma.user.create({ data: { name: "owner", email: "owner@dashboard-checks.test" } }); + await prisma.membership.create({ + data: { userId: user.id, organizationId: organization.id, role: "owner", accepted: true }, + }); + const workspace = await prisma.workspace.create({ + data: { name: "Dashboard Checks Workspace", organizationId: organization.id }, + }); + + scenario.organizationId = organization.id; + scenario.userId = user.id; + scenario.workspaceId = workspace.id; + await synchronizeAuthzedIntegrationFixture(); +}, 120_000); + +describe("dashboard list authorization amplification, against a real database", () => { + test.each(DASHBOARD_COUNTS)( + "fetching a workspace's dashboards costs exactly one check, with %d dashboards present", + async (dashboardCount) => { + await prisma.dashboard.deleteMany({ where: { workspaceId: scenario.workspaceId } }); + await prisma.dashboard.createMany({ + data: Array.from({ length: dashboardCount }, (_unused, index) => ({ + name: `dashboard-${index}`, + workspaceId: scenario.workspaceId, + })), + }); + + const result = await withAuthorizationSurface("server_action", async () => { + const canRead = await can({ type: "user", id: scenario.userId }, "workspace.read", { + type: "workspace", + id: scenario.workspaceId, + }); + expect(canRead).toBe(true); + + const dashboards = await getDashboards(scenario.workspaceId); + return { checksIssued: getIssuedAuthorizationCheckCount(), rowCount: dashboards.length }; + }); + + expect(result.rowCount).toBe(dashboardCount); + expect(result.checksIssued).toBe(1); + } + ); +}); diff --git a/apps/web/lib/authorization/checks-per-request-metric.test.ts b/apps/web/lib/authorization/checks-per-request-metric.test.ts new file mode 100644 index 000000000000..0da3afb8b24d --- /dev/null +++ b/apps/web/lib/authorization/checks-per-request-metric.test.ts @@ -0,0 +1,125 @@ +import { metrics } from "@opentelemetry/api"; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +/** + * ENG-1739 — the checks-per-request histogram, verified against a real SDK rather than by asserting + * the advice object. + * + * `advice` is only a *hint*: whether it takes effect depends on the SDK honouring it, so asserting + * the boundaries we passed in would restate the source and prove nothing. What is worth pinning is + * what lands on the exported data point, and that the whole path — `withAuthorizationSurface` → + * `after()` → `record` — actually produces one. + * + * The load-bearing assertion is the bucket *separation* between 0 and 1. OpenTelemetry buckets are + * upper-inclusive and lower-exclusive, so a boundary list starting at 1 puts a request that made no + * authorization decisions and a request that made exactly one into the same `(-inf, 1]` bucket. + * Every wrapped request records, and most product requests authorize once, so under those boundaries + * the healthy case would be unreadable on the very histogram that exists to make amplification + * visible. Asserting the two land in different buckets fails if that boundary is ever removed. + */ + +const afterCallbacks = vi.hoisted(() => [] as Array<() => Promise | void>); + +vi.mock("next/server", () => ({ + after: vi.fn((callback: () => Promise | void) => afterCallbacks.push(callback)), +})); + +const HISTOGRAM_NAME = "formbricks_authzed_authorization_checks_per_request"; + +type TBuckets = Readonly<{ boundaries: number[]; counts: number[] }>; +type THistogramValue = Readonly<{ buckets: TBuckets; count: number; sum: number }>; + +const recordRequestWithChecks = async ( + checkCount: number, + surface: "api_v3" | "server_action" = "server_action" +) => { + afterCallbacks.length = 0; + // Each case needs the histogram bound to *its* provider, and the instrument is created at module + // load, so the module graph has to be rebuilt after the provider is installed. + vi.resetModules(); + // `setGlobalMeterProvider` is a no-op while a global is already registered, so a second call + // without this would silently leave the instrument bound to the previous (already shut down) + // provider and export nothing. + metrics.disable(); + + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + // A long interval so nothing exports on a timer; `forceFlush` is what drives the export here. + const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); + const provider = new MeterProvider({ readers: [reader] }); + metrics.setGlobalMeterProvider(provider); + + const { recordAuthorizationCheckIssued, withAuthorizationSurface } = await import("./context"); + + await withAuthorizationSurface(surface, async () => { + for (let index = 0; index < checkCount; index += 1) recordAuthorizationCheckIssued(); + }); + await afterCallbacks[0](); + + await reader.forceFlush(); + const dataPoint = exporter + .getMetrics() + .flatMap((resourceMetric) => resourceMetric.scopeMetrics) + .flatMap((scopeMetric) => scopeMetric.metrics) + .find((metric) => metric.descriptor.name === HISTOGRAM_NAME)?.dataPoints[0]; + + return { dataPoint, provider, value: dataPoint?.value as THistogramValue | undefined }; +}; + +/** The single populated bucket for a one-observation histogram. */ +const occupiedBucketIndex = (value: THistogramValue | undefined): number => + (value?.buckets.counts ?? []).findIndex((count) => count > 0); + +describe("checks-per-request histogram", () => { + let shutdown: (() => Promise) | undefined; + + afterEach(async () => { + await shutdown?.(); + shutdown = undefined; + metrics.disable(); + }); + + test("a request making no checks and one making a single check land in different buckets", async () => { + const none = await recordRequestWithChecks(0); + shutdown = () => none.provider.shutdown(); + const noneIndex = occupiedBucketIndex(none.value); + await shutdown(); + + const one = await recordRequestWithChecks(1); + shutdown = () => one.provider.shutdown(); + const oneIndex = occupiedBucketIndex(one.value); + + expect(noneIndex).toBeGreaterThanOrEqual(0); + expect(oneIndex).toBeGreaterThanOrEqual(0); + // Without the 0.5 boundary both are bucket 0 and this equality holds — which is the regression. + expect(oneIndex).not.toBe(noneIndex); + }); + + test("exports the request's check count as the observed value, tagged by surface", async () => { + const { dataPoint, provider, value } = await recordRequestWithChecks(7, "api_v3"); + shutdown = () => provider.shutdown(); + + expect(value?.count).toBe(1); + // The observation is the count itself, not a duration or a rate. + expect(value?.sum).toBe(7); + expect(dataPoint?.attributes).toEqual({ surface: "api_v3" }); + }); + + test("keeps resolution across the amplification range a regression would move through", async () => { + const { provider, value } = await recordRequestWithChecks(1); + shutdown = () => provider.shutdown(); + + const boundaries = value?.buckets.boundaries ?? []; + expect(boundaries[0]).toBe(0.5); + // A per-row regression on a 3,000-row list has to remain distinguishable from a healthy request + // rather than saturating the final bucket. + expect(Math.max(...boundaries)).toBeGreaterThanOrEqual(1_000); + // Enough small buckets that going from 1 check to a handful is visible, not averaged away. + expect(boundaries.filter((boundary) => boundary <= 10)).toHaveLength(6); + }); +}); diff --git a/apps/web/lib/authorization/checks-per-request-workspace-discovery.integration.test.ts b/apps/web/lib/authorization/checks-per-request-workspace-discovery.integration.test.ts new file mode 100644 index 000000000000..79916e7ebf9e --- /dev/null +++ b/apps/web/lib/authorization/checks-per-request-workspace-discovery.integration.test.ts @@ -0,0 +1,88 @@ +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { listV3Workspaces } from "@/app/api/v3/workspaces/lib/operations"; +import { resetDb } from "@/integration/reset-db"; +import { getIssuedAuthorizationCheckCount, withAuthorizationSurface } from "./context"; + +const lookupResources = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/authzed/client", () => ({ + getAuthzedClient: () => ({ lookupResources }), +})); +vi.mock("@/lib/authzed/outbox-freshness", () => ({ + assertAuthzedProjectionFreshness: vi.fn(), +})); + +const scenario = { organizationId: "", userId: "" }; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ + data: { name: "Workspace Discovery Checks Org" }, + }); + const user = await prisma.user.create({ + data: { email: "workspace-discovery-checks@test.local", name: "Discovery Owner" }, + }); + await prisma.membership.create({ + data: { accepted: true, organizationId: organization.id, role: "owner", userId: user.id }, + }); + + scenario.organizationId = organization.id; + scenario.userId = user.id; +}, 120_000); + +beforeEach(() => { + lookupResources.mockImplementation(async () => ({ + resourceIds: ( + await prisma.workspace.findMany({ + where: { organizationId: scenario.organizationId }, + select: { id: true }, + }) + ).map(({ id }) => id), + })); +}); + +const listAndCount = async (): Promise> => + withAuthorizationSurface("mcp", async () => { + const response = await listV3Workspaces({ + authentication: { + expires: "2027-01-01T00:00:00.000Z", + user: { id: scenario.userId }, + } as never, + instance: "/api/mcp", + requestId: "workspace-discovery-check-count", + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: ReadonlyArray }; + return { + checksIssued: getIssuedAuthorizationCheckCount() ?? -1, + workspaceCount: body.data.length, + }; + }); + +describe("MCP workspace discovery amplification with a mocked lookup and real PostgreSQL resolution", () => { + test("one and one hundred workspaces each produce exactly one central operation", async () => { + await prisma.workspace.create({ + data: { name: "Discovery Workspace 1", organizationId: scenario.organizationId }, + }); + const small = await listAndCount(); + + await prisma.workspace.createMany({ + data: Array.from({ length: 99 }, (_unused, index) => ({ + name: `Discovery Workspace ${index + 2}`, + organizationId: scenario.organizationId, + })), + }); + const large = await listAndCount(); + + expect(small.workspaceCount).toBe(1); + expect(large.workspaceCount).toBe(100); + // Positive assertions prevent an accidentally disconnected counter from making the growth check + // pass vacuously at zero. + expect(small.checksIssued).toBeGreaterThan(0); + expect(large.checksIssued).toBeGreaterThan(0); + expect(small.checksIssued).toBe(1); + expect(large.checksIssued - small.checksIssued).toBe(0); + }); +}); diff --git a/apps/web/lib/authorization/checks-per-request.integration.test.ts b/apps/web/lib/authorization/checks-per-request.integration.test.ts new file mode 100644 index 000000000000..0c53ef9b513c --- /dev/null +++ b/apps/web/lib/authorization/checks-per-request.integration.test.ts @@ -0,0 +1,193 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import { synchronizeAuthzedIntegrationFixture } from "@/integration/authzed"; +import { resetDb } from "@/integration/reset-db"; +import { can } from "@/lib/authorization"; +import { getIssuedAuthorizationCheckCount, withAuthorizationSurface } from "@/lib/authorization/context"; +import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page"; + +/** + * ENG-1739 — the ticket's central claim, proven rather than argued. + * + * Everything else this ticket produced (`authzed-perf.ts`) times a single authorization decision. It + * cannot see whether a page or endpoint issues one decision or one per row — a regression that made + * the survey list check once per survey would still report "fast" checks. Only a per-request count + * can see that, which is what `getIssuedAuthorizationCheckCount` exists for. + * + * The real list path is exactly what a workspace's survey list page uses: one `workspace.read` + * decision (what `getWorkspaceAuth` asks), then `getSurveyListPage` to fetch the rows. The list query + * runs no authorization of its own — access was already established by the workspace check — so the + * claim under test is that fetching many rows costs the same ONE check as fetching few. + * + * ENG-2388: these declare the `page` surface. They previously declared `server_action`, which was a + * stand-in — the scenario is a page render, but no page surface existed to name. That substitution is + * itself the bug ENG-2388 fixes, and it made the test quietly unfaithful: the checks it counted were + * attributed to the wrong surface, so the histogram this file exists to defend reported page renders + * as server-action traffic. Saying `page` here is both the accurate declaration and the end-to-end + * proof that the new surface resolves a rollout target through the real `can()` and coordinator. + */ +const scenario: { organizationId: string; userId: string; workspaceId: string } = { + organizationId: "", + userId: "", + workspaceId: "", +}; + +const SURVEY_COUNTS = [50, 3_000] as const; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ data: { name: "Checks Per Request Org" } }); + const user = await prisma.user.create({ data: { name: "owner", email: "owner@checks.test" } }); + await prisma.membership.create({ + data: { userId: user.id, organizationId: organization.id, role: "owner", accepted: true }, + }); + const workspace = await prisma.workspace.create({ + data: { name: "Checks Per Request Workspace", organizationId: organization.id }, + }); + + scenario.organizationId = organization.id; + scenario.userId = user.id; + scenario.workspaceId = workspace.id; + await synchronizeAuthzedIntegrationFixture(); +}, 120_000); + +describe("survey list authorization amplification, against a real database", () => { + test.each(SURVEY_COUNTS)( + "fetching a workspace's surveys costs exactly one check, with %d surveys present", + async (surveyCount) => { + await prisma.survey.deleteMany({ where: { workspaceId: scenario.workspaceId } }); + await prisma.survey.createMany({ + data: Array.from({ length: surveyCount }, (_unused, index) => ({ + name: `survey-${index}`, + workspaceId: scenario.workspaceId, + status: "inProgress" as const, + type: "link" as const, + })), + }); + + const surveys = await withAuthorizationSurface("page", async () => { + const canRead = await can({ type: "user", id: scenario.userId }, "workspace.read", { + type: "workspace", + id: scenario.workspaceId, + }); + expect(canRead).toBe(true); + + const page = await getSurveyListPage(scenario.workspaceId, { + limit: surveyCount, + cursor: null, + sortBy: "updatedAt", + }); + return { checksIssued: getIssuedAuthorizationCheckCount(), rowCount: page.surveys.length }; + }); + + expect(surveys.rowCount).toBe(surveyCount); + // The claim: one workspace-level decision, independent of how many rows it unlocked. + expect(surveys.checksIssued).toBe(1); + } + ); + + test("the check count does not grow between 50 and 3,000 surveys", async () => { + const countFor = async (surveyCount: number): Promise => { + await prisma.survey.deleteMany({ where: { workspaceId: scenario.workspaceId } }); + await prisma.survey.createMany({ + data: Array.from({ length: surveyCount }, (_unused, index) => ({ + name: `growth-survey-${index}`, + workspaceId: scenario.workspaceId, + status: "inProgress" as const, + type: "link" as const, + })), + }); + + return withAuthorizationSurface("page", async () => { + await can({ type: "user", id: scenario.userId }, "workspace.read", { + type: "workspace", + id: scenario.workspaceId, + }); + await getSurveyListPage(scenario.workspaceId, { + limit: surveyCount, + cursor: null, + sortBy: "updatedAt", + }); + return getIssuedAuthorizationCheckCount() ?? -1; + }); + }; + + // Sequential, not concurrent: both calls reuse and rewrite the same workspace's surveys, so + // running them together would race the delete/create of one against the other's read. + const small = await countFor(50); + const large = await countFor(3_000); + // Asserted separately: a counter that stopped incrementing would read 0 for both, and + // `large - small` would equal 0 vacuously — this was actually missed on first pass, caught only + // by re-checking which assertion a mutation run left passing rather than trusting the tally. + expect(small).toBeGreaterThan(0); + // Not merely "both equal the same thing" — a 60x growth in rows produces zero growth in checks. + // This is the O(1) claim the ticket asks for, stated as an equality a regression would break. + expect(large - small).toBe(0); + }); +}); + +describe("survey list authorization amplification (member, not owner), against a real database", () => { + /** + * Separate from the owner tests above: owners short-circuit nearly every authorization path, so an + * integration test that only exercises the owner role misses the scope-resolver and team-membership + * code that members exercise. This variant sets up a member whose workspace access arrives through + * team membership — the path real non-admin users take — and confirms the O(1) claim still holds. + */ + const scenario = { organizationId: "", userId: "", workspaceId: "" }; + + beforeAll(async () => { + const organization = await prisma.organization.create({ data: { name: "Member Checks Org" } }); + const user = await prisma.user.create({ data: { name: "member", email: "member@checks.test" } }); + await prisma.membership.create({ + data: { userId: user.id, organizationId: organization.id, role: "member", accepted: true }, + }); + const workspace = await prisma.workspace.create({ + data: { name: "Member Checks Workspace", organizationId: organization.id }, + }); + const team = await prisma.team.create({ + data: { name: "Member Checks Team", organizationId: organization.id }, + }); + await prisma.teamUser.create({ + data: { teamId: team.id, userId: user.id, role: "contributor" }, + }); + await prisma.workspaceTeam.create({ + data: { teamId: team.id, workspaceId: workspace.id, permission: "read" }, + }); + + scenario.organizationId = organization.id; + scenario.userId = user.id; + scenario.workspaceId = workspace.id; + await synchronizeAuthzedIntegrationFixture(); + }, 120_000); + + test("a member with team-based workspace access issues exactly one check for the survey list", async () => { + await prisma.survey.createMany({ + data: Array.from({ length: 100 }, (_unused, index) => ({ + name: `member-survey-${index}`, + workspaceId: scenario.workspaceId, + status: "inProgress" as const, + type: "link" as const, + })), + }); + + const result = await withAuthorizationSurface("page", async () => { + const canRead = await can({ type: "user", id: scenario.userId }, "workspace.read", { + type: "workspace", + id: scenario.workspaceId, + }); + expect(canRead).toBe(true); + + const page = await getSurveyListPage(scenario.workspaceId, { + limit: 100, + cursor: null, + sortBy: "updatedAt", + }); + return { checksIssued: getIssuedAuthorizationCheckCount(), rowCount: page.surveys.length }; + }); + + expect(result.rowCount).toBe(100); + // Owner or member, the workspace-level decision is still one check. + expect(result.checksIssued).toBe(1); + }); +}); diff --git a/apps/web/lib/authorization/context.rsc.test.ts b/apps/web/lib/authorization/context.rsc.test.ts new file mode 100644 index 000000000000..9e055bab1cd1 --- /dev/null +++ b/apps/web/lib/authorization/context.rsc.test.ts @@ -0,0 +1,165 @@ +import { after } from "next/server"; +import * as React from "react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + getAuthorizationSurface, + getIssuedAuthorizationCheckCount, + recordAuthorizationCheckIssued, + withAuthorizationSurface, +} from "./context"; +import { recordAuthorizationChecksPerRequest } from "./metrics"; + +/** + * ENG-2444 — the `page` surface under a real React request scope. + * + * This file exists because the `unit` project cannot cover the fix at all: the default build of React + * ships `cache` as a permanent no-op (`return fn.apply(null, arguments)`), so the slot the `page` + * surface lives in never memoizes there and the surface silently falls back to the old async-scoped + * boundary. Only the react-server build implements `cache`, which is why these run in the `rsc` + * Vitest project (see vite.config.mts). + * + * The request scope is installed directly rather than by running a renderer: React's `cache` reads + * `ReactSharedInternals.A` and calls `getCacheForType` on it, and that is the entire contract Next.js + * satisfies per request. Installing it here exercises React's REAL `cache` implementation — what is + * substituted is the request boundary Next would provide, not the behaviour under test. + */ +const reactServerInternals = ( + React as unknown as { + __SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { A: unknown }; + } +).__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + +/** One React request scope, the shape Next.js establishes around a render. */ +const enterRequestScope = (): void => { + const roots = new Map<() => unknown, unknown>(); + reactServerInternals.A = { + getCacheForType(resourceType: () => T): T { + if (!roots.has(resourceType)) roots.set(resourceType, resourceType()); + return roots.get(resourceType) as T; + }, + }; +}; + +const leaveRequestScope = (): void => { + reactServerInternals.A = null; +}; + +const afterCallbacks = vi.hoisted(() => [] as Array<() => Promise | void>); + +vi.mock("next/server", () => ({ + after: vi.fn((task: Promise | (() => unknown | Promise)) => + afterCallbacks.push(async () => { + if (typeof task === "function") { + await task(); + } else { + await task; + } + }) + ), +})); +vi.mock("./metrics", () => ({ recordAuthorizationChecksPerRequest: vi.fn() })); + +beforeEach(() => { + afterCallbacks.length = 0; + leaveRequestScope(); + vi.mocked(recordAuthorizationChecksPerRequest).mockReset(); + vi.mocked(after) + .mockReset() + .mockImplementation((task) => + afterCallbacks.push(async () => { + if (typeof task === "function") { + await task(); + } else { + await task; + } + }) + ); +}); + +describe("the react-server build is what makes this testable", () => { + test("React here implements cache for real, and only memoizes inside a request scope", () => { + const slot = React.cache(() => ({})); + + expect(slot()).not.toBe(slot()); + enterRequestScope(); + expect(slot()).toBe(slot()); + }); +}); + +describe("page surface — request-scoped boundary (ENG-2444)", () => { + test("a check issued AFTER the choke-point helper returns is still attributed to the page surface", async () => { + enterRequestScope(); + + // Exactly the shape of a real page: await the choke point, then keep authorizing. Before this + // change the surface closed with the helper and this check answered from the legacy evaluator + // with no rollout target at all — correct, invisible, and unenforceable. + await withAuthorizationSurface("page", async () => "workspace-auth"); + + expect(getAuthorizationSurface()).toBe("page"); + }); + + test("a layout and its page share ONE context, so the request records one observation", async () => { + enterRequestScope(); + + // Two choke points in one render — a layout's and its page's. + await withAuthorizationSurface("page", async () => recordAuthorizationCheckIssued()); + await withAuthorizationSurface("page", async () => recordAuthorizationCheckIssued()); + // ...plus a check the page makes on its own, outside both. + recordAuthorizationCheckIssued(); + + expect(getIssuedAuthorizationCheckCount()).toBe(3); + expect(afterCallbacks).toHaveLength(1); + + await afterCallbacks[0](); + expect(recordAuthorizationChecksPerRequest).toHaveBeenCalledTimes(1); + expect(recordAuthorizationChecksPerRequest).toHaveBeenCalledWith(3, "page"); + }); + + test("two requests never share a context", async () => { + enterRequestScope(); + await withAuthorizationSurface("page", async () => recordAuthorizationCheckIssued()); + expect(getIssuedAuthorizationCheckCount()).toBe(1); + + enterRequestScope(); + expect(getAuthorizationSurface()).toBe("unscoped"); + await withAuthorizationSurface("page", async () => undefined); + expect(getIssuedAuthorizationCheckCount()).toBe(0); + }); + + test("an enclosing server-action surface keeps precedence over the page slot", async () => { + enterRequestScope(); + + await withAuthorizationSurface("server_action", async () => { + await withAuthorizationSurface("page", async () => undefined); + // The action opened first and owns the request: a page choke point inside it must not + // re-attribute the request to `the page surface`. + expect(getAuthorizationSurface()).toBe("server_action"); + }); + }); + + test("an established page surface keeps precedence over a nested non-page wrapper", async () => { + enterRequestScope(); + + await withAuthorizationSurface("page", async () => { + await withAuthorizationSurface("server_action", async () => { + recordAuthorizationCheckIssued(); + expect(getAuthorizationSurface()).toBe("page"); + }); + }); + + expect(afterCallbacks).toHaveLength(1); + await afterCallbacks[0](); + expect(recordAuthorizationChecksPerRequest).toHaveBeenCalledOnce(); + expect(recordAuthorizationChecksPerRequest).toHaveBeenCalledWith(1, "page"); + }); + + test("outside a request scope it falls back to the async-scoped boundary", async () => { + // Scripts and non-RSC callers have no scope to hang the slot on. The surface must still work + // within the callback — that is the pre-ENG-2444 behaviour — and must not leak past it. + await withAuthorizationSurface("page", async () => { + expect(getAuthorizationSurface()).toBe("page"); + }); + + expect(getAuthorizationSurface()).toBe("unscoped"); + }); +}); diff --git a/apps/web/lib/authorization/context.test.ts b/apps/web/lib/authorization/context.test.ts new file mode 100644 index 000000000000..2883c154fc01 --- /dev/null +++ b/apps/web/lib/authorization/context.test.ts @@ -0,0 +1,156 @@ +import { after } from "next/server"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + getAuthorizationSurface, + getIssuedAuthorizationCheckCount, + recordAuthorizationCheckIssued, + withAuthorizationSurface, +} from "./context"; +import { recordAuthorizationChecksPerRequest } from "./metrics"; + +const afterCallbacks = vi.hoisted(() => [] as Array<() => Promise | void>); + +vi.mock("next/server", () => ({ + after: vi.fn((task: unknown) => { + if (typeof task === "function") { + afterCallbacks.push(async () => { + await task(); + }); + } + }), +})); + +vi.mock("./metrics", () => ({ recordAuthorizationChecksPerRequest: vi.fn() })); + +beforeEach(() => { + afterCallbacks.length = 0; + vi.mocked(recordAuthorizationChecksPerRequest).mockReset(); + vi.mocked(after) + .mockReset() + .mockImplementation((task) => { + if (typeof task === "function") { + afterCallbacks.push(async () => { + await task(); + }); + } + }); +}); + +describe("authorization request context", () => { + test("preserves the outer surface across nested wrappers", async () => { + await withAuthorizationSurface("api_v1", () => + withAuthorizationSurface("api_v3", async () => { + expect(getAuthorizationSurface()).toBe("api_v1"); + }) + ); + + expect(afterCallbacks).toHaveLength(1); + }); + + test("isolates concurrent request surfaces", async () => { + const observed = await Promise.all([ + withAuthorizationSurface("api_v1", async () => { + await Promise.resolve(); + return getAuthorizationSurface(); + }), + withAuthorizationSurface("mcp", async () => { + await Promise.resolve(); + return getAuthorizationSurface(); + }), + ]); + + expect(observed).toEqual(["api_v1", "mcp"]); + expect(afterCallbacks).toHaveLength(2); + }); + + // ENG-2444 made `page` request-scoped via a React `cache()` slot. Nothing in this project has such a + // scope — the shared vitestSetup mocks `cache` to identity, and the non-server React build ships it + // as a no-op anyway — so `page` here exercises the async-scoped fallback, which is what scripts and + // any non-RSC caller get. It must still work inside the callback and must not leak past it. The + // request-scoped behaviour is covered in context.rsc.test.ts, which runs React's real `cache`. + test("page falls back to the async-scoped boundary when there is no request scope", async () => { + await withAuthorizationSurface("page", async () => { + expect(getAuthorizationSurface()).toBe("page"); + recordAuthorizationCheckIssued(); + expect(getIssuedAuthorizationCheckCount()).toBe(1); + }); + + expect(getAuthorizationSurface()).toBe("unscoped"); + expect(getIssuedAuthorizationCheckCount()).toBeNull(); + }); + + test("is unscoped outside a request context", () => { + expect(getAuthorizationSurface()).toBe("unscoped"); + }); + + test("returns the callback result when after is unavailable", async () => { + vi.mocked(after).mockImplementationOnce(() => { + throw new Error("after is unavailable"); + }); + + await expect(withAuthorizationSurface("server_action", async () => "completed")).resolves.toBe( + "completed" + ); + expect(afterCallbacks).toHaveLength(0); + }); + + test("counts each central operation within a surface", async () => { + await withAuthorizationSurface("server_action", async () => { + expect(getIssuedAuthorizationCheckCount()).toBe(0); + recordAuthorizationCheckIssued(); + recordAuthorizationCheckIssued(); + expect(getIssuedAuthorizationCheckCount()).toBe(2); + }); + }); + + test("recording outside a surface is a no-op", () => { + expect(getIssuedAuthorizationCheckCount()).toBeNull(); + expect(() => recordAuthorizationCheckIssued()).not.toThrow(); + expect(getIssuedAuthorizationCheckCount()).toBeNull(); + }); + + test("keeps concurrent surfaces' counts independent", async () => { + const counts = await Promise.all([ + withAuthorizationSurface("api_v1", async () => { + recordAuthorizationCheckIssued(); + await Promise.resolve(); + recordAuthorizationCheckIssued(); + return getIssuedAuthorizationCheckCount(); + }), + withAuthorizationSurface("mcp", async () => { + recordAuthorizationCheckIssued(); + await Promise.resolve(); + return getIssuedAuthorizationCheckCount(); + }), + ]); + + expect(counts).toEqual([2, 1]); + }); + + test("reports the request total after the response", async () => { + await withAuthorizationSurface("api_v3", async () => { + recordAuthorizationCheckIssued(); + recordAuthorizationCheckIssued(); + }); + + expect(recordAuthorizationChecksPerRequest).not.toHaveBeenCalled(); + await afterCallbacks[0](); + expect(recordAuthorizationChecksPerRequest).toHaveBeenCalledExactlyOnceWith(2, "api_v3"); + }); + + test("records zero for a request with no authorization decision", async () => { + await withAuthorizationSurface("server_action", async () => undefined); + + await afterCallbacks[0](); + expect(recordAuthorizationChecksPerRequest).toHaveBeenCalledExactlyOnceWith(0, "server_action"); + }); + + test("a telemetry failure does not affect the request", async () => { + vi.mocked(recordAuthorizationChecksPerRequest).mockImplementationOnce(() => { + throw new Error("meter provider exploded"); + }); + + await withAuthorizationSurface("server_action", async () => undefined); + expect(() => afterCallbacks[0]()).not.toThrow(); + }); +}); diff --git a/apps/web/lib/authorization/context.ts b/apps/web/lib/authorization/context.ts new file mode 100644 index 000000000000..23be88085ab7 --- /dev/null +++ b/apps/web/lib/authorization/context.ts @@ -0,0 +1,173 @@ +import "server-only"; +import { after } from "next/server"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { cache as reactCache } from "react"; +import { recordAuthorizationChecksPerRequest } from "./metrics"; + +/** + * `page` is the server-rendered route surface (React Server Components), added for ENG-2388. + * + * Unlike every other surface, it is not established at a single request boundary: Next.js gives no RSC + * equivalent of the action-client or API wrapper, and a layout's render and its page's render are + * separate async contexts. It is therefore opened at the authorization choke points every product + * route already funnels through, and opening it more than once is idempotent rather than nesting. + * + * **`page` is request-scoped; every other surface is callback-scoped.** That difference is ENG-2444. + * `AsyncLocalStorage` closes when the awaited callback returns, so the surface used to end with the + * choke-point helper: a page that awaited `getWorkspaceAuth()` and then authorized anything else did + * so with no surface, and those decisions were labelled `unscoped` in the authoritative decision + * telemetry the direct-authority rollout is monitored on. Nine routes were affected, two of them + * issuing one such check *per feedback directory* or *per dashboard widget*. + * + * `page` is now held in a React `cache()` slot, which is scoped to the whole render pass, so a layout + * and its page share one context and it outlives every helper. Two consequences, both improvements on + * what this comment used to record: + * + * A navigation records ONE checks-per-request observation rather than one per choke point, which is + * the N+1 signal that histogram exists for. + * + * `getAuthorizationSurface()` reports `page` for the whole render instead of `unscoped` after the first + * helper returns, so `formbricks_authzed_authorization_decisions_total` attributes page traffic + * correctly. + * + * Outside a React request scope — scripts, unit tests, any non-RSC caller — `cache()` does not + * memoize, so there is no slot to hold. `page` then falls back to the `AsyncLocalStorage` boundary, + * which is the pre-ENG-2444 behaviour: narrower than a render scope, but correct for a caller with no + * render to scope to. + */ +export type TAuthorizationSurface = + | "server_action" + | "page" + | "api_v1" + | "api_v2" + | "api_v3" + | "mcp" + | "feedback_gateway"; + +type TAuthorizationContext = { + checksIssued: number; + surface: TAuthorizationSurface; +}; + +type TPageSurfaceSlot = { context: TAuthorizationContext | null }; + +const globalForAuthorization = globalThis as unknown as { + formbricksAuthorizationContext: AsyncLocalStorage | undefined; + formbricksAuthorizationPageSurfaceSlot: (() => TPageSurfaceSlot) | undefined; +}; + +const authorizationContext = + globalForAuthorization.formbricksAuthorizationContext ?? new AsyncLocalStorage(); + +globalForAuthorization.formbricksAuthorizationContext = authorizationContext; + +/** + * One slot per React request scope. In an RSC render that is the whole render pass, so a layout and + * its page resolve the same slot — which is what lets the `page` surface outlive the choke-point + * helper that opened it. + * + * `reactCache` is already this codebase's memoization idiom (resolvers.ts, and both choke-point + * modules); here it is used for its scope rather than to cache a value. The wrapper itself is pinned + * to `globalThis` so duplicated Next.js server bundles still use the same React cache key. + */ +const getPageSurfaceSlot = + globalForAuthorization.formbricksAuthorizationPageSurfaceSlot ?? + reactCache((): TPageSurfaceSlot => ({ context: null })); + +globalForAuthorization.formbricksAuthorizationPageSurfaceSlot = getPageSurfaceSlot; + +/** + * Whether React is holding a request scope we can hang the `page` surface on. + * + * `cache()` only memoizes inside one — outside it (scripts, unit tests, the non-RSC bundle, where the + * client build's `cache` is a permanent no-op) every call returns a fresh object, so identity is a + * direct, dependency-free probe. Deliberately not named "is rendering": Next also establishes a scope + * outside component rendering, and `page` is only ever *selected* by the caller — the ALS store is + * consulted first, so an enclosing server-action or API surface always keeps precedence. + */ +const hasReactRequestScope = (slot: TPageSurfaceSlot): boolean => slot === getPageSurfaceSlot(); + +const createSurfaceContext = (surface: TAuthorizationSurface): TAuthorizationContext => ({ + checksIssued: 0, + surface, +}); + +/** + * Register the one post-response observation for a surface. Shared by both boundaries so they cannot + * drift, and fail-safe: an unavailable `after()` costs the histogram observation, never the decision. + */ +const scheduleChecksPerRequestObservation = (context: TAuthorizationContext): void => { + try { + after(() => { + try { + recordAuthorizationChecksPerRequest(context.checksIssued, context.surface); + } catch { + // Telemetry must never alter an authoritative response. + } + }); + } catch { + // A wrapper can be invoked outside a Next.js request in scripts/tests — `after()` is unavailable + // there. The authorization decision remains authoritative; only the request histogram observation + // is omitted. + } +}; + +/** The surface answering for the current caller: an explicit ALS boundary first, else the page slot. */ +const getActiveContext = (): TAuthorizationContext | null => + authorizationContext.getStore() ?? getPageSurfaceSlot().context; + +export const withAuthorizationSurface = async ( + surface: TAuthorizationSurface, + callback: () => T | Promise +): Promise => { + if (authorizationContext.getStore()) { + return callback(); + } + + const pageSlot = getPageSurfaceSlot(); + if (hasReactRequestScope(pageSlot) && pageSlot.context) { + // A request has one outer surface. Before `page` moved from ALS to React cache, a nested wrapper + // inherited the page context through the early return above. Preserve that behavior so one render + // cannot split its check count across multiple telemetry observations. + return callback(); + } + + if (surface === "page") { + if (hasReactRequestScope(pageSlot)) { + // Opened once per render, then left open: every later check in the same render — including the + // ones a page issues long after this helper returned — resolves through this slot. + if (!pageSlot.context) { + pageSlot.context = createSurfaceContext(surface); + scheduleChecksPerRequestObservation(pageSlot.context); + } + return callback(); + } + // No render to scope to: fall through to the async-scoped boundary, which is what this surface did + // before ENG-2444. Narrower, but correct for a caller with no request scope. + } + + const context = createSurfaceContext(surface); + + return authorizationContext.run(context, async () => { + scheduleChecksPerRequestObservation(context); + return callback(); + }); +}; + +/** + * Record that one central authorization operation was made. Scalar `can()`/`assertCan()` decisions and + * narrow list observers each call this exactly once regardless of how much source data they process. + * A no-op outside a surface — same fail-safe posture as the rest of this module — so scripts and tests + * that never establish a surface are simply not counted rather than throwing. + */ +export const recordAuthorizationCheckIssued = (): void => { + const context = getActiveContext(); + if (context) context.checksIssued += 1; +}; + +/** The number of central authorization operations in the current surface, or `null` outside one. */ +export const getIssuedAuthorizationCheckCount = (): number | null => getActiveContext()?.checksIssued ?? null; + +/** The current bounded request surface, or `unscoped` for scripts and non-request authorization calls. */ +export const getAuthorizationSurface = (): TAuthorizationSurface | "unscoped" => + getActiveContext()?.surface ?? "unscoped"; diff --git a/apps/web/lib/authorization/contract.test.ts b/apps/web/lib/authorization/contract.test.ts new file mode 100644 index 000000000000..e38fdab0da71 --- /dev/null +++ b/apps/web/lib/authorization/contract.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { AUTHORIZATION_PERMISSION_MAP } from "./contract"; + +describe("current authorization vocabulary", () => { + test("contains exactly the current resource permissions", () => { + expect(AUTHORIZATION_PERMISSION_MAP).toEqual({ + apiKey: ["read", "manage"], + organization: [ + "read", + "write", + "manage", + "manage_billing", + "read_access", + "manage_access", + "manage_api_keys", + ], + team: ["read", "manage", "delete"], + workspace: ["read", "write", "manage", "share"], + survey: ["read", "write", "manage", "delete", "publish", "response_read", "response_export"], + dashboard: ["read", "write"], + feedbackDirectory: ["read", "write", "manage"], + feedbackDirectoryAssignment: ["read", "write", "manage"], + response: ["read", "write", "manage", "export"], + }); + }); + + test("contains 35 actions and no deferred capabilities", () => { + const actions = Object.entries(AUTHORIZATION_PERMISSION_MAP).flatMap(([resourceType, permissions]) => + permissions.map((permission) => `${resourceType}.${permission}`) + ); + + expect(actions).toHaveLength(35); + expect(actions).not.toContain("survey.share"); + expect(actions).not.toContain("dashboard.manage"); + expect(actions).not.toContain("auditLog.read"); + }); +}); diff --git a/apps/web/lib/authorization/contract.ts b/apps/web/lib/authorization/contract.ts new file mode 100644 index 000000000000..8255730838d3 --- /dev/null +++ b/apps/web/lib/authorization/contract.ts @@ -0,0 +1,73 @@ +import "server-only"; + +/** + * Current Formbricks authorization vocabulary. + * + * This map is the application source of truth for valid resource/permission + * combinations. Keep it independent from AuthZed SDK and schema types. + * + * @internal Import the public types from "@/lib/authorization". + */ +export const AUTHORIZATION_PERMISSION_MAP = { + apiKey: ["read", "manage"], + organization: [ + "read", + "write", + "manage", + "manage_billing", + "read_access", + "manage_access", + "manage_api_keys", + ], + team: ["read", "manage", "delete"], + workspace: ["read", "write", "manage", "share"], + survey: ["read", "write", "manage", "delete", "publish", "response_read", "response_export"], + dashboard: ["read", "write"], + feedbackDirectory: ["read", "write", "manage"], + feedbackDirectoryAssignment: ["read", "write", "manage"], + response: ["read", "write", "manage", "export"], +} as const satisfies Readonly>; + +type TAuthorizationPermissionMap = { + readonly [TResourceType in keyof typeof AUTHORIZATION_PERMISSION_MAP]: (typeof AUTHORIZATION_PERMISSION_MAP)[TResourceType][number]; +}; + +export type TAuthorizationResourceType = keyof TAuthorizationPermissionMap; + +export type TAuthorizationActor = + | Readonly<{ + type: "user"; + id: string; + }> + | Readonly<{ + type: "apiKey"; + id: string; + }>; + +type TAuthorizationResourceOfType = + TResourceType extends "feedbackDirectoryAssignment" + ? Readonly<{ + type: TResourceType; + feedbackDirectoryId: string; + workspaceId: string; + }> + : TResourceType extends TAuthorizationResourceType + ? Readonly<{ + type: TResourceType; + id: string; + }> + : never; + +export type TAuthorizationResource = TAuthorizationResourceOfType; + +export type TAuthorizationAction = { + [TResourceType in TAuthorizationResourceType]: `${TResourceType}.${TAuthorizationPermissionMap[TResourceType]}`; +}[TAuthorizationResourceType]; + +type TAuthorizationResourceTypeForAction = + TAction extends `${infer TResourceType extends TAuthorizationResourceType}.${string}` + ? TResourceType + : never; + +export type TAuthorizationResourceForAction = + TAuthorizationResourceOfType>; diff --git a/apps/web/lib/authorization/contract.typecheck.test.ts b/apps/web/lib/authorization/contract.typecheck.test.ts new file mode 100644 index 000000000000..57127042f72d --- /dev/null +++ b/apps/web/lib/authorization/contract.typecheck.test.ts @@ -0,0 +1,200 @@ +import "server-only"; +import { describe, expect, expectTypeOf, test } from "vitest"; +import type { + TAuthorizationAction, + TAuthorizationActor, + TAuthorizationResource, + TAuthorizationResourceForAction, +} from "./contract"; + +type TEqual = + (() => T extends TLeft ? 1 : 2) extends () => T extends TRight ? 1 : 2 + ? (() => T extends TRight ? 1 : 2) extends () => T extends TLeft ? 1 : 2 + ? true + : false + : false; + +type TExpect = TValue; + +type TExpectedAuthorizationAction = + | "apiKey.read" + | "apiKey.manage" + | "organization.read" + | "organization.write" + | "organization.manage" + | "organization.manage_billing" + | "organization.read_access" + | "organization.manage_access" + | "organization.manage_api_keys" + | "team.read" + | "team.manage" + | "team.delete" + | "workspace.read" + | "workspace.write" + | "workspace.manage" + | "workspace.share" + | "survey.read" + | "survey.write" + | "survey.manage" + | "survey.delete" + | "survey.publish" + | "survey.response_read" + | "survey.response_export" + | "dashboard.read" + | "dashboard.write" + | "feedbackDirectory.read" + | "feedbackDirectory.write" + | "feedbackDirectory.manage" + | "feedbackDirectoryAssignment.read" + | "feedbackDirectoryAssignment.write" + | "feedbackDirectoryAssignment.manage" + | "response.read" + | "response.write" + | "response.manage" + | "response.export"; + +type TActionVocabularyIsExact = TExpect>; + +type TExpectedResourceForAction = + TAction extends `feedbackDirectoryAssignment.${string}` + ? Readonly<{ + type: "feedbackDirectoryAssignment"; + feedbackDirectoryId: string; + workspaceId: string; + }> + : TAction extends `${infer TResourceType}.${string}` + ? Readonly<{ + type: TResourceType; + id: string; + }> + : never; + +type TAllActionResourceMappingsAreExact = TExpect< + { + [TAction in TExpectedAuthorizationAction]: TEqual< + TAuthorizationResourceForAction, + TExpectedResourceForAction + >; + }[TExpectedAuthorizationAction] extends true + ? true + : false +>; + +const actionVocabularyIsExact: TActionVocabularyIsExact = true; +const allActionResourceMappingsAreExact: TAllActionResourceMappingsAreExact = true; + +const checkAuthorizationTypes = ( + _actor: TAuthorizationActor, + _action: TAction, + _resource: TAuthorizationResourceForAction> +): void => undefined; + +describe("current authorization contract types", () => { + test("keeps the action vocabulary and resource mappings exact", () => { + expectTypeOf(actionVocabularyIsExact).toEqualTypeOf(); + expectTypeOf(allActionResourceMappingsAreExact).toEqualTypeOf(); + }); + + test("accepts current actors and matching action/resource pairs", () => { + expect( + checkAuthorizationTypes({ type: "user", id: "user-id" }, "survey.read", { + type: "survey", + id: "survey-id", + }) + ).toBeUndefined(); + expect( + checkAuthorizationTypes({ type: "apiKey", id: "api-key-id" }, "workspace.write", { + type: "workspace", + id: "workspace-id", + }) + ).toBeUndefined(); + expect( + checkAuthorizationTypes({ type: "user", id: "user-id" }, "feedbackDirectoryAssignment.read", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: "directory-id", + workspaceId: "workspace-id", + }) + ).toBeUndefined(); + }); + + test("rejects unsupported actors, actions, and resource combinations", () => { + const actor: TAuthorizationActor = { type: "user", id: "user-id" }; + const resource: TAuthorizationResource = { type: "survey", id: "survey-id" }; + const systemActor: TAuthorizationActor = { + // @ts-expect-error System principals are not part of the current contract. + type: "system", + id: "system-id", + }; + + const systemActorResult = checkAuthorizationTypes(systemActor, "survey.read", resource); + + // @ts-expect-error Survey-level sharing is a deferred capability. + const surveyShareResult = checkAuthorizationTypes(actor, "survey.share", resource); + + const dashboardManageResult = checkAuthorizationTypes( + actor, + // @ts-expect-error Per-dashboard management is a deferred capability. + "dashboard.manage", + { + type: "dashboard", + id: "dashboard-id", + } + ); + + const auditLogReadResult = checkAuthorizationTypes( + actor, + // @ts-expect-error Audit-log access is not part of the current contract. + "auditLog.read", + { + type: "organization", + id: "organization-id", + } + ); + + const mismatchedResourceResult = checkAuthorizationTypes(actor, "survey.read", { + // @ts-expect-error A survey action cannot target a workspace. + type: "workspace", + id: "workspace-id", + }); + + const assignmentWithoutWorkspaceResult = checkAuthorizationTypes( + actor, + "feedbackDirectoryAssignment.read", + { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: "directory-id", + // @ts-expect-error Exact assignment checks require the workspace scope. + workspaceId: undefined, + } + ); + + expect(systemActorResult).toBeUndefined(); + expect(surveyShareResult).toBeUndefined(); + expect(dashboardManageResult).toBeUndefined(); + expect(auditLogReadResult).toBeUndefined(); + expect(mismatchedResourceResult).toBeUndefined(); + expect(assignmentWithoutWorkspaceResult).toBeUndefined(); + }); + + test("requires immutable actors and resources with opaque identifiers", () => { + // @ts-expect-error Actors require an opaque identifier. + const actorWithoutId: TAuthorizationActor = { type: "user" }; + + // @ts-expect-error Resources require an opaque identifier. + const resourceWithoutId: TAuthorizationResource = { type: "response" }; + + const actor: TAuthorizationActor = { type: "user", id: "user-id" }; + const resource: TAuthorizationResource = { type: "response", id: "response-id" }; + + // @ts-expect-error Authorization actors are immutable. + actor.id = "other-user-id"; + + // @ts-expect-error Authorization resources are immutable. + resource.id = "other-response-id"; + + expect(actorWithoutId).toEqual({ type: "user" }); + expect(resourceWithoutId).toEqual({ type: "response" }); + expect(actor.id).toBe("other-user-id"); + expect(resource.id).toBe("other-response-id"); + }); +}); diff --git a/apps/web/lib/authorization/coordinator.test.ts b/apps/web/lib/authorization/coordinator.test.ts new file mode 100644 index 000000000000..9bcd5e69eddc --- /dev/null +++ b/apps/web/lib/authorization/coordinator.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "@/lib/authzed/errors"; +import { getAuthorizationSurface } from "./context"; +import { authorizationCoordinator } from "./coordinator"; +import { recordAuthorizationDecision } from "./metrics"; +import { spicedbEvaluator } from "./spicedb-evaluator"; + +vi.mock("./context", () => ({ getAuthorizationSurface: vi.fn(() => "unscoped") })); +vi.mock("./metrics", () => ({ recordAuthorizationDecision: vi.fn() })); +vi.mock("./spicedb-evaluator", () => ({ spicedbEvaluator: { can: vi.fn() } })); + +const actor = { type: "user", id: "user-1" } as const; +const resource = { type: "survey", id: "survey-1" } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("authorizationCoordinator", () => { + test("uses SpiceDB for an unscoped central authorization call", async () => { + vi.mocked(spicedbEvaluator.can).mockResolvedValue(true); + + await expect(authorizationCoordinator.can(actor, "survey.read", resource)).resolves.toBe(true); + + expect(spicedbEvaluator.can).toHaveBeenCalledExactlyOnceWith(actor, "survey.read", resource); + expect(recordAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + action: "survey.read", + actorType: "user", + outcome: "allow", + resourceType: "survey", + surface: "unscoped", + }) + ); + }); + + test("returns a genuine SpiceDB denial", async () => { + vi.mocked(spicedbEvaluator.can).mockResolvedValue(false); + + await expect(authorizationCoordinator.can(actor, "survey.read", resource)).resolves.toBe(false); + expect(recordAuthorizationDecision).toHaveBeenCalledWith(expect.objectContaining({ outcome: "deny" })); + }); + + test("preserves stable AuthZed failures without exposing the original error", async () => { + const outage = new AuthzedError({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + grpcStatus: 14, + operation: "check_permission", + retryable: true, + }); + vi.mocked(spicedbEvaluator.can).mockRejectedValue(outage); + + const thrown = await authorizationCoordinator + .can(actor, "survey.read", resource) + .catch((error: unknown) => error); + + expect(thrown).toMatchObject({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + grpcStatus: 14, + operation: "authorization", + retryable: true, + }); + expect(thrown).not.toHaveProperty("cause", outage); + expect(recordAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + errorCode: AUTHZED_ERROR_CODES.UNAVAILABLE, + outcome: "operational_error", + }) + ); + }); + + test("normalizes resolver failures into a fail-closed operational error", async () => { + vi.mocked(spicedbEvaluator.can).mockRejectedValue(new Error("database unavailable")); + + await expect(authorizationCoordinator.can(actor, "survey.read", resource)).rejects.toMatchObject({ + attempts: 1, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "authorization", + retryable: false, + }); + }); + + test("records the active bounded request surface", async () => { + vi.mocked(getAuthorizationSurface).mockReturnValueOnce("api_v3"); + vi.mocked(spicedbEvaluator.can).mockResolvedValue(true); + + await authorizationCoordinator.can(actor, "survey.read", resource); + + expect(recordAuthorizationDecision).toHaveBeenCalledWith(expect.objectContaining({ surface: "api_v3" })); + }); +}); diff --git a/apps/web/lib/authorization/coordinator.ts b/apps/web/lib/authorization/coordinator.ts new file mode 100644 index 000000000000..2e3ee648b9fb --- /dev/null +++ b/apps/web/lib/authorization/coordinator.ts @@ -0,0 +1,51 @@ +import "server-only"; +import { performance } from "node:perf_hooks"; +import { getAuthorizationSurface } from "./context"; +import type { TAuthorizationAction, TAuthorizationActor, TAuthorizationResourceForAction } from "./contract"; +import type { AuthorizationEvaluator } from "./evaluator"; +import { recordAuthorizationDecision } from "./metrics"; +import { normalizeAuthorizationOperationalError } from "./operational-error"; +import { spicedbEvaluator } from "./spicedb-evaluator"; + +/** + * The one authoritative authorization coordinator. + * + * Rollout surfaces and request context are deliberately irrelevant here: every central decision, + * including calls made outside a request boundary, is evaluated by SpiceDB. A missing source actor or + * resource is a genuine denial from `spicedbEvaluator`; configuration, resolver, freshness, and + * transport failures are typed operational failures and therefore fail closed. + */ +export const authorizationCoordinator: AuthorizationEvaluator = { + async can( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction> + ): Promise { + const startedAt = performance.now(); + const metric = { + action, + actorType: actor.type, + resourceType: resource.type, + surface: getAuthorizationSurface(), + } as const; + + try { + const allowed = await spicedbEvaluator.can(actor, action, resource); + recordAuthorizationDecision({ + ...metric, + durationMs: performance.now() - startedAt, + outcome: allowed ? "allow" : "deny", + }); + return allowed; + } catch (error) { + const normalized = normalizeAuthorizationOperationalError(error, "authorization"); + recordAuthorizationDecision({ + ...metric, + durationMs: performance.now() - startedAt, + errorCode: normalized.code, + outcome: "operational_error", + }); + throw normalized; + } + }, +}; diff --git a/apps/web/lib/authorization/evaluator.ts b/apps/web/lib/authorization/evaluator.ts new file mode 100644 index 000000000000..76c913410bd8 --- /dev/null +++ b/apps/web/lib/authorization/evaluator.ts @@ -0,0 +1,18 @@ +import "server-only"; +import type { TAuthorizationAction, TAuthorizationActor, TAuthorizationResourceForAction } from "./contract"; + +/** + * A backend that answers one authorization decision. The runtime implementation + * is SpiceDB-backed while product call sites depend only on this interface. + * + * The action is the sole generic inference source; `NoInfer` on the resource + * prevents TypeScript from widening a mismatched action/resource pair (per the + * contract in `./contract`). + */ +export interface AuthorizationEvaluator { + can( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction> + ): Promise; +} diff --git a/apps/web/lib/authorization/index.test.ts b/apps/web/lib/authorization/index.test.ts new file mode 100644 index 000000000000..4b7847f275fd --- /dev/null +++ b/apps/web/lib/authorization/index.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { assertCan, can } from "."; +import type { TAuthorizationActor, TAuthorizationResource } from "./contract"; +import { authorizationCoordinator } from "./coordinator"; + +// Stub the evaluator so these tests exercise only the public interface + selection point. +vi.mock("./coordinator", () => ({ authorizationCoordinator: { can: vi.fn() } })); + +const ACTOR: TAuthorizationActor = { type: "user", id: "user1" }; +const RESOURCE: TAuthorizationResource = { type: "survey", id: "survey1" }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("authorization public interface", () => { + test("can delegates to the selected evaluator and returns its decision", async () => { + vi.mocked(authorizationCoordinator.can).mockResolvedValue(true); + await expect(can(ACTOR, "survey.read", RESOURCE)).resolves.toBe(true); + expect(authorizationCoordinator.can).toHaveBeenCalledWith(ACTOR, "survey.read", RESOURCE); + }); + + test("assertCan resolves when the evaluator allows", async () => { + vi.mocked(authorizationCoordinator.can).mockResolvedValue(true); + await expect(assertCan(ACTOR, "survey.read", RESOURCE)).resolves.toBeUndefined(); + }); + + test("assertCan throws AuthorizationError when the evaluator denies", async () => { + vi.mocked(authorizationCoordinator.can).mockResolvedValue(false); + await expect(assertCan(ACTOR, "survey.read", RESOURCE)).rejects.toBeInstanceOf(AuthorizationError); + }); + + test("assertCan propagates an evaluator failure without turning it into a denial", async () => { + vi.mocked(authorizationCoordinator.can).mockRejectedValue(new Error("db down")); + await expect(assertCan(ACTOR, "survey.read", RESOURCE)).rejects.toThrow("db down"); + }); +}); diff --git a/apps/web/lib/authorization/index.ts b/apps/web/lib/authorization/index.ts new file mode 100644 index 000000000000..e9b26b940f08 --- /dev/null +++ b/apps/web/lib/authorization/index.ts @@ -0,0 +1,60 @@ +import "server-only"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { recordAuthorizationCheckIssued } from "./context"; +import type { TAuthorizationAction, TAuthorizationActor, TAuthorizationResourceForAction } from "./contract"; +import { authorizationCoordinator } from "./coordinator"; +import type { AuthorizationEvaluator } from "./evaluator"; + +/** + * The single, engine-independent authorization interface for Formbricks (Phase 0 + * of the Authorization & Access Refinement project). + * + * `can` returns a boolean decision; `assertCan` throws an `AuthorizationError` + * on denial. Both evaluate today's authorization rules and change nothing about + * who can access what — they only funnel scattered checks through one boundary. + * SpiceDB is the sole evaluator. Product call sites stay independent of its SDK + * and receive only Formbricks-owned decisions and typed operational failures. + */ + +const evaluator: AuthorizationEvaluator = authorizationCoordinator; + +/** + * Whether `actor` may perform `action` on `resource`. + * + * Counted here, once, ahead of the evaluator call — the ENG-1739 per-request instrumentation. This + * is the one place every `can()` and `assertCan()` call passes through regardless of caller, so it + * is the only place a count taken here is guaranteed not to miss or double-count a decision. + * + * The count increments *before* the evaluator answers, so a thrown error from the evaluator still + * counts as one issued decision — the metric tracks "checks issued", not "checks completed". Both + * are useful; this one answers "how many authorization decisions did this page attempt" and is the + * number that catches an N+1 regression regardless of whether any individual check fails. + */ +export const can = ( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction> +): Promise => { + recordAuthorizationCheckIssued(); + return evaluator.can(actor, action, resource); +}; + +/** Assert that `actor` may perform `action` on `resource`, throwing `AuthorizationError` otherwise. */ +export const assertCan = async ( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction> +): Promise => { + const allowed = await can(actor, action, resource); + if (!allowed) { + throw new AuthorizationError("Not authorized"); + } +}; + +export type { + TAuthorizationAction, + TAuthorizationActor, + TAuthorizationResource, + TAuthorizationResourceForAction, + TAuthorizationResourceType, +} from "./contract"; diff --git a/apps/web/lib/authorization/legacy-removal.test.ts b/apps/web/lib/authorization/legacy-removal.test.ts new file mode 100644 index 000000000000..d760d3ebf4a2 --- /dev/null +++ b/apps/web/lib/authorization/legacy-removal.test.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +const WEB_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const REPOSITORY_ROOT = join(WEB_ROOT, "../.."); +const IGNORED_DIRECTORIES = new Set([ + ".next", + ".turbo", + "coverage", + "dist", + "node_modules", + "playwright-report", +]); + +const walkRuntimeSources = (directory: string): ReadonlyArray => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = join(directory, entry.name); + if (entry.isDirectory()) { + return IGNORED_DIRECTORIES.has(entry.name) ? [] : walkRuntimeSources(absolutePath); + } + if (!/\.(ts|tsx|mjs)$/.test(entry.name) || /\.(test|spec)\.(ts|tsx)$/.test(entry.name)) return []; + return [absolutePath]; + }); + +describe("direct-authority architecture", () => { + test("does not retain a legacy evaluator or rollout selector module", () => { + for (const relativePath of [ + "lib/authorization/legacy-evaluator.ts", + "lib/authorization/legacy-api-key-access.ts", + "lib/authorization/legacy-workspace-access.ts", + "lib/authorization/rollout-config.ts", + "lib/authorization/workspace-list-observer.ts", + "lib/utils/action-client/action-client-middleware.ts", + ]) { + expect(existsSync(join(WEB_ROOT, relativePath)), relativePath).toBe(false); + } + }); + + test("keeps production authorization paths free of deleted compatibility entry points", () => { + const forbiddenSymbols = [ + "checkAuthorizationUpdated", + "hasUserWorkspaceAccessForAction", + "hasApiKeyWorkspaceAccess", + "observeWorkspaceListAuthorization", + ]; + const offenders = walkRuntimeSources(WEB_ROOT).filter((filePath) => { + const source = readFileSync(filePath, "utf8"); + return forbiddenSymbols.some((symbol) => source.includes(symbol)); + }); + + expect(offenders.map((filePath) => filePath.slice(WEB_ROOT.length + 1))).toEqual([]); + }); + + test("does not accept historical shadow or enforcement configuration", () => { + const configSources = [ + readFileSync(join(WEB_ROOT, "lib/env.ts"), "utf8"), + readFileSync(join(WEB_ROOT, "turbo.json"), "utf8"), + readFileSync(join(REPOSITORY_ROOT, "turbo.json"), "utf8"), + ].join("\n"); + const forbiddenVariables = [ + "AUTHZED_AUTHORIZATION_ENABLED", + "AUTHZED_SHADOW_TARGETS", + "AUTHZED_SHADOW_ORGANIZATION_IDS", + "AUTHZED_ENFORCEMENT_TARGETS", + "AUTHZED_ENFORCEMENT_ORGANIZATION_IDS", + "AUTHZED_AUTHORIZATION_COHORT", + "AUTHZED_MINIMUM_SNAPSHOT", + ]; + + for (const variable of forbiddenVariables) { + expect(configSources, variable).not.toContain(variable); + } + }); +}); diff --git a/apps/web/lib/authorization/manage-access.integration.test.ts b/apps/web/lib/authorization/manage-access.integration.test.ts new file mode 100644 index 000000000000..f5ce04d4d337 --- /dev/null +++ b/apps/web/lib/authorization/manage-access.integration.test.ts @@ -0,0 +1,130 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import type { TOrganizationRole } from "@formbricks/types/memberships"; +import { synchronizeAuthzedIntegrationFixture } from "@/integration/authzed"; +import { resetDb } from "@/integration/reset-db"; +import { can } from "@/lib/authorization"; +import { USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants"; +import { getUserManagementAccess } from "@/lib/membership/utils"; + +/** + * ENG-1737 (review follow-up): the two capabilities that reviewer feedback moved onto the central + * interface, decided against real rows. + * + * Both replacements are equivalence claims, so they get the same treatment as the rest of this + * change rather than a mocked assertion of what the code now does: + * + * - `organization.manage_access` must answer exactly what the inline + * `getUserManagementAccess(role, USER_MANAGEMENT_MINIMUM_ROLE)` answered in the membership-update + * action. That helper is the comparison below, so the test tracks the deployment's configured + * floor instead of hardcoding one — an install set to `owner` or `disabled` asserts against its + * own policy. (Varying the constant per case would need module-level env mocking, which the unit + * suite is the right place for; here the point is that the two agree under whatever is set.) + * + * - `team.manage` must answer exactly "is a team admin, or an organization owner/manager", which is + * what `getTeamsWhereUserIsAdmin` plus the owner/manager branch established in the invite action. + */ +const scenario: { + organizationId: string; + otherTeamId: string; + teamId: string; + userIdByLabel: Map; +} = { organizationId: "", otherTeamId: "", teamId: "", userIdByLabel: new Map() }; + +const ORGANIZATION_ROLES: ReadonlyArray = ["owner", "manager", "member", "billing"]; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ data: { name: "Manage Access Org" } }); + const team = await prisma.team.create({ data: { name: "Team A", organizationId: organization.id } }); + const otherTeam = await prisma.team.create({ data: { name: "Team B", organizationId: organization.id } }); + + const makeUser = async ( + label: string, + role: TOrganizationRole | null, + teamMembership?: { role: "admin" | "contributor"; teamId: string } + ) => { + const user = await prisma.user.create({ data: { name: label, email: `${label}@manage.test` } }); + if (role) { + await prisma.membership.create({ + data: { userId: user.id, organizationId: organization.id, role, accepted: true }, + }); + } + if (teamMembership) { + await prisma.teamUser.create({ + data: { teamId: teamMembership.teamId, userId: user.id, role: teamMembership.role }, + }); + } + scenario.userIdByLabel.set(label, user.id); + }; + + for (const role of ORGANIZATION_ROLES) { + await makeUser(role, role); + } + await makeUser("team-admin", "member", { role: "admin", teamId: team.id }); + await makeUser("team-contributor", "member", { role: "contributor", teamId: team.id }); + await makeUser("outsider", null); + + scenario.organizationId = organization.id; + scenario.teamId = team.id; + scenario.otherTeamId = otherTeam.id; + await synchronizeAuthzedIntegrationFixture(); +}, 120_000); + +describe("organization.manage_access against a real database", () => { + test.each(ORGANIZATION_ROLES)( + "matches getUserManagementAccess for the %s role under the configured floor", + async (role) => { + const expected = getUserManagementAccess(role, USER_MANAGEMENT_MINIMUM_ROLE); + + const actual = await can( + { type: "user", id: scenario.userIdByLabel.get(role)! }, + "organization.manage_access", + { type: "organization", id: scenario.organizationId } + ); + + expect(actual).toBe(expected); + } + ); + + test("refuses a user outside the organization regardless of the floor", async () => { + const actual = await can( + { type: "user", id: scenario.userIdByLabel.get("outsider")! }, + "organization.manage_access", + { type: "organization", id: scenario.organizationId } + ); + + expect(actual).toBe(false); + }); +}); + +describe("team.manage against a real database", () => { + test.each([ + ["owner", true], + ["manager", true], + ["team-admin", true], + ["team-contributor", false], + ["member", false], + ["billing", false], + ["outsider", false], + ] as const)("decides %s as %s for a team they may be admin of", async (label, expected) => { + const actual = await can({ type: "user", id: scenario.userIdByLabel.get(label)! }, "team.manage", { + type: "team", + id: scenario.teamId, + }); + + expect(actual).toBe(expected); + }); + + // The reason the invite path asks per requested team rather than once: admin of one team is not + // admin of another, and the invite writes to exactly the teams it names. + test("does not carry a team admin's authority to a sibling team", async () => { + const actual = await can({ type: "user", id: scenario.userIdByLabel.get("team-admin")! }, "team.manage", { + type: "team", + id: scenario.otherTeamId, + }); + + expect(actual).toBe(false); + }); +}); diff --git a/apps/web/lib/authorization/metrics.test.ts b/apps/web/lib/authorization/metrics.test.ts new file mode 100644 index 000000000000..b4465e2166d1 --- /dev/null +++ b/apps/web/lib/authorization/metrics.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const counters = new Map }>(); +const histograms = new Map }>(); + +vi.mock("@opentelemetry/api", () => ({ + metrics: { + getMeter: vi.fn(() => ({ + createCounter: vi.fn((name: string) => { + const instrument = { add: vi.fn() }; + counters.set(name, instrument); + return instrument; + }), + createHistogram: vi.fn((name: string) => { + const instrument = { record: vi.fn() }; + histograms.set(name, instrument); + return instrument; + }), + })), + }, +})); + +const { recordAuthorizationDecision } = await import("./metrics"); + +beforeEach(() => { + for (const instrument of counters.values()) instrument.add.mockClear(); + for (const instrument of histograms.values()) instrument.record.mockClear(); +}); + +describe("authoritative authorization metrics", () => { + test.each(["allow", "deny"] as const)("records a bounded %s decision and latency", (outcome) => { + recordAuthorizationDecision({ + action: "survey.read", + actorType: "user", + durationMs: 125, + outcome, + resourceType: "survey", + surface: "server_action", + }); + + expect(counters.get("formbricks_authzed_authorization_decisions_total")?.add).toHaveBeenCalledWith(1, { + action: "survey.read", + actor_type: "user", + error_code: "none", + outcome, + resource_type: "survey", + surface: "server_action", + }); + expect( + histograms.get("formbricks_authzed_authorization_decision_duration_seconds")?.record + ).toHaveBeenCalledWith(0.125, { + action: "survey.read", + actor_type: "user", + outcome, + resource_type: "survey", + surface: "server_action", + }); + }); + + test("distinguishes an unscoped operational error from a product denial", () => { + recordAuthorizationDecision({ + action: "workspace.read", + actorType: "apiKey", + durationMs: 10, + errorCode: "authzed_unavailable", + outcome: "operational_error", + resourceType: "workspace", + surface: "unscoped", + }); + + expect(counters.get("formbricks_authzed_authorization_decisions_total")?.add).toHaveBeenCalledWith(1, { + action: "workspace.read", + actor_type: "apiKey", + error_code: "authzed_unavailable", + outcome: "operational_error", + resource_type: "workspace", + surface: "unscoped", + }); + }); + + test("never lets a meter failure alter authorization flow", () => { + counters.get("formbricks_authzed_authorization_decisions_total")?.add.mockImplementationOnce(() => { + throw new Error("exporter unavailable"); + }); + + expect(() => + recordAuthorizationDecision({ + action: "organization.read", + actorType: "user", + durationMs: 1, + outcome: "allow", + resourceType: "organization", + surface: "page", + }) + ).not.toThrow(); + }); +}); diff --git a/apps/web/lib/authorization/metrics.ts b/apps/web/lib/authorization/metrics.ts new file mode 100644 index 000000000000..a81ea4341c08 --- /dev/null +++ b/apps/web/lib/authorization/metrics.ts @@ -0,0 +1,92 @@ +import "server-only"; +import { metrics } from "@opentelemetry/api"; +import type { TAuthzedErrorCode } from "@/lib/authzed/errors"; +import type { TAuthorizationSurface } from "./context"; +import type { TAuthorizationAction, TAuthorizationActor, TAuthorizationResourceType } from "./contract"; + +const meter = metrics.getMeter("formbricks.authzed.authorization"); + +const decisionsTotal = meter.createCounter("formbricks_authzed_authorization_decisions_total", { + description: "Authoritative SpiceDB authorization decisions by bounded outcome", +}); + +const authorizationDuration = meter.createHistogram( + "formbricks_authzed_authorization_decision_duration_seconds", + { + advice: { + explicitBucketBoundaries: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5], + }, + description: "Duration of authoritative SpiceDB authorization operations", + unit: "s", + } +); + +export type TAuthorizationDecisionOutcome = "allow" | "deny" | "operational_error"; + +type TAuthorizationDecisionMetricContext = Readonly<{ + action: TAuthorizationAction; + actorType: TAuthorizationActor["type"]; + durationMs: number; + resourceType: TAuthorizationResourceType; + surface: TAuthorizationSurface | "unscoped"; +}>; + +export type TAuthorizationDecisionMetric = TAuthorizationDecisionMetricContext & + ( + | Readonly<{ errorCode?: never; outcome: Exclude }> + | Readonly<{ errorCode: TAuthzedErrorCode; outcome: "operational_error" }> + ); + +export const recordAuthorizationDecision = (metric: TAuthorizationDecisionMetric): void => { + try { + const attributes = { + action: metric.action, + actor_type: metric.actorType, + error_code: metric.errorCode ?? "none", + outcome: metric.outcome, + resource_type: metric.resourceType, + surface: metric.surface, + }; + + decisionsTotal.add(1, attributes); + authorizationDuration.record(Math.max(0, metric.durationMs) / 1_000, { + action: metric.action, + actor_type: metric.actorType, + outcome: metric.outcome, + resource_type: metric.resourceType, + surface: metric.surface, + }); + } catch { + // Telemetry must never alter an authoritative decision or turn an instrumentation outage into a + // protected-operation outage. + } +}; + +/** + * ENG-1739: how many central authorization operations one request made. + * + * The perf harness times a single decision; it cannot see whether a page issues one decision or one + * per row. A workspace-scoped list path that authorizes once still reports "fast" under that harness + * even if a regression made it authorize per row, so this is the metric that catches an N+1. + * + * The lowest boundary is 0.5, not 1, and that is load-bearing. OpenTelemetry histogram buckets are + * upper-inclusive and lower-exclusive, so boundaries starting at 1 put both 0 and 1 in `(-inf, 1]`. + * Every wrapped request records — including the many that never authorize anything — so without the + * 0.5 split the healthy single-check case is indistinguishable from "no authorization happened" on + * the one histogram meant to make amplification visible. + */ +const checksPerRequest = meter.createHistogram("formbricks_authzed_authorization_checks_per_request", { + advice: { + explicitBucketBoundaries: [0.5, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 250, 350, 500, 750, 1000], + }, + description: "Number of central authorization decisions made while handling one request", + unit: "{check}", +}); + +/** Record one request's total central-operation count, tagged by the surface that served it. */ +export const recordAuthorizationChecksPerRequest = ( + checksIssued: number, + surface: TAuthorizationSurface +): void => { + checksPerRequest.record(checksIssued, { surface }); +}; diff --git a/apps/web/lib/authorization/object-type.test.ts b/apps/web/lib/authorization/object-type.test.ts new file mode 100644 index 000000000000..52081e3b1b19 --- /dev/null +++ b/apps/web/lib/authorization/object-type.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "vitest"; +import { getSpicedbObjectType } from "./object-type"; + +describe("getSpicedbObjectType", () => { + test.each([ + ["apiKey", "api_key"], + ["feedbackDirectory", "feedback_directory"], + ["feedbackDirectoryAssignment", "feedback_directory_assignment"], + ["user", "user"], + ["workspace", "workspace"], + ] as const)("maps %s to %s", (type, expected) => { + expect(getSpicedbObjectType(type)).toBe(expected); + }); +}); diff --git a/apps/web/lib/authorization/object-type.ts b/apps/web/lib/authorization/object-type.ts new file mode 100644 index 000000000000..a25b783374af --- /dev/null +++ b/apps/web/lib/authorization/object-type.ts @@ -0,0 +1,14 @@ +import "server-only"; +import type { TAuthorizationActor, TAuthorizationResourceType } from "./contract"; + +type TAuthorizationObjectType = TAuthorizationActor["type"] | TAuthorizationResourceType; + +const SPICEDB_OBJECT_TYPE_MAP = { + apiKey: "api_key", + feedbackDirectory: "feedback_directory", + feedbackDirectoryAssignment: "feedback_directory_assignment", +} as const satisfies Partial>; + +/** Map Formbricks-owned authorization names to their SpiceDB schema definitions. */ +export const getSpicedbObjectType = (type: TAuthorizationObjectType): string => + SPICEDB_OBJECT_TYPE_MAP[type as keyof typeof SPICEDB_OBJECT_TYPE_MAP] ?? type; diff --git a/apps/web/lib/authorization/operational-error.test.ts b/apps/web/lib/authorization/operational-error.test.ts new file mode 100644 index 000000000000..b5823920f3c9 --- /dev/null +++ b/apps/web/lib/authorization/operational-error.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "@/lib/authzed/errors"; +import { normalizeAuthorizationOperationalError } from "./operational-error"; + +describe("authorization operational error normalization", () => { + test("preserves stable AuthZed fields while replacing the operation", () => { + const source = new AuthzedError({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + grpcStatus: 14, + operation: "lookup_resources", + retryable: true, + }); + + expect(normalizeAuthorizationOperationalError(source, "authorization")).toMatchObject({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + grpcStatus: 14, + operation: "authorization", + retryable: true, + }); + }); + + test("sanitizes unexpected failures as non-retryable internal errors", () => { + const normalized = normalizeAuthorizationOperationalError( + new Error("private raw message"), + "authorization" + ); + + expect(normalized).toMatchObject({ + attempts: 1, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "authorization", + retryable: false, + }); + expect(normalized.message).not.toContain("private raw message"); + expect(normalized.stack ?? "").not.toContain("private raw message"); + }); +}); diff --git a/apps/web/lib/authorization/operational-error.ts b/apps/web/lib/authorization/operational-error.ts new file mode 100644 index 000000000000..d9713b333c44 --- /dev/null +++ b/apps/web/lib/authorization/operational-error.ts @@ -0,0 +1,21 @@ +import "server-only"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "@/lib/authzed/errors"; + +export const normalizeAuthorizationOperationalError = (error: unknown, operation: string): AuthzedError => { + if (error instanceof AuthzedError) { + return new AuthzedError({ + attempts: error.attempts, + code: error.code, + grpcStatus: error.grpcStatus, + operation, + retryable: error.retryable, + }); + } + + return new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation, + retryable: false, + }); +}; diff --git a/apps/web/lib/authorization/org-page-gates.integration.test.ts b/apps/web/lib/authorization/org-page-gates.integration.test.ts new file mode 100644 index 000000000000..ccae49abea97 --- /dev/null +++ b/apps/web/lib/authorization/org-page-gates.integration.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import type { TOrganizationRole } from "@formbricks/types/memberships"; +import { synchronizeAuthzedIntegrationFixture } from "@/integration/authzed"; +import { resetDb } from "@/integration/reset-db"; +import { can } from "@/lib/authorization"; +import { getIssuedAuthorizationCheckCount, withAuthorizationSurface } from "@/lib/authorization/context"; + +/** + * ENG-2409 — the equivalence claims behind the organization page gates, decided against real rows. + * + * Every gate this ticket moved is an assertion that some `organization.*` permission selects exactly + * the roles a flag test used to select. Those claims are proven three ways, and this is the one that + * exercises the real evaluator end to end rather than a mock or the schema in isolation: + * + * - `authzed/schema-validation.yaml` proves the SpiceDB side (and now proves the billing exclusion + * of `read_access`, which had no assertFalse at all before this ticket). + * - the unit tests prove each call site asks the right question. + * - this file proves the sole SpiceDB evaluator answers the same way for real membership rows + * projected from PostgreSQL. + * + * The mapping under test, all for a session user: + * organization.read = owner + manager + member + billing (the getOrganizationAuth gate) + * organization.read_access = owner + manager + member (the billing-role redirect) + * organization.manage_billing = owner + manager + billing (the enterprise page) + * organization.manage = owner + manager (feedback directories) + * organization.manage_api_keys= owner + manager (API keys) + */ +const scenario: { organizationId: string; userIdByRole: Map; outsiderId: string } = + { organizationId: "", userIdByRole: new Map(), outsiderId: "" }; + +const ORGANIZATION_ROLES: ReadonlyArray = ["owner", "manager", "member", "billing"]; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ data: { name: "Org Page Gates" } }); + + for (const role of ORGANIZATION_ROLES) { + const user = await prisma.user.create({ data: { name: role, email: `${role}@org-gates.test` } }); + await prisma.membership.create({ + data: { userId: user.id, organizationId: organization.id, role, accepted: true }, + }); + scenario.userIdByRole.set(role, user.id); + } + + const outsider = await prisma.user.create({ + data: { name: "outsider", email: "outsider@org-gates.test" }, + }); + + scenario.organizationId = organization.id; + scenario.outsiderId = outsider.id; + await synchronizeAuthzedIntegrationFixture(); +}, 120_000); + +const check = async (userId: string, action: Parameters[1]) => + withAuthorizationSurface("page", () => + can({ type: "user", id: userId }, action, { type: "organization", id: scenario.organizationId }) + ); + +describe("organization page gates, against a real database", () => { + // Each row is "this permission admits exactly these roles". Stated as the full four-role partition + // rather than only the allowed set, so a widening fails here and not only in the schema suite. + const GATES = [ + { action: "organization.read", allowed: ["owner", "manager", "member", "billing"] }, + { action: "organization.read_access", allowed: ["owner", "manager", "member"] }, + { action: "organization.manage_billing", allowed: ["owner", "manager", "billing"] }, + { action: "organization.manage", allowed: ["owner", "manager"] }, + { action: "organization.manage_api_keys", allowed: ["owner", "manager"] }, + ] as const; + + test.each(GATES)("$action admits exactly $allowed", async ({ action, allowed }) => { + const decisions = await Promise.all( + ORGANIZATION_ROLES.map(async (role) => [role, await check(scenario.userIdByRole.get(role)!, action)]) + ); + + expect(Object.fromEntries(decisions)).toEqual( + Object.fromEntries(ORGANIZATION_ROLES.map((role) => [role, allowed.includes(role as never)])) + ); + }); + + test.each(GATES)("$action refuses a user with no membership in the organization", async ({ action }) => { + expect(await check(scenario.outsiderId, action)).toBe(false); + }); + + test("a page's gates cost one check each, and do not scale with anything", async () => { + // The api-keys page is the worst case in this ticket: the shared billing redirect runs one check + // and the page's own gate runs another. Pinned so a later change that moves a check inside a loop + // over workspaces or API keys shows up here rather than in production latency. + const issued = await withAuthorizationSurface("page", async () => { + const actor = { type: "user", id: scenario.userIdByRole.get("owner")! } as const; + const organization = { type: "organization", id: scenario.organizationId } as const; + + await can(actor, "organization.read_access", organization); + await can(actor, "organization.manage_api_keys", organization); + + return getIssuedAuthorizationCheckCount(); + }); + + expect(issued).toBe(2); + }); +}); diff --git a/apps/web/lib/authorization/permission-action.test.ts b/apps/web/lib/authorization/permission-action.test.ts new file mode 100644 index 000000000000..955548c7110e --- /dev/null +++ b/apps/web/lib/authorization/permission-action.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { OrganizationAccessType } from "@formbricks/types/api-key"; +import { + getFeedbackDirectoryAssignmentAuthorizationAction, + getFeedbackDirectoryAuthorizationAction, + getOrganizationAuthorizationActionForAccessType, + getWorkspaceAuthorizationAction, + getWorkspaceAuthorizationActionForMethod, +} from "./permission-action"; + +describe("semantic authorization action mapping", () => { + test.each([ + [undefined, "workspace.read"], + ["read", "workspace.read"], + ["readWrite", "workspace.write"], + ["manage", "workspace.manage"], + ] as const)("maps workspace permission %s", (permission, action) => { + expect(getWorkspaceAuthorizationAction(permission)).toBe(action); + }); + + test.each([ + ["GET", "workspace.read"], + ["POST", "workspace.write"], + ["PUT", "workspace.write"], + ["PATCH", "workspace.write"], + ["DELETE", "workspace.manage"], + ] as const)("maps HTTP %s", (method, action) => { + expect(getWorkspaceAuthorizationActionForMethod(method)).toBe(action); + }); + + test.each([ + [OrganizationAccessType.Read, "organization.read_access"], + [OrganizationAccessType.Write, "organization.manage_access"], + ] as const)("maps organization access %s", (accessType, action) => { + expect(getOrganizationAuthorizationActionForAccessType(accessType)).toBe(action); + }); + + test.each([ + ["read", "feedbackDirectory.read"], + ["write", "feedbackDirectory.write"], + ["manage", "feedbackDirectory.manage"], + ] as const)("maps directory %s to the central vocabulary", (permission, action) => { + expect(getFeedbackDirectoryAuthorizationAction(permission)).toBe(action); + }); + + test.each([ + [undefined, "feedbackDirectoryAssignment.read"], + ["read", "feedbackDirectoryAssignment.read"], + ["readWrite", "feedbackDirectoryAssignment.write"], + ["manage", "feedbackDirectoryAssignment.manage"], + ] as const)("maps assignment %s to the central vocabulary", (permission, action) => { + expect(getFeedbackDirectoryAssignmentAuthorizationAction(permission)).toBe(action); + }); +}); diff --git a/apps/web/lib/authorization/permission-action.ts b/apps/web/lib/authorization/permission-action.ts new file mode 100644 index 000000000000..229fae5e3a44 --- /dev/null +++ b/apps/web/lib/authorization/permission-action.ts @@ -0,0 +1,51 @@ +import "server-only"; +import { OrganizationAccessType } from "@formbricks/types/api-key"; +import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; +import type { TAuthorizationAction } from "./contract"; + +type TWorkspaceAction = Extract; +type TFeedbackDirectoryAction = Extract; +type TFeedbackDirectoryAssignmentAction = Extract< + TAuthorizationAction, + `feedbackDirectoryAssignment.${string}` +>; + +export type TFeedbackDirectoryPermission = "read" | "write" | "manage"; +export type TAuthorizationHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +/** + * Maps the current WorkspaceTeam permission ladder to the central vocabulary. + * + * This is intentionally not exported from the public authorization barrel. It translates persisted + * source permission enums into the engine-independent authorization vocabulary at service boundaries. + */ +export const getWorkspaceAuthorizationAction = (minPermission?: TTeamPermission): TWorkspaceAction => { + if (minPermission === "manage") return "workspace.manage"; + if (minPermission === "readWrite") return "workspace.write"; + return "workspace.read"; +}; + +export const getWorkspaceAuthorizationActionForMethod = ( + method: TAuthorizationHttpMethod +): TWorkspaceAction => { + if (method === "DELETE") return "workspace.manage"; + if (method === "GET") return "workspace.read"; + return "workspace.write"; +}; + +export const getOrganizationAuthorizationActionForAccessType = ( + accessType: OrganizationAccessType +): Extract => + accessType === OrganizationAccessType.Write ? "organization.manage_access" : "organization.read_access"; + +export const getFeedbackDirectoryAuthorizationAction = ( + permission: TFeedbackDirectoryPermission +): TFeedbackDirectoryAction => `feedbackDirectory.${permission}`; + +export const getFeedbackDirectoryAssignmentAuthorizationAction = ( + minPermission?: TTeamPermission +): TFeedbackDirectoryAssignmentAction => { + if (minPermission === "manage") return "feedbackDirectoryAssignment.manage"; + if (minPermission === "readWrite") return "feedbackDirectoryAssignment.write"; + return "feedbackDirectoryAssignment.read"; +}; diff --git a/apps/web/lib/authorization/resolvers.test.ts b/apps/web/lib/authorization/resolvers.test.ts new file mode 100644 index 000000000000..65d5a6ab2c05 --- /dev/null +++ b/apps/web/lib/authorization/resolvers.test.ts @@ -0,0 +1,366 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { DatabaseError } from "@formbricks/types/errors"; +import { + getApiKeyAuthById, + getApiKeyOrganizationId, + getAuthorizationOrganizationId, + getDashboardAuthorizationWorkspaceScope, + getDashboardWorkspaceId, + getFeedbackDirectoryAssignmentAuthorizationScope, + getFeedbackDirectoryAuthorizationScope, + getResponseAuthorizationWorkspaceScope, + getResponseSurveyId, + getSurveyAuthorizationWorkspaceScope, + getSurveyWorkspaceId, + getTeamOrganizationId, + getWorkspaceOrganizationId, + getWorkspaceOrganizationReferences, + isAuthorizationUserActive, +} from "./resolvers"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + survey: { findUnique: vi.fn() }, + dashboard: { findUnique: vi.fn() }, + response: { findUnique: vi.fn() }, + feedbackDirectory: { findUnique: vi.fn() }, + feedbackDirectoryWorkspace: { findUnique: vi.fn() }, + team: { findUnique: vi.fn() }, + apiKey: { findUnique: vi.fn() }, + organization: { findUnique: vi.fn() }, + user: { findUnique: vi.fn() }, + workspace: { findMany: vi.fn(), findUnique: vi.fn() }, + }, +})); + +const prismaKnownError = new Prisma.PrismaClientKnownRequestError("boom", { + code: "P2025", + clientVersion: "0.0.0", +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("parent-id resolvers", () => { + // Distinct ids per assertion avoid React.cache reuse across calls in one test. + const cases = [ + { fn: getSurveyWorkspaceId, model: prisma.survey.findUnique, row: { workspaceId: "ws1" }, value: "ws1" }, + { + fn: getDashboardWorkspaceId, + model: prisma.dashboard.findUnique, + row: { workspaceId: "ws2" }, + value: "ws2", + }, + { fn: getResponseSurveyId, model: prisma.response.findUnique, row: { surveyId: "sv1" }, value: "sv1" }, + { fn: getTeamOrganizationId, model: prisma.team.findUnique, row: { organizationId: "o1" }, value: "o1" }, + { + fn: getApiKeyOrganizationId, + model: prisma.apiKey.findUnique, + row: { organizationId: "o2" }, + value: "o2", + }, + { + fn: getAuthorizationOrganizationId, + model: prisma.organization.findUnique, + row: { id: "o3" }, + value: "o3", + }, + { + fn: getWorkspaceOrganizationId, + model: prisma.workspace.findUnique, + row: { organizationId: "o4" }, + value: "o4", + }, + ]; + + test.each(cases)( + "returns the id when found, null when missing, and rethrows Prisma errors as DatabaseError", + async ({ fn, model, row, value }) => { + vi.mocked(model).mockResolvedValueOnce(row as never); + await expect(fn("found-id")).resolves.toBe(value); + + vi.mocked(model).mockResolvedValueOnce(null); + await expect(fn("missing-id")).resolves.toBeNull(); + + vi.mocked(model).mockRejectedValueOnce(prismaKnownError); + await expect(fn("error-id")).rejects.toBeInstanceOf(DatabaseError); + } + ); +}); + +describe("authorization workspace scope resolvers", () => { + test("resolves survey scope in one query", async () => { + vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce({ + workspaceId: "ws-survey", + workspace: { organizationId: "org-1" }, + } as never); + + await expect(getSurveyAuthorizationWorkspaceScope("survey-scope")).resolves.toEqual({ + organizationId: "org-1", + workspaceId: "ws-survey", + }); + expect(prisma.survey.findUnique).toHaveBeenCalledWith({ + where: { id: "survey-scope" }, + select: { + workspaceId: true, + workspace: { select: { organizationId: true } }, + }, + }); + }); + + test("resolves dashboard scope in one query", async () => { + vi.mocked(prisma.dashboard.findUnique).mockResolvedValueOnce({ + workspaceId: "ws-dashboard", + workspace: { organizationId: "org-2" }, + } as never); + + await expect(getDashboardAuthorizationWorkspaceScope("dashboard-scope")).resolves.toEqual({ + organizationId: "org-2", + workspaceId: "ws-dashboard", + }); + }); + + test("resolves response scope in one query", async () => { + vi.mocked(prisma.response.findUnique).mockResolvedValueOnce({ + survey: { + workspaceId: "ws-response", + workspace: { organizationId: "org-3" }, + }, + } as never); + + await expect(getResponseAuthorizationWorkspaceScope("response-scope")).resolves.toEqual({ + organizationId: "org-3", + workspaceId: "ws-response", + }); + expect(prisma.response.findUnique).toHaveBeenCalledWith({ + where: { id: "response-scope" }, + select: { + survey: { + select: { + workspaceId: true, + workspace: { select: { organizationId: true } }, + }, + }, + }, + }); + }); + + test.each([ + [getSurveyAuthorizationWorkspaceScope, prisma.survey.findUnique], + [getDashboardAuthorizationWorkspaceScope, prisma.dashboard.findUnique], + [getResponseAuthorizationWorkspaceScope, prisma.response.findUnique], + ] as const)("returns null when missing and maps Prisma errors", async (resolver, model) => { + vi.mocked(model).mockResolvedValueOnce(null); + await expect(resolver(`missing-${model.name}`)).resolves.toBeNull(); + + vi.mocked(model).mockRejectedValueOnce(prismaKnownError); + await expect(resolver(`error-${model.name}`)).rejects.toBeInstanceOf(DatabaseError); + }); +}); + +describe("getWorkspaceOrganizationReferences", () => { + test("deduplicates a small input into one batch query", async () => { + vi.mocked(prisma.workspace.findMany).mockResolvedValueOnce([ + { id: "workspace-1", organizationId: "org-1" }, + { id: "workspace-2", organizationId: "org-2" }, + ] as never); + + await expect( + getWorkspaceOrganizationReferences(["workspace-1", "workspace-2", "workspace-1"]) + ).resolves.toEqual([ + { id: "workspace-1", organizationId: "org-1" }, + { id: "workspace-2", organizationId: "org-2" }, + ]); + expect(prisma.workspace.findMany).toHaveBeenCalledExactlyOnceWith({ + where: { id: { in: ["workspace-1", "workspace-2"] } }, + select: { id: true, organizationId: true }, + }); + }); + + test("uses bounded batches and maps Prisma failures to DatabaseError", async () => { + const workspaceIds = Array.from({ length: 501 }, (_unused, index) => `workspace-${index}`); + vi.mocked(prisma.workspace.findMany) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(prismaKnownError); + + await expect(getWorkspaceOrganizationReferences(workspaceIds)).resolves.toEqual([]); + expect(prisma.workspace.findMany).toHaveBeenCalledTimes(2); + expect(prisma.workspace.findMany).toHaveBeenNthCalledWith(1, { + where: { id: { in: workspaceIds.slice(0, 500) } }, + select: { id: true, organizationId: true }, + }); + expect(prisma.workspace.findMany).toHaveBeenNthCalledWith(2, { + where: { id: { in: workspaceIds.slice(500) } }, + select: { id: true, organizationId: true }, + }); + + await expect(getWorkspaceOrganizationReferences(["workspace-error"])).rejects.toBeInstanceOf( + DatabaseError + ); + }); + + test("starts independent workspace-resolution batches concurrently", async () => { + let resolveFirstBatch!: (rows: []) => void; + const firstBatch = new Promise<[]>((resolve) => { + resolveFirstBatch = resolve; + }); + vi.mocked(prisma.workspace.findMany) + .mockImplementationOnce(() => firstBatch as never) + .mockResolvedValueOnce([]); + + const pending = getWorkspaceOrganizationReferences( + Array.from({ length: 501 }, (_unused, index) => `workspace-${index}`) + ); + + expect(prisma.workspace.findMany).toHaveBeenCalledTimes(2); + resolveFirstBatch([]); + await expect(pending).resolves.toEqual([]); + }); + + test("does not query PostgreSQL for an empty set", async () => { + await expect(getWorkspaceOrganizationReferences([])).resolves.toEqual([]); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("isAuthorizationUserActive", () => { + test("requires an existing active user and preserves operational failures", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ isActive: true } as never); + await expect(isAuthorizationUserActive("active-user")).resolves.toBe(true); + + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ isActive: false } as never); + await expect(isAuthorizationUserActive("inactive-user")).resolves.toBe(false); + + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null); + await expect(isAuthorizationUserActive("missing-user")).resolves.toBe(false); + + vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(prismaKnownError); + await expect(isAuthorizationUserActive("error-user")).rejects.toBeInstanceOf(DatabaseError); + }); +}); + +describe("feedback dataset scope resolvers", () => { + test("resolves an active directory and its assigned workspace IDs", async () => { + vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce({ + isArchived: false, + organizationId: "org-1", + workspaces: [{ workspaceId: "workspace-a" }, { workspaceId: "workspace-b" }], + } as never); + + await expect(getFeedbackDirectoryAuthorizationScope("directory-1")).resolves.toEqual({ + isArchived: false, + organizationId: "org-1", + workspaceIds: ["workspace-a", "workspace-b"], + }); + }); + + test("resolves only active same-organization assignment pairs", async () => { + vi.mocked(prisma.feedbackDirectoryWorkspace.findUnique).mockResolvedValueOnce({ + feedbackDirectory: { isArchived: false, organizationId: "org-1" }, + workspace: { organizationId: "org-1" }, + } as never); + + await expect( + getFeedbackDirectoryAssignmentAuthorizationScope("directory-1", "workspace-1") + ).resolves.toMatchObject({ + assignmentId: expect.stringMatching(/^fdwa_/), + organizationId: "org-1", + workspaceId: "workspace-1", + }); + + vi.mocked(prisma.feedbackDirectoryWorkspace.findUnique).mockResolvedValueOnce({ + feedbackDirectory: { isArchived: true, organizationId: "org-1" }, + workspace: { organizationId: "org-1" }, + } as never); + await expect( + getFeedbackDirectoryAssignmentAuthorizationScope("directory-archived", "workspace-1") + ).resolves.toBeNull(); + + vi.mocked(prisma.feedbackDirectoryWorkspace.findUnique).mockResolvedValueOnce({ + feedbackDirectory: { isArchived: false, organizationId: "org-1" }, + workspace: { organizationId: "org-2" }, + } as never); + await expect( + getFeedbackDirectoryAssignmentAuthorizationScope("directory-cross-org", "workspace-2") + ).resolves.toBeNull(); + }); + + test("preserves missing rows as denials and database failures as operational errors", async () => { + vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(null); + await expect(getFeedbackDirectoryAuthorizationScope("directory-missing")).resolves.toBeNull(); + + vi.mocked(prisma.feedbackDirectoryWorkspace.findUnique).mockRejectedValueOnce(prismaKnownError); + await expect( + getFeedbackDirectoryAssignmentAuthorizationScope("directory-error", "workspace-error") + ).rejects.toBeInstanceOf(DatabaseError); + }); +}); + +describe("getApiKeyAuthById", () => { + test("maps the key's workspace grants and organization access", async () => { + vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({ + id: "key1", + organizationId: "org1", + organizationAccess: { accessControl: { read: true, write: false } }, + apiKeyWorkspaces: [ + { + permission: "read", + workspaceId: "ws1", + workspace: { name: "Growth", organizationId: "org1" }, + }, + ], + } as never); + + await expect(getApiKeyAuthById("key1")).resolves.toEqual({ + type: "apiKey", + apiKeyId: "key1", + organizationId: "org1", + organizationAccess: { accessControl: { read: true, write: false } }, + workspacePermissions: [{ permission: "read", workspaceId: "ws1", workspaceName: "Growth" }], + }); + }); + + test("drops workspace grants outside the API key's organization", async () => { + vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({ + id: "key-with-foreign-grant", + organizationId: "org1", + organizationAccess: {}, + apiKeyWorkspaces: [ + { + permission: "manage", + workspaceId: "ws-legitimate", + workspace: { name: "Legitimate", organizationId: "org1" }, + }, + { + permission: "manage", + workspaceId: "ws-foreign", + workspace: { name: "Foreign", organizationId: "org2" }, + }, + ], + } as never); + + await expect(getApiKeyAuthById("key-with-foreign-grant")).resolves.toEqual({ + type: "apiKey", + apiKeyId: "key-with-foreign-grant", + organizationId: "org1", + organizationAccess: {}, + workspacePermissions: [ + { permission: "manage", workspaceId: "ws-legitimate", workspaceName: "Legitimate" }, + ], + }); + }); + + test("returns null when the key no longer exists", async () => { + vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(null); + await expect(getApiKeyAuthById("gone")).resolves.toBeNull(); + }); + + test("rethrows Prisma errors as DatabaseError", async () => { + vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(prismaKnownError); + await expect(getApiKeyAuthById("boom")).rejects.toBeInstanceOf(DatabaseError); + }); +}); diff --git a/apps/web/lib/authorization/resolvers.ts b/apps/web/lib/authorization/resolvers.ts new file mode 100644 index 000000000000..2412db28bbda --- /dev/null +++ b/apps/web/lib/authorization/resolvers.ts @@ -0,0 +1,367 @@ +import "server-only"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { DatabaseError } from "@formbricks/types/errors"; +import { getFeedbackDirectoryAssignmentObjectId } from "@/lib/authzed/feedback-directory-assignment-id"; + +/** + * Light, cached lookups the legacy authorization evaluator uses to walk a + * resource up to the boundary its permission is enforced at (a workspace, an + * organization). They select only the id needed — never the full record — so + * an authorization check never over-fetches. They wrap `react.cache` for + * best-effort request-scoped dedup during Server Component render; they are + * deliberately NOT cached across requests (`cache.withCache`/Redis) because + * these are cheap indexed lookups and stale authorization data would be a + * security bug. Operational failures propagate as `DatabaseError`; a missing + * record resolves to `null` (a genuine "no such resource", which the evaluator + * treats as a denial, never an error). + */ + +const rethrowAsDatabaseError = (error: unknown): never => { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; +}; + +export type TAuthorizationWorkspaceScope = Readonly<{ + organizationId: string; + workspaceId: string; +}>; + +export type TFeedbackDirectoryAuthorizationScope = Readonly<{ + isArchived: boolean; + organizationId: string; + workspaceIds: ReadonlyArray; +}>; + +export type TFeedbackDirectoryAssignmentAuthorizationScope = Readonly<{ + assignmentId: string; + organizationId: string; + workspaceId: string; +}>; + +export type TWorkspaceOrganizationReference = Readonly<{ + id: string; + organizationId: string; +}>; + +const WORKSPACE_ORGANIZATION_RESOLUTION_BATCH_SIZE = 500; + +/** Whether a user principal still exists and is active. */ +export const isAuthorizationUserActive = reactCache(async (userId: string): Promise => { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { isActive: true }, + }); + return user?.isActive === true; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +/** Whether an organization resource still exists. */ +export const getAuthorizationOrganizationId = reactCache( + async (organizationId: string): Promise => { + try { + const organization = await prisma.organization.findUnique({ + where: { id: organizationId }, + select: { id: true }, + }); + return organization?.id ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); + +/** The organization a workspace belongs to (`Workspace.organizationId`). */ +export const getWorkspaceOrganizationId = reactCache(async (workspaceId: string): Promise => { + try { + const workspace = await prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { organizationId: true }, + }); + return workspace?.organizationId ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +/** + * Resolve workspace IDs to organizations in bounded `IN` queries. + * + * LookupResources returns a stream-sized set. Resolving that set one row at a time would turn one + * authorization observation into an N+1 database path, so this helper keeps the number of queries + * proportional to bounded chunks instead of result cardinality. + */ +export const getWorkspaceOrganizationReferences = async ( + workspaceIds: ReadonlyArray +): Promise> => { + const uniqueWorkspaceIds = [...new Set(workspaceIds)]; + if (uniqueWorkspaceIds.length === 0) return []; + + try { + const batches: string[][] = []; + for ( + let offset = 0; + offset < uniqueWorkspaceIds.length; + offset += WORKSPACE_ORGANIZATION_RESOLUTION_BATCH_SIZE + ) { + batches.push(uniqueWorkspaceIds.slice(offset, offset + WORKSPACE_ORGANIZATION_RESOLUTION_BATCH_SIZE)); + } + + const references = await Promise.all( + batches.map((batch) => + prisma.workspace.findMany({ + where: { id: { in: batch } }, + select: { id: true, organizationId: true }, + }) + ) + ); + return references.flat(); + } catch (error) { + return rethrowAsDatabaseError(error); + } +}; + +/** The workspace and organization a survey belongs to. */ +export const getSurveyAuthorizationWorkspaceScope = reactCache( + async (surveyId: string): Promise => { + try { + const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + select: { + workspaceId: true, + workspace: { select: { organizationId: true } }, + }, + }); + return survey + ? { organizationId: survey.workspace.organizationId, workspaceId: survey.workspaceId } + : null; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); + +/** The workspace and organization a dashboard belongs to. */ +export const getDashboardAuthorizationWorkspaceScope = reactCache( + async (dashboardId: string): Promise => { + try { + const dashboard = await prisma.dashboard.findUnique({ + where: { id: dashboardId }, + select: { + workspaceId: true, + workspace: { select: { organizationId: true } }, + }, + }); + return dashboard + ? { organizationId: dashboard.workspace.organizationId, workspaceId: dashboard.workspaceId } + : null; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); + +/** The workspace and organization a response's survey belongs to. */ +export const getResponseAuthorizationWorkspaceScope = reactCache( + async (responseId: string): Promise => { + try { + const response = await prisma.response.findUnique({ + where: { id: responseId }, + select: { + survey: { + select: { + workspaceId: true, + workspace: { select: { organizationId: true } }, + }, + }, + }, + }); + return response + ? { + organizationId: response.survey.workspace.organizationId, + workspaceId: response.survey.workspaceId, + } + : null; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); + +/** The workspace a survey belongs to (`Survey.workspaceId`). */ +export const getSurveyWorkspaceId = reactCache(async (surveyId: string): Promise => { + try { + const survey = await prisma.survey.findUnique({ + where: { id: surveyId }, + select: { workspaceId: true }, + }); + return survey?.workspaceId ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +/** The workspace a dashboard belongs to (`Dashboard.workspaceId`). */ +export const getDashboardWorkspaceId = reactCache(async (dashboardId: string): Promise => { + try { + const dashboard = await prisma.dashboard.findUnique({ + where: { id: dashboardId }, + select: { workspaceId: true }, + }); + return dashboard?.workspaceId ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +/** The survey a response belongs to (`Response.surveyId`). */ +export const getResponseSurveyId = reactCache(async (responseId: string): Promise => { + try { + const response = await prisma.response.findUnique({ + where: { id: responseId }, + select: { surveyId: true }, + }); + return response?.surveyId ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +/** The organization a team belongs to (`Team.organizationId`). */ +export const getTeamOrganizationId = reactCache(async (teamId: string): Promise => { + try { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { organizationId: true }, + }); + return team?.organizationId ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +/** The organization an API key belongs to (`ApiKey.organizationId`). */ +export const getApiKeyOrganizationId = reactCache(async (apiKeyId: string): Promise => { + try { + const apiKey = await prisma.apiKey.findUnique({ + where: { id: apiKeyId }, + select: { organizationId: true }, + }); + return apiKey?.organizationId ?? null; + } catch (error) { + return rethrowAsDatabaseError(error); + } +}); + +export const getFeedbackDirectoryAuthorizationScope = reactCache( + async (feedbackDirectoryId: string): Promise => { + try { + const directory = await prisma.feedbackDirectory.findUnique({ + where: { id: feedbackDirectoryId }, + select: { + isArchived: true, + organizationId: true, + workspaces: { select: { workspaceId: true }, orderBy: { workspaceId: "asc" } }, + }, + }); + return directory + ? { + isArchived: directory.isArchived, + organizationId: directory.organizationId, + workspaceIds: directory.workspaces.map(({ workspaceId }) => workspaceId), + } + : null; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); + +export const getFeedbackDirectoryAssignmentAuthorizationScope = reactCache( + async ( + feedbackDirectoryId: string, + workspaceId: string + ): Promise => { + try { + const assignment = await prisma.feedbackDirectoryWorkspace.findUnique({ + where: { + feedbackDirectoryId_workspaceId: { feedbackDirectoryId, workspaceId }, + }, + select: { + feedbackDirectory: { select: { isArchived: true, organizationId: true } }, + workspace: { select: { organizationId: true } }, + }, + }); + if ( + !assignment || + assignment.feedbackDirectory.isArchived || + assignment.feedbackDirectory.organizationId !== assignment.workspace.organizationId + ) { + return null; + } + + return { + assignmentId: getFeedbackDirectoryAssignmentObjectId(feedbackDirectoryId, workspaceId), + organizationId: assignment.feedbackDirectory.organizationId, + workspaceId, + }; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); + +/** + * Resolve an API key acting as a principal (by `ApiKey.id`) into its effective + * scopes: per-workspace permissions and organization-level access control. This + * is the by-id counterpart to `getApiKeyWithPermissions`, which resolves by raw + * secret during request authentication. Returns `null` if the key is gone. + */ +export const getApiKeyAuthById = reactCache( + async (apiKeyId: string): Promise => { + try { + const apiKey = await prisma.apiKey.findUnique({ + where: { id: apiKeyId }, + select: { + id: true, + organizationId: true, + organizationAccess: true, + apiKeyWorkspaces: { + select: { + permission: true, + workspaceId: true, + workspace: { select: { name: true, organizationId: true } }, + }, + }, + }, + }); + + if (!apiKey) return null; + + return { + type: "apiKey", + apiKeyId: apiKey.id, + organizationId: apiKey.organizationId, + organizationAccess: apiKey.organizationAccess as TAuthenticationApiKey["organizationAccess"], + workspacePermissions: apiKey.apiKeyWorkspaces + .filter( + (workspacePermission) => workspacePermission.workspace.organizationId === apiKey.organizationId + ) + .map((workspacePermission) => ({ + permission: workspacePermission.permission, + workspaceId: workspacePermission.workspaceId, + workspaceName: workspacePermission.workspace.name, + })), + }; + } catch (error) { + return rethrowAsDatabaseError(error); + } + } +); diff --git a/apps/web/lib/authorization/resource-inventory.test.ts b/apps/web/lib/authorization/resource-inventory.test.ts new file mode 100644 index 000000000000..2593565377a1 --- /dev/null +++ b/apps/web/lib/authorization/resource-inventory.test.ts @@ -0,0 +1,64 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { + AUDIT_TARGET_AUTHORIZATION_RESOURCE_INVENTORY, + PRISMA_AUTHORIZATION_RESOURCE_INVENTORY, +} from "./resource-inventory"; + +const REPOSITORY_ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); + +const readPrismaModels = (): ReadonlyArray => { + const schemaDirectory = join(REPOSITORY_ROOT, "packages/database/schema"); + const models = readdirSync(schemaDirectory) + .filter((file) => file.endsWith(".prisma")) + .flatMap((file) => + [...readFileSync(join(schemaDirectory, file), "utf8").matchAll(/^model\s+(\w+)\s+\{/gm)].map( + ([, model]) => model + ) + ); + + return [...new Set(models)].sort(); +}; + +const readAuditTargets = (): ReadonlyArray => { + const source = readFileSync( + join(REPOSITORY_ROOT, "apps/web/modules/ee/audit-logs/types/audit-log.ts"), + "utf8" + ); + const targetBlock = source.match(/ZAuditTarget\s*=\s*z\.enum\(\[([\s\S]*?)\]\)/)?.[1]; + if (!targetBlock) { + throw new Error("Unable to locate ZAuditTarget"); + } + + return [...targetBlock.matchAll(/"([^"]+)"/g)].map(([, target]) => target).sort(); +}; + +describe("authorization resource inventory", () => { + test("classifies every Prisma model exactly once", () => { + expect(Object.keys(PRISMA_AUTHORIZATION_RESOURCE_INVENTORY).sort()).toEqual(readPrismaModels()); + }); + + test("classifies every audit target exactly once", () => { + expect(Object.keys(AUDIT_TARGET_AUTHORIZATION_RESOURCE_INVENTORY).sort()).toEqual(readAuditTargets()); + }); + + test("keeps every current grant source in the relationship category", () => { + for (const source of [ + "Membership", + "TeamUser", + "WorkspaceTeam", + "ApiKey", + "ApiKeyWorkspace", + "FeedbackDirectoryWorkspace", + ] as const) { + expect(PRISMA_AUTHORIZATION_RESOURCE_INVENTORY[source]).toBe("relationship_or_grant_source"); + } + }); + + test("keeps charts and workflows workspace-inherited rather than standalone Phase 1 ACLs", () => { + expect(PRISMA_AUTHORIZATION_RESOURCE_INVENTORY.Chart).toBe("workspace_inherited_resource"); + expect(PRISMA_AUTHORIZATION_RESOURCE_INVENTORY.Workflow).toBe("workspace_inherited_resource"); + }); +}); diff --git a/apps/web/lib/authorization/resource-inventory.ts b/apps/web/lib/authorization/resource-inventory.ts new file mode 100644 index 000000000000..3716fa45fdc0 --- /dev/null +++ b/apps/web/lib/authorization/resource-inventory.ts @@ -0,0 +1,114 @@ +import "server-only"; + +export const AUTHORIZATION_RESOURCE_CATEGORIES = { + AUTHENTICATION_OR_APPLICATION: "authentication_or_application", + DIRECT_AUTHORIZATION_RESOURCE: "direct_authorization_resource", + PARENT_DERIVED_OR_DATA_INTEGRITY: "parent_derived_or_data_integrity", + PUBLIC_OR_OUT_OF_SCOPE: "public_or_out_of_scope", + RELATIONSHIP_OR_GRANT_SOURCE: "relationship_or_grant_source", + WORKSPACE_INHERITED_RESOURCE: "workspace_inherited_resource", +} as const; + +type TResourceCategory = + (typeof AUTHORIZATION_RESOURCE_CATEGORIES)[keyof typeof AUTHORIZATION_RESOURCE_CATEGORIES]; + +/** + * Non-runtime review inventory for every Prisma model. + * + * `FeedbackDirectory` is the product's “Feedback Dataset”. Charts and workflows inherit workspace + * authorization; chart `createdBy` is metadata, not ownership. Feedback records live in Hub rather than + * Prisma and remain protected by dataset/workspace authorization plus tenant and integrity checks. + */ +export const PRISMA_AUTHORIZATION_RESOURCE_INVENTORY = { + Account: "authentication_or_application", + ActionClass: "workspace_inherited_resource", + ApiKey: "relationship_or_grant_source", + ApiKeyWorkspace: "relationship_or_grant_source", + AuthzedProjectionOutbox: "authentication_or_application", + Chart: "workspace_inherited_resource", + Contact: "workspace_inherited_resource", + ContactAttribute: "parent_derived_or_data_integrity", + ContactAttributeKey: "workspace_inherited_resource", + Dashboard: "direct_authorization_resource", + DashboardWidget: "parent_derived_or_data_integrity", + DataMigration: "public_or_out_of_scope", + Display: "parent_derived_or_data_integrity", + FeedbackDirectory: "direct_authorization_resource", + FeedbackDirectoryWorkspace: "relationship_or_grant_source", + FeedbackSource: "parent_derived_or_data_integrity", + FeedbackSourceFieldMapping: "parent_derived_or_data_integrity", + FeedbackSourceFormbricksMapping: "parent_derived_or_data_integrity", + Integration: "workspace_inherited_resource", + Invite: "authentication_or_application", + Language: "parent_derived_or_data_integrity", + Membership: "relationship_or_grant_source", + Organization: "direct_authorization_resource", + OrganizationBilling: "authentication_or_application", + // This is a Prisma model name and a classification label, not a credential or credential value. + PasswordResetToken: "authentication_or_application", // NOSONAR + Response: "direct_authorization_resource", + ResponseQuotaLink: "parent_derived_or_data_integrity", + Segment: "workspace_inherited_resource", + Session: "authentication_or_application", + Survey: "direct_authorization_resource", + SurveyAttributeFilter: "parent_derived_or_data_integrity", + SurveyFollowUp: "parent_derived_or_data_integrity", + SurveyLanguage: "parent_derived_or_data_integrity", + SurveyQuota: "parent_derived_or_data_integrity", + SurveyTrigger: "parent_derived_or_data_integrity", + Tag: "workspace_inherited_resource", + TagsOnResponses: "parent_derived_or_data_integrity", + Team: "direct_authorization_resource", + TeamUser: "relationship_or_grant_source", + TwoFactor: "authentication_or_application", + User: "authentication_or_application", + VerificationToken: "authentication_or_application", + Webhook: "workspace_inherited_resource", + Workflow: "workspace_inherited_resource", + WorkflowRun: "parent_derived_or_data_integrity", + WorkflowRunLog: "parent_derived_or_data_integrity", + WorkflowVersion: "parent_derived_or_data_integrity", + Workspace: "direct_authorization_resource", + WorkspaceTeam: "relationship_or_grant_source", + jwks: "authentication_or_application", + oauthAccessToken: "authentication_or_application", + oauthClient: "authentication_or_application", + oauthClientAssertion: "authentication_or_application", + oauthClientResource: "authentication_or_application", + oauthConsent: "authentication_or_application", + oauthRefreshToken: "authentication_or_application", + oauthResource: "authentication_or_application", +} as const satisfies Readonly>; + +/** Audit-only targets use a separate namespace so each target and each Prisma model is classified once. */ +export const AUDIT_TARGET_AUTHORIZATION_RESOURCE_INVENTORY = { + actionClass: "workspace_inherited_resource", + apiKey: "relationship_or_grant_source", + chart: "workspace_inherited_resource", + contact: "workspace_inherited_resource", + contactAttributeKey: "workspace_inherited_resource", + cubeQuery: "parent_derived_or_data_integrity", + dashboard: "direct_authorization_resource", + dashboardWidget: "parent_derived_or_data_integrity", + feedbackDirectory: "direct_authorization_resource", + feedbackRecord: "parent_derived_or_data_integrity", + feedbackSource: "parent_derived_or_data_integrity", + file: "workspace_inherited_resource", + integration: "workspace_inherited_resource", + invite: "authentication_or_application", + language: "parent_derived_or_data_integrity", + membership: "relationship_or_grant_source", + organization: "direct_authorization_resource", + quota: "parent_derived_or_data_integrity", + response: "direct_authorization_resource", + segment: "workspace_inherited_resource", + survey: "direct_authorization_resource", + tag: "workspace_inherited_resource", + team: "direct_authorization_resource", + twoFactorAuth: "authentication_or_application", + user: "authentication_or_application", + webhook: "workspace_inherited_resource", + workflow: "workspace_inherited_resource", + workspace: "direct_authorization_resource", + workspaceTeam: "relationship_or_grant_source", +} as const satisfies Readonly>; diff --git a/apps/web/lib/authorization/resource-list.test.ts b/apps/web/lib/authorization/resource-list.test.ts new file mode 100644 index 000000000000..11d6bdbac7d8 --- /dev/null +++ b/apps/web/lib/authorization/resource-list.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getAuthzedClient } from "@/lib/authzed/client"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "@/lib/authzed/errors"; +import { assertAuthzedProjectionFreshness } from "@/lib/authzed/outbox-freshness"; +import { getAuthorizationSurface, recordAuthorizationCheckIssued } from "./context"; +import { recordAuthorizationDecision } from "./metrics"; +import { lookupAuthorizedOrganizationIds, lookupAuthorizedWorkspaceIds } from "./resource-list"; + +vi.mock("@/lib/authzed/client", () => ({ getAuthzedClient: vi.fn() })); +vi.mock("@/lib/authzed/outbox-freshness", () => ({ assertAuthzedProjectionFreshness: vi.fn() })); +vi.mock("./context", () => ({ + getAuthorizationSurface: vi.fn(() => "unscoped"), + recordAuthorizationCheckIssued: vi.fn(), +})); +vi.mock("./metrics", () => ({ recordAuthorizationDecision: vi.fn() })); + +const lookupResources = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAuthzedClient).mockReturnValue({ lookupResources } as never); + lookupResources.mockResolvedValue({ resourceIds: [] }); +}); + +describe("authoritative resource lists", () => { + test("looks up readable organizations for a user after the freshness guard", async () => { + lookupResources.mockResolvedValue({ resourceIds: ["organization-1"] }); + + await expect(lookupAuthorizedOrganizationIds({ type: "user", id: "user-1" })).resolves.toEqual([ + "organization-1", + ]); + + expect(assertAuthzedProjectionFreshness).toHaveBeenCalledOnce(); + expect(recordAuthorizationCheckIssued).toHaveBeenCalledOnce(); + expect(lookupResources).toHaveBeenCalledExactlyOnceWith({ + permission: "read", + resourceType: "organization", + subject: { objectId: "user-1", objectType: "user" }, + }); + expect(recordAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + action: "organization.read", + actorType: "user", + outcome: "allow", + resourceType: "organization", + surface: "unscoped", + }) + ); + }); + + test.each(["read", "write"] as const)( + "looks up %s workspaces for an API key using the mapped object type", + async (permission) => { + lookupResources.mockResolvedValue({ resourceIds: ["workspace-1", "workspace-2"] }); + + await expect( + lookupAuthorizedWorkspaceIds({ type: "apiKey", id: "key-1" }, permission) + ).resolves.toEqual(["workspace-1", "workspace-2"]); + + expect(lookupResources).toHaveBeenCalledExactlyOnceWith({ + permission, + resourceType: "workspace", + subject: { objectId: "key-1", objectType: "api_key" }, + }); + } + ); + + test("fails closed before lookup when projection freshness cannot be established", async () => { + const stale = new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.PROJECTION_STALE, + operation: "projection_freshness", + retryable: false, + }); + vi.mocked(assertAuthzedProjectionFreshness).mockRejectedValue(stale); + + await expect(lookupAuthorizedWorkspaceIds({ type: "user", id: "user-1" })).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.PROJECTION_STALE, + operation: "authorization_list", + }); + expect(getAuthzedClient).not.toHaveBeenCalled(); + expect(recordAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + errorCode: AUTHZED_ERROR_CODES.PROJECTION_STALE, + outcome: "operational_error", + }) + ); + }); + + test("propagates lookup operational failures without returning a partial list", async () => { + const unavailable = new Error("AuthZed unavailable"); + lookupResources.mockRejectedValue(unavailable); + + await expect(lookupAuthorizedWorkspaceIds({ type: "user", id: "user-1" })).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "authorization_list", + }); + expect(recordAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ errorCode: AUTHZED_ERROR_CODES.INTERNAL, outcome: "operational_error" }) + ); + }); + + test("records an empty authoritative list as an aggregate deny on the active surface", async () => { + vi.mocked(getAuthorizationSurface).mockReturnValueOnce("mcp"); + + await expect(lookupAuthorizedWorkspaceIds({ type: "apiKey", id: "key-1" })).resolves.toEqual([]); + + expect(recordAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "deny", surface: "mcp" }) + ); + }); +}); diff --git a/apps/web/lib/authorization/resource-list.ts b/apps/web/lib/authorization/resource-list.ts new file mode 100644 index 000000000000..3bd0bfd715b9 --- /dev/null +++ b/apps/web/lib/authorization/resource-list.ts @@ -0,0 +1,71 @@ +import "server-only"; +import { performance } from "node:perf_hooks"; +import { cache as reactCache } from "react"; +import { getAuthzedClient } from "@/lib/authzed/client"; +import { assertAuthzedProjectionFreshness } from "@/lib/authzed/outbox-freshness"; +import { getAuthorizationSurface, recordAuthorizationCheckIssued } from "./context"; +import type { TAuthorizationAction, TAuthorizationActor } from "./contract"; +import { recordAuthorizationDecision } from "./metrics"; +import { getSpicedbObjectType } from "./object-type"; +import { normalizeAuthorizationOperationalError } from "./operational-error"; + +type TCurrentListResource = "organization" | "workspace"; +type TCurrentListPermission = "read" | "write"; + +const lookupAuthorizationResourceIds = reactCache( + async ( + actorType: TAuthorizationActor["type"], + actorId: string, + resourceType: TCurrentListResource, + permission: TCurrentListPermission + ): Promise> => { + recordAuthorizationCheckIssued(); + const startedAt = performance.now(); + const action = `${resourceType}.${permission}` as TAuthorizationAction; + const metric = { + action, + actorType, + resourceType, + surface: getAuthorizationSurface(), + } as const; + + try { + await assertAuthzedProjectionFreshness(); + + const result = await getAuthzedClient().lookupResources({ + permission, + resourceType: getSpicedbObjectType(resourceType), + subject: { + objectId: actorId, + objectType: getSpicedbObjectType(actorType), + }, + }); + + recordAuthorizationDecision({ + ...metric, + durationMs: performance.now() - startedAt, + // For a list operation, an empty authorized set is the aggregate equivalent of a deny. + outcome: result.resourceIds.length > 0 ? "allow" : "deny", + }); + return result.resourceIds; + } catch (error) { + const normalized = normalizeAuthorizationOperationalError(error, "authorization_list"); + recordAuthorizationDecision({ + ...metric, + durationMs: performance.now() - startedAt, + errorCode: normalized.code, + outcome: "operational_error", + }); + throw normalized; + } + } +); + +export const lookupAuthorizedOrganizationIds = (actor: TAuthorizationActor): Promise> => + lookupAuthorizationResourceIds(actor.type, actor.id, "organization", "read"); + +export const lookupAuthorizedWorkspaceIds = ( + actor: TAuthorizationActor, + permission: TCurrentListPermission = "read" +): Promise> => + lookupAuthorizationResourceIds(actor.type, actor.id, "workspace", permission); diff --git a/apps/web/lib/authorization/source-scope.test.ts b/apps/web/lib/authorization/source-scope.test.ts new file mode 100644 index 000000000000..e16c85d77ce9 --- /dev/null +++ b/apps/web/lib/authorization/source-scope.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + getApiKeyOrganizationId, + getAuthorizationOrganizationId, + getDashboardAuthorizationWorkspaceScope, + getFeedbackDirectoryAssignmentAuthorizationScope, + getFeedbackDirectoryAuthorizationScope, + getResponseAuthorizationWorkspaceScope, + getSurveyAuthorizationWorkspaceScope, + getTeamOrganizationId, + getWorkspaceOrganizationId, + isAuthorizationUserActive, +} from "./resolvers"; +import { resolveAuthorizationScope } from "./source-scope"; + +vi.mock("./resolvers", () => ({ + getApiKeyOrganizationId: vi.fn(), + getAuthorizationOrganizationId: vi.fn(), + getDashboardAuthorizationWorkspaceScope: vi.fn(), + getFeedbackDirectoryAssignmentAuthorizationScope: vi.fn(), + getFeedbackDirectoryAuthorizationScope: vi.fn(), + getResponseAuthorizationWorkspaceScope: vi.fn(), + getSurveyAuthorizationWorkspaceScope: vi.fn(), + getTeamOrganizationId: vi.fn(), + getWorkspaceOrganizationId: vi.fn(), + isAuthorizationUserActive: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthorizationUserActive).mockResolvedValue(true); +}); + +describe("resolveAuthorizationScope", () => { + test.each([ + ["organization", getAuthorizationOrganizationId], + ["workspace", getWorkspaceOrganizationId], + ["team", getTeamOrganizationId], + ["apiKey", getApiKeyOrganizationId], + ] as const)("resolves a %s resource organization", async (resourceType, resolver) => { + vi.mocked(resolver).mockResolvedValue("org-1"); + + await expect( + resolveAuthorizationScope({ type: "user", id: "user-1" }, { type: resourceType, id: "resource-1" }) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-1", + permissionResource: { type: resourceType, id: "resource-1" }, + }); + }); + + test("resolves survey, dashboard, and response parent chains", async () => { + vi.mocked(getSurveyAuthorizationWorkspaceScope).mockResolvedValue({ + organizationId: "org-workspace-survey", + workspaceId: "workspace-survey", + }); + vi.mocked(getDashboardAuthorizationWorkspaceScope).mockResolvedValue({ + organizationId: "org-workspace-dashboard", + workspaceId: "workspace-dashboard", + }); + vi.mocked(getResponseAuthorizationWorkspaceScope).mockResolvedValue({ + organizationId: "org-workspace-response", + workspaceId: "workspace-response", + }); + + await expect( + resolveAuthorizationScope({ type: "user", id: "user-1" }, { type: "survey", id: "survey-1" }) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-workspace-survey", + permissionResource: { type: "workspace", id: "workspace-survey" }, + }); + await expect( + resolveAuthorizationScope({ type: "user", id: "user-1" }, { type: "dashboard", id: "dashboard-1" }) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-workspace-dashboard", + permissionResource: { type: "workspace", id: "workspace-dashboard" }, + }); + await expect( + resolveAuthorizationScope({ type: "user", id: "user-1" }, { type: "response", id: "response-1" }) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-workspace-response", + permissionResource: { type: "workspace", id: "workspace-response" }, + }); + }); + + test("resolves directory and exact directory-workspace assignment resources", async () => { + vi.mocked(getFeedbackDirectoryAuthorizationScope).mockResolvedValue({ + isArchived: false, + organizationId: "org-1", + workspaceIds: ["workspace-1"], + }); + vi.mocked(getFeedbackDirectoryAssignmentAuthorizationScope).mockResolvedValue({ + assignmentId: "fdwa-1", + organizationId: "org-1", + workspaceId: "workspace-1", + }); + + await expect( + resolveAuthorizationScope( + { type: "user", id: "user-1" }, + { type: "feedbackDirectory", id: "directory-1" } + ) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-1", + permissionResource: { type: "feedbackDirectory", id: "directory-1" }, + }); + await expect( + resolveAuthorizationScope( + { type: "user", id: "user-1" }, + { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: "directory-1", + workspaceId: "workspace-1", + } + ) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-1", + permissionResource: { + type: "feedbackDirectoryAssignment", + id: "fdwa-1", + }, + }); + expect(getFeedbackDirectoryAssignmentAuthorizationScope).toHaveBeenCalledWith( + "directory-1", + "workspace-1" + ); + }); + + test("denies archived directories and invalid exact assignments", async () => { + vi.mocked(getFeedbackDirectoryAuthorizationScope).mockResolvedValue({ + isArchived: true, + organizationId: "org-1", + workspaceIds: ["workspace-1"], + }); + vi.mocked(getFeedbackDirectoryAssignmentAuthorizationScope).mockResolvedValue(null); + + await expect( + resolveAuthorizationScope( + { type: "user", id: "user-1" }, + { type: "feedbackDirectory", id: "directory-1" } + ) + ).resolves.toBeNull(); + await expect( + resolveAuthorizationScope( + { type: "user", id: "user-1" }, + { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: "directory-1", + workspaceId: "workspace-1", + } + ) + ).resolves.toBeNull(); + }); + + test("denies missing resources after resolving actor and resource in parallel", async () => { + vi.mocked(getWorkspaceOrganizationId).mockResolvedValue(null); + + await expect( + resolveAuthorizationScope({ type: "user", id: "user-1" }, { type: "workspace", id: "missing" }) + ).resolves.toBeNull(); + expect(isAuthorizationUserActive).toHaveBeenCalledWith("user-1"); + }); + + test("starts actor validation without waiting for resource scope resolution", async () => { + let resolveResource: ((organizationId: string) => void) | undefined; + vi.mocked(getWorkspaceOrganizationId).mockReturnValue( + new Promise((resolve) => { + resolveResource = resolve; + }) + ); + + const result = resolveAuthorizationScope( + { type: "user", id: "user-1" }, + { type: "workspace", id: "workspace-1" } + ); + + expect(isAuthorizationUserActive).toHaveBeenCalledWith("user-1"); + resolveResource?.("org-1"); + await expect(result).resolves.toMatchObject({ actorValid: true, organizationId: "org-1" }); + }); + + test("marks a missing user as invalid", async () => { + vi.mocked(getAuthorizationOrganizationId).mockResolvedValue("org-1"); + vi.mocked(isAuthorizationUserActive).mockResolvedValue(false); + + await expect( + resolveAuthorizationScope({ type: "user", id: "missing" }, { type: "organization", id: "org-1" }) + ).resolves.toEqual({ + actorValid: false, + organizationId: "org-1", + permissionResource: { type: "organization", id: "org-1" }, + }); + }); + + test("accepts only API keys belonging to the resource organization", async () => { + vi.mocked(getWorkspaceOrganizationId).mockResolvedValue("org-1"); + vi.mocked(getApiKeyOrganizationId).mockResolvedValueOnce("org-1").mockResolvedValueOnce("org-2"); + + await expect( + resolveAuthorizationScope({ type: "apiKey", id: "key-1" }, { type: "workspace", id: "workspace-1" }) + ).resolves.toEqual({ + actorValid: true, + organizationId: "org-1", + permissionResource: { type: "workspace", id: "workspace-1" }, + }); + await expect( + resolveAuthorizationScope({ type: "apiKey", id: "key-2" }, { type: "workspace", id: "workspace-1" }) + ).resolves.toEqual({ + actorValid: false, + organizationId: "org-1", + permissionResource: { type: "workspace", id: "workspace-1" }, + }); + }); + + test("propagates resolver failures as operational errors", async () => { + const failure = new Error("database unavailable"); + vi.mocked(getTeamOrganizationId).mockRejectedValue(failure); + + await expect( + resolveAuthorizationScope({ type: "user", id: "user-1" }, { type: "team", id: "team-1" }) + ).rejects.toBe(failure); + }); +}); diff --git a/apps/web/lib/authorization/source-scope.ts b/apps/web/lib/authorization/source-scope.ts new file mode 100644 index 000000000000..0326980bd731 --- /dev/null +++ b/apps/web/lib/authorization/source-scope.ts @@ -0,0 +1,142 @@ +import "server-only"; +import type { TAuthorizationActor, TAuthorizationResource, TAuthorizationResourceType } from "./contract"; +import { + getApiKeyOrganizationId, + getAuthorizationOrganizationId, + getDashboardAuthorizationWorkspaceScope, + getFeedbackDirectoryAssignmentAuthorizationScope, + getFeedbackDirectoryAuthorizationScope, + getResponseAuthorizationWorkspaceScope, + getSurveyAuthorizationWorkspaceScope, + getTeamOrganizationId, + getWorkspaceOrganizationId, + isAuthorizationUserActive, +} from "./resolvers"; + +type TResolvedPermissionResource = Readonly<{ + type: TAuthorizationResourceType; + id: string; +}>; + +export type TResolvedAuthorizationScope = Readonly<{ + actorValid: boolean; + organizationId: string; + permissionResource: TResolvedPermissionResource; +}>; + +type TResourceScope = Readonly<{ + organizationId: string; + permissionResource: TResolvedPermissionResource; +}>; + +const resolveWorkspaceScope = async (workspaceId: string): Promise => { + const organizationId = await getWorkspaceOrganizationId(workspaceId); + return organizationId + ? { organizationId, permissionResource: { type: "workspace", id: workspaceId } } + : null; +}; + +const toWorkspaceResourceScope = ( + scope: Readonly<{ organizationId: string; workspaceId: string }> | null +): TResourceScope | null => + scope + ? { + organizationId: scope.organizationId, + permissionResource: { type: "workspace", id: scope.workspaceId }, + } + : null; + +const resolveResourceScope = async (resource: TAuthorizationResource): Promise => { + switch (resource.type) { + case "organization": { + const organizationId = await getAuthorizationOrganizationId(resource.id); + return organizationId + ? { organizationId, permissionResource: { type: resource.type, id: resource.id } } + : null; + } + case "workspace": + return resolveWorkspaceScope(resource.id); + case "team": { + const organizationId = await getTeamOrganizationId(resource.id); + return organizationId + ? { organizationId, permissionResource: { type: resource.type, id: resource.id } } + : null; + } + case "apiKey": { + const organizationId = await getApiKeyOrganizationId(resource.id); + return organizationId + ? { organizationId, permissionResource: { type: resource.type, id: resource.id } } + : null; + } + case "survey": { + return toWorkspaceResourceScope(await getSurveyAuthorizationWorkspaceScope(resource.id)); + } + case "dashboard": { + return toWorkspaceResourceScope(await getDashboardAuthorizationWorkspaceScope(resource.id)); + } + case "response": { + return toWorkspaceResourceScope(await getResponseAuthorizationWorkspaceScope(resource.id)); + } + case "feedbackDirectory": { + const scope = await getFeedbackDirectoryAuthorizationScope(resource.id); + // Archive state is an authoritative PostgreSQL policy input, not a projected relationship. + // Deny it before consulting SpiceDB so organization administrators cannot retain access through + // feedback_directory#organization while the directory is archived. + return scope && !scope.isArchived + ? { + organizationId: scope.organizationId, + permissionResource: { type: resource.type, id: resource.id }, + } + : null; + } + case "feedbackDirectoryAssignment": { + const scope = await getFeedbackDirectoryAssignmentAuthorizationScope( + resource.feedbackDirectoryId, + resource.workspaceId + ); + return scope + ? { + organizationId: scope.organizationId, + permissionResource: { + id: scope.assignmentId, + type: "feedbackDirectoryAssignment", + }, + } + : null; + } + } +}; + +/** + * Resolve the authoritative PostgreSQL tenant boundary before consulting the + * SpiceDB projection. Missing actors/resources are genuine denials; database + * failures propagate so the caller can distinguish them from a denied check. + */ +export const resolveAuthorizationScope = async ( + actor: TAuthorizationActor, + resource: TAuthorizationResource +): Promise => { + if (actor.type === "user") { + const [resourceScope, actorValid] = await Promise.all([ + resolveResourceScope(resource), + isAuthorizationUserActive(actor.id), + ]); + if (!resourceScope) return null; + + return { + actorValid, + ...resourceScope, + }; + } + + const [resourceScope, actorOrganizationId] = await Promise.all([ + resolveResourceScope(resource), + getApiKeyOrganizationId(actor.id), + ]); + if (!resourceScope) return null; + + return { + actorValid: actorOrganizationId !== null && actorOrganizationId === resourceScope.organizationId, + ...resourceScope, + }; +}; diff --git a/apps/web/lib/authorization/spicedb-evaluator.test.ts b/apps/web/lib/authorization/spicedb-evaluator.test.ts new file mode 100644 index 000000000000..f5f25eb59170 --- /dev/null +++ b/apps/web/lib/authorization/spicedb-evaluator.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getAuthzedClient } from "@/lib/authzed/client"; +import { AUTHORIZATION_PERMISSION_MAP, type TAuthorizationAction } from "./contract"; +import { resolveAuthorizationScope } from "./source-scope"; +import { checkSpicedbPermissionAtScope, spicedbEvaluator } from "./spicedb-evaluator"; + +const constantsMock = vi.hoisted(() => ({ minimumRole: "manager" })); + +vi.mock("@/lib/constants", () => ({ + get USER_MANAGEMENT_MINIMUM_ROLE() { + return constantsMock.minimumRole; + }, +})); +vi.mock("@/lib/authzed/client", () => ({ getAuthzedClient: vi.fn() })); +vi.mock("@/lib/authzed/outbox-freshness", () => ({ assertAuthzedProjectionFreshness: vi.fn() })); +vi.mock("./source-scope", () => ({ resolveAuthorizationScope: vi.fn() })); + +const checkPermission = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + constantsMock.minimumRole = "manager"; + vi.mocked(getAuthzedClient).mockReturnValue({ checkPermission } as never); + vi.mocked(resolveAuthorizationScope).mockImplementation(async (_actor, resource) => ({ + actorValid: true, + organizationId: "org-1", + permissionResource: + resource.type === "survey" || resource.type === "dashboard" || resource.type === "response" + ? { type: "workspace", id: "workspace-1" } + : resource.type === "feedbackDirectoryAssignment" + ? { type: resource.type, id: "assignment-1" } + : resource, + })); + checkPermission.mockResolvedValue({ allowed: true }); +}); + +describe("spicedbEvaluator", () => { + test("maps every current action for both actor types to its resource and SpiceDB permission", async () => { + for (const actorType of ["user", "apiKey"] as const) { + for (const [resourceType, permissions] of Object.entries(AUTHORIZATION_PERMISSION_MAP)) { + for (const permission of permissions) { + checkPermission.mockClear(); + const action = `${resourceType}.${permission}` as TAuthorizationAction; + + const resource = + resourceType === "feedbackDirectoryAssignment" + ? { + type: resourceType, + feedbackDirectoryId: "directory-1", + workspaceId: "workspace-1", + } + : { type: resourceType, id: "resource-1" }; + + await expect( + spicedbEvaluator.can({ type: actorType, id: "actor-1" }, action, resource as never) + ).resolves.toBe(true); + + const derivedPermission = { + "dashboard.read": "read", + "dashboard.write": "write", + "response.export": "read", + "response.manage": "manage", + "response.read": "read", + "response.write": "write", + "survey.delete": "write", + "survey.manage": "manage", + "survey.publish": "write", + "survey.read": "read", + "survey.response_export": "read", + "survey.response_read": "read", + "survey.write": "write", + } as const; + const isDerived = action in derivedPermission; + expect(checkPermission).toHaveBeenCalledWith({ + permission: isDerived ? derivedPermission[action as keyof typeof derivedPermission] : permission, + resource: isDerived + ? { objectId: "workspace-1", objectType: "workspace" } + : { + objectId: resourceType === "feedbackDirectoryAssignment" ? "assignment-1" : "resource-1", + objectType: + resourceType === "apiKey" + ? "api_key" + : resourceType === "feedbackDirectory" + ? "feedback_directory" + : resourceType === "feedbackDirectoryAssignment" + ? "feedback_directory_assignment" + : resourceType, + }, + subject: { objectId: "actor-1", objectType: actorType === "apiKey" ? "api_key" : "user" }, + }); + } + } + } + }); + + test("maps API-key actors and resources to api_key", async () => { + await expect( + spicedbEvaluator.can({ type: "apiKey", id: "actor-key" }, "apiKey.manage", { + type: "apiKey", + id: "resource-key", + }) + ).resolves.toBe(true); + + expect(checkPermission).toHaveBeenCalledWith({ + permission: "manage", + resource: { objectId: "resource-key", objectType: "api_key" }, + subject: { objectId: "actor-key", objectType: "api_key" }, + }); + }); + + test.each([ + ["manager", "manage_access", true], + ["owner", "write", true], + ["disabled", null, false], + ])("honors the user-management floor %s", async (minimumRole, expectedPermission, allowed) => { + constantsMock.minimumRole = minimumRole; + + await expect( + checkSpicedbPermissionAtScope( + { type: "user", id: "user-1" }, + "organization.manage_access", + { type: "organization", id: "org-1" }, + { + actorValid: true, + organizationId: "org-1", + permissionResource: { type: "organization", id: "org-1" }, + } + ) + ).resolves.toBe(allowed); + + if (expectedPermission) { + expect(checkPermission).toHaveBeenCalledWith( + expect.objectContaining({ permission: expectedPermission }) + ); + } else { + expect(getAuthzedClient).not.toHaveBeenCalled(); + } + }); + + test("does not apply the user-management floor to API keys", async () => { + constantsMock.minimumRole = "disabled"; + + await spicedbEvaluator.can({ type: "apiKey", id: "key-1" }, "organization.manage_access", { + type: "organization", + id: "org-1", + }); + + expect(checkPermission).toHaveBeenCalledWith(expect.objectContaining({ permission: "manage_access" })); + }); + + test("denies missing resources and invalid actors without constructing the client", async () => { + vi.mocked(resolveAuthorizationScope).mockResolvedValueOnce(null); + await expect( + spicedbEvaluator.can({ type: "user", id: "user-1" }, "workspace.read", { + type: "workspace", + id: "missing", + }) + ).resolves.toBe(false); + + await expect( + checkSpicedbPermissionAtScope( + { type: "user", id: "missing" }, + "workspace.read", + { type: "workspace", id: "workspace-1" }, + { + actorValid: false, + organizationId: "org-1", + permissionResource: { type: "workspace", id: "workspace-1" }, + } + ) + ).resolves.toBe(false); + + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("returns the facade decision and propagates operational errors", async () => { + checkPermission.mockResolvedValueOnce({ allowed: false }); + await expect( + spicedbEvaluator.can({ type: "user", id: "user-1" }, "survey.read", { type: "survey", id: "survey-1" }) + ).resolves.toBe(false); + + const databaseFailure = new Error("database unavailable"); + vi.mocked(resolveAuthorizationScope).mockRejectedValueOnce(databaseFailure); + await expect( + spicedbEvaluator.can({ type: "user", id: "user-1" }, "survey.read", { type: "survey", id: "survey-1" }) + ).rejects.toBe(databaseFailure); + }); +}); diff --git a/apps/web/lib/authorization/spicedb-evaluator.ts b/apps/web/lib/authorization/spicedb-evaluator.ts new file mode 100644 index 000000000000..70d7c3fc56de --- /dev/null +++ b/apps/web/lib/authorization/spicedb-evaluator.ts @@ -0,0 +1,112 @@ +import "server-only"; +import { getAuthzedClient } from "@/lib/authzed/client"; +import { assertAuthzedProjectionFreshness } from "@/lib/authzed/outbox-freshness"; +import { USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants"; +import { + AUTHORIZATION_PERMISSION_MAP, + type TAuthorizationAction, + type TAuthorizationActor, + type TAuthorizationResourceForAction, + type TAuthorizationResourceType, +} from "./contract"; +import type { AuthorizationEvaluator } from "./evaluator"; +import { getSpicedbObjectType } from "./object-type"; +import { type TResolvedAuthorizationScope, resolveAuthorizationScope } from "./source-scope"; + +const parseAction = ( + action: TAuthorizationAction +): Readonly<{ permission: string; resourceType: TAuthorizationResourceType }> => { + const separator = action.indexOf("."); + return { + permission: action.slice(separator + 1), + resourceType: action.slice(0, separator) as TAuthorizationResourceType, + }; +}; + +const WORKSPACE_PERMISSION_FOR_DERIVED_ACTION = { + "dashboard.read": "read", + "dashboard.write": "write", + "response.export": "read", + "response.manage": "manage", + "response.read": "read", + "response.write": "write", + "survey.delete": "write", + "survey.manage": "manage", + "survey.publish": "write", + "survey.read": "read", + "survey.response_export": "read", + "survey.response_read": "read", + "survey.write": "write", +} as const satisfies Partial>; + +const getPermission = ( + actor: TAuthorizationActor, + action: TAuthorizationAction, + resourceType: TAuthorizationResourceType +): string | null => { + const parsed = parseAction(action); + if ( + parsed.resourceType !== resourceType || + !(AUTHORIZATION_PERMISSION_MAP[resourceType] as readonly string[]).includes(parsed.permission) + ) { + throw new Error(`Invalid authorization action/resource combination`); + } + + if (actor.type === "user" && action === "organization.manage_access") { + switch (USER_MANAGEMENT_MINIMUM_ROLE) { + case "disabled": + return null; + case "owner": + return "write"; + case "manager": + return "manage_access"; + } + } + + if (action in WORKSPACE_PERMISSION_FOR_DERIVED_ACTION) { + return WORKSPACE_PERMISSION_FOR_DERIVED_ACTION[ + action as keyof typeof WORKSPACE_PERMISSION_FOR_DERIVED_ACTION + ]; + } + + return parsed.permission; +}; + +export const checkSpicedbPermissionAtScope = async ( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction>, + scope: TResolvedAuthorizationScope +): Promise => { + if (!scope.actorValid) return false; + + await assertAuthzedProjectionFreshness(); + + const permission = getPermission(actor, action, resource.type); + if (!permission) return false; + + const decision = await getAuthzedClient().checkPermission({ + permission, + resource: { + objectId: scope.permissionResource.id, + objectType: getSpicedbObjectType(scope.permissionResource.type), + }, + subject: { + objectId: actor.id, + objectType: getSpicedbObjectType(actor.type), + }, + }); + + return decision.allowed; +}; + +export const spicedbEvaluator: AuthorizationEvaluator = { + async can( + actor: TAuthorizationActor, + action: TAction, + resource: TAuthorizationResourceForAction> + ): Promise { + const scope = await resolveAuthorizationScope(actor, resource); + return scope ? checkSpicedbPermissionAtScope(actor, action, resource, scope) : false; + }, +}; diff --git a/apps/web/lib/authzed/__mocks__/client-dependencies.ts b/apps/web/lib/authzed/__mocks__/client-dependencies.ts new file mode 100644 index 000000000000..38063ad5a5ea --- /dev/null +++ b/apps/web/lib/authzed/__mocks__/client-dependencies.ts @@ -0,0 +1,74 @@ +import { vi } from "vitest"; + +export const sdkMocks = { + checkPermission: vi.fn(), + close: vi.fn(), + deadlineInterceptor: vi.fn((timeoutMs: number) => ({ timeoutMs })), + deleteRelationships: vi.fn(), + diffSchema: vi.fn(), + lookupResources: vi.fn(), + newClient: vi.fn(), + readRelationships: vi.fn(), + readSchema: vi.fn(), + writeRelationships: vi.fn(), + writeSchema: vi.fn(), +}; + +export const configMocks = { + isAuthzedEnabled: vi.fn(), +}; + +export const envMock = { + AUTHZED_CONSISTENCY: undefined as "minimize_latency" | "fully_consistent" | undefined, + AUTHZED_ENDPOINT: "spicedb:50051" as string | undefined, + AUTHZED_INSECURE: "true" as "true" | "false" | "1" | "0" | undefined, + AUTHZED_SYSTEM_KEY: "formbricks" as string | undefined, + AUTHZED_TOKEN: "private-token" as string | undefined, +}; + +export const retryMocks = { + execute: vi.fn((_operation: string, request: () => Promise) => request()), +}; + +vi.mock("@authzed/authzed-node", () => ({ + deadlineInterceptor: sdkMocks.deadlineInterceptor, + v1: { + ClientSecurity: { + INSECURE_PLAINTEXT_CREDENTIALS: 2, + SECURE: 0, + }, + CheckPermissionResponse_Permissionship: { + CONDITIONAL_PERMISSION: 3, + HAS_PERMISSION: 2, + NO_PERMISSION: 1, + UNSPECIFIED: 0, + }, + LookupPermissionship: { + CONDITIONAL_PERMISSION: 2, + HAS_PERMISSION: 1, + UNSPECIFIED: 0, + }, + // Mirrors the real enum. A mock that omitted it would make the facade's completeness assertion + // throw a TypeError instead of exercising it. + DeleteRelationshipsResponse_DeletionProgress: { + COMPLETE: 1, + PARTIAL: 2, + UNSPECIFIED: 0, + }, + NewClient: sdkMocks.newClient, + RelationshipUpdate_Operation: { + DELETE: 3, + TOUCH: 2, + }, + }, +})); + +vi.mock("@/lib/env", () => ({ env: envMock })); + +vi.mock("../config", () => ({ + isAuthzedEnabled: configMocks.isAuthzedEnabled, +})); + +vi.mock("../retry", () => ({ + executeAuthzedOperation: retryMocks.execute, +})); diff --git a/apps/web/lib/authzed/__mocks__/logger.ts b/apps/web/lib/authzed/__mocks__/logger.ts new file mode 100644 index 000000000000..a537655fdab2 --- /dev/null +++ b/apps/web/lib/authzed/__mocks__/logger.ts @@ -0,0 +1,10 @@ +import { vi } from "vitest"; + +export const loggerMocks = { + debug: vi.fn(), + warn: vi.fn(), +}; + +vi.mock("@formbricks/logger", () => ({ + logger: loggerMocks, +})); diff --git a/apps/web/lib/authzed/api-key.test.ts b/apps/web/lib/authzed/api-key.test.ts new file mode 100644 index 000000000000..dcec2463d56c --- /dev/null +++ b/apps/web/lib/authzed/api-key.test.ts @@ -0,0 +1,568 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { reconcileApiKeyRelationships } from "./api-key"; +import { type TAuthzedRelationshipUpdate, getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; + +const clientMocks = { + deleteRelationships: vi.fn(), + writeRelationships: vi.fn(), +}; + +vi.mock("@formbricks/database", () => ({ + prisma: { + apiKey: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock("./client", () => ({ + getAuthzedClient: vi.fn(), +})); + +vi.mock("./config", () => ({ + isAuthzedEnabled: vi.fn(), +})); + +const API_KEY_ID = "api-key-private-id"; +const ORGANIZATION_ID = "organization-private-id"; +const WORKSPACE_ID = "workspace-private-id"; + +const createSnapshot = ({ + apiKeyId = API_KEY_ID, + organizationAccess = { + accessControl: { + read: false, + write: false, + }, + }, + permission = "read", + workspaceId = WORKSPACE_ID, +}: Readonly<{ + apiKeyId?: string; + organizationAccess?: unknown; + permission?: "manage" | "read" | "write" | null; + workspaceId?: string; +}> = {}) => ({ + apiKeyWorkspaces: + permission === null + ? [] + : [ + { + permission, + workspaceId, + }, + ], + id: apiKeyId, + organizationAccess, + organizationId: ORGANIZATION_ID, +}); + +const setStableSnapshot = ( + options: Parameters[0] = {} +): ReturnType => { + const snapshot = createSnapshot(options); + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([snapshot] as never); + return snapshot; +}; + +describe("API key relationship projection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(getAuthzedClient).mockReturnValue( + clientMocks as unknown as ReturnType + ); + clientMocks.deleteRelationships.mockResolvedValue(undefined); + clientMocks.writeRelationships.mockResolvedValue(undefined); + setStableSnapshot(); + }); + + test("reads only authorization-bearing API key fields", async () => { + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + expect(prisma.apiKey.findMany).toHaveBeenCalledWith({ + where: { + id: { + in: [API_KEY_ID], + }, + }, + select: { + apiKeyWorkspaces: { + select: { + permission: true, + workspaceId: true, + }, + orderBy: { + workspaceId: "asc", + }, + }, + id: true, + organizationAccess: true, + organizationId: true, + }, + orderBy: { + id: "asc", + }, + }); + + const serializedQuery = JSON.stringify(vi.mocked(prisma.apiKey.findMany).mock.calls[0]); + expect(serializedQuery).not.toContain("hashedKey"); + expect(serializedQuery).not.toContain("lookupHash"); + expect(serializedQuery).not.toContain("lastUsedAt"); + expect(serializedQuery).not.toContain("createdBy"); + }); + + test("projects the API key organization parent", async () => { + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + expect(clientMocks.writeRelationships.mock.calls.flatMap(([batch]) => batch)).toContainEqual({ + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: API_KEY_ID, objectType: "api_key" }, + subject: { objectId: ORGANIZATION_ID, objectType: "organization" }, + }, + }); + }); + + test("removes every previous parent and organization access edge before restoring current access", async () => { + setStableSnapshot({ + organizationAccess: { accessControl: { read: true, write: false } }, + }); + + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + relation: "organization", + resourceId: API_KEY_ID, + resourceType: "api_key", + }); + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + relation: "api_key_reader", + resourceType: "organization", + subject: { objectId: API_KEY_ID, objectType: "api_key" }, + }); + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + relation: "api_key_writer", + resourceType: "organization", + subject: { objectId: API_KEY_ID, objectType: "api_key" }, + }); + expect(Math.max(...clientMocks.deleteRelationships.mock.invocationCallOrder)).toBeLessThan( + clientMocks.writeRelationships.mock.invocationCallOrder[0] + ); + }); + + test.each([ + [false, false, []], + [true, false, ["api_key_reader"]], + [false, true, ["api_key_writer"]], + [true, true, ["api_key_reader", "api_key_writer"]], + ] as const)( + "projects organization access read=%s write=%s independently", + async (read, write, touchedRelations) => { + setStableSnapshot({ + organizationAccess: { + accessControl: { + read, + write, + }, + }, + }); + + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + const organizationUpdates = clientMocks.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.resource.objectType === "organization"); + expect(organizationUpdates).toHaveLength(2); + expect( + organizationUpdates + .filter(({ operation }) => operation === "touch") + .map(({ relationship }) => relationship.relation) + ).toEqual(touchedRelations); + expect(organizationUpdates.filter(({ operation }) => operation === "delete")).toHaveLength( + 2 - touchedRelations.length + ); + } + ); + + test.each([ + undefined, + null, + [], + {}, + { accessControl: null }, + { accessControl: { read: "true", write: 1 } }, + ])("treats malformed organization access as no access", async (organizationAccess) => { + setStableSnapshot({ organizationAccess }); + + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + const organizationUpdates = clientMocks.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.resource.objectType === "organization"); + expect(organizationUpdates).toHaveLength(2); + expect(organizationUpdates.every(({ operation }) => operation === "delete")).toBe(true); + }); + + test.each([ + ["read", "reader"], + ["write", "writer"], + ["manage", "manager"], + ] as const)( + "projects a %s workspace scope and deletes its alternate grants", + async (permission, relation) => { + setStableSnapshot({ permission }); + + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + const workspaceUpdates = clientMocks.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.resource.objectType === "workspace"); + expect(workspaceUpdates).toHaveLength(3); + expect(workspaceUpdates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ + relation, + subject: { objectId: API_KEY_ID, objectType: "api_key" }, + }), + }), + ]) + ); + expect(workspaceUpdates.filter(({ operation }) => operation === "delete")).toHaveLength(2); + } + ); + + test("projects multiple workspace scopes independently", async () => { + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([ + { + ...createSnapshot({ permission: null }), + apiKeyWorkspaces: [ + { permission: "read", workspaceId: "workspace-a" }, + { permission: "manage", workspaceId: "workspace-b" }, + ], + }, + ] as never); + + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + const touchedWorkspaceRelations = clientMocks.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter( + ({ operation, relationship }) => + operation === "touch" && relationship.resource.objectType === "workspace" + ) + .map(({ relationship }) => relationship.relation); + expect(touchedWorkspaceRelations).toEqual(["reader", "manager"]); + }); + + test("deletes a workspace scope observed before a concurrent source removal", async () => { + const withScope = createSnapshot({ permission: "manage" }); + const withoutScope = createSnapshot({ permission: null }); + vi.mocked(prisma.apiKey.findMany) + .mockResolvedValueOnce([withScope] as never) + .mockResolvedValueOnce([withoutScope] as never) + .mockResolvedValueOnce([withoutScope] as never) + .mockResolvedValueOnce([withoutScope] as never); + + await expect(reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] })).resolves.toEqual({ + passes: 2, + status: "projected", + }); + + const secondPassUpdates = clientMocks.writeRelationships.mock + .calls[1][0] as ReadonlyArray; + const workspaceUpdates = secondPassUpdates.filter( + ({ relationship }) => relationship.resource.objectType === "workspace" + ); + expect(workspaceUpdates).toHaveLength(3); + expect(workspaceUpdates.every(({ operation }) => operation === "delete")).toBe(true); + }); + + test("reconciles a complete snapshot again when organization access changes concurrently", async () => { + const reader = createSnapshot({ + organizationAccess: { accessControl: { read: true, write: false } }, + }); + const writer = createSnapshot({ + organizationAccess: { accessControl: { read: false, write: true } }, + }); + vi.mocked(prisma.apiKey.findMany) + .mockResolvedValueOnce([reader] as never) + .mockResolvedValueOnce([writer] as never) + .mockResolvedValueOnce([writer] as never) + .mockResolvedValueOnce([writer] as never); + + await expect(reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] })).resolves.toEqual({ + passes: 2, + status: "projected", + }); + + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(2); + }); + + test("converges when a missing API key is recreated during reconciliation", async () => { + const recreatedApiKey = createSnapshot({ + organizationAccess: { accessControl: { read: true, write: false } }, + permission: "write", + }); + vi.mocked(prisma.apiKey.findMany) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([recreatedApiKey] as never) + .mockResolvedValueOnce([recreatedApiKey] as never) + .mockResolvedValueOnce([recreatedApiKey] as never); + + await expect(reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] })).resolves.toEqual({ + passes: 2, + status: "projected", + }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledTimes(6); + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(1); + expect(clientMocks.writeRelationships.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ relation: "api_key_reader" }), + }), + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ relation: "writer" }), + }), + ]) + ); + }); + + test("returns a stable failure after three changing snapshots", async () => { + const reader = createSnapshot({ + organizationAccess: { accessControl: { read: true, write: false } }, + }); + const writer = createSnapshot({ + organizationAccess: { accessControl: { read: false, write: true } }, + }); + vi.mocked(prisma.apiKey.findMany) + .mockResolvedValueOnce([reader] as never) + .mockResolvedValueOnce([writer] as never) + .mockResolvedValueOnce([reader] as never) + .mockResolvedValueOnce([writer] as never) + .mockResolvedValueOnce([reader] as never) + .mockResolvedValueOnce([writer] as never); + + await expect(reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] })).resolves.toEqual({ + attempts: 3, + code: "authzed_projection_unstable", + retryable: false, + status: "failed", + }); + }); + + test("deduplicates and deterministically orders API key targets", async () => { + const secondApiKeyId = "another-api-key"; + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([ + createSnapshot({ apiKeyId: secondApiKeyId }), + createSnapshot(), + ] as never); + + await reconcileApiKeyRelationships({ + apiKeyIds: [API_KEY_ID, secondApiKeyId, API_KEY_ID], + }); + + expect(prisma.apiKey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: { + in: [secondApiKeyId, API_KEY_ID], + }, + }, + }) + ); + }); + + test("packs at most 1,000 updates without splitting an organization access pair", async () => { + const apiKeys = Array.from({ length: 334 }, (_, index) => + createSnapshot({ apiKeyId: `api-key-${index}`, permission: null }) + ); + vi.mocked(prisma.apiKey.findMany).mockResolvedValue(apiKeys as never); + + await reconcileApiKeyRelationships({ apiKeyIds: apiKeys.map(({ id }) => id) }); + + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(2); + expect(clientMocks.writeRelationships.mock.calls[0][0]).toHaveLength(1_000); + expect(clientMocks.writeRelationships.mock.calls[1][0]).toHaveLength(2); + const finalBatch = clientMocks.writeRelationships.mock + .calls[1][0] as ReadonlyArray; + expect(finalBatch.every(({ relationship }) => relationship.resource.objectType === "organization")).toBe( + true + ); + }); + + test("cleans every resource and subject relationship for a missing API key", async () => { + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([]); + + await reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] }); + + expect(clientMocks.deleteRelationships).toHaveBeenNthCalledWith(1, { + resourceId: API_KEY_ID, + resourceType: "api_key", + }); + expect(clientMocks.deleteRelationships).toHaveBeenNthCalledWith(2, { + resourceType: "organization", + subject: { objectId: API_KEY_ID, objectType: "api_key" }, + }); + expect(clientMocks.deleteRelationships).toHaveBeenNthCalledWith(3, { + resourceType: "workspace", + subject: { objectId: API_KEY_ID, objectType: "api_key" }, + }); + }); + + test("bounds parallel relationship deletion for large API key cascades", async () => { + const missingApiKeyIds = Array.from({ length: 12 }, (_, index) => `missing-api-key-${index}`); + let activeDeletes = 0; + let maxActiveDeletes = 0; + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([]); + clientMocks.deleteRelationships.mockImplementation(async () => { + activeDeletes++; + maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes); + await Promise.resolve(); + activeDeletes--; + }); + + await reconcileApiKeyRelationships({ apiKeyIds: missingApiKeyIds }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledTimes(missingApiKeyIds.length * 3); + expect(maxActiveDeletes).toBe(AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES); + }); + + test("returns disabled before reading PostgreSQL or constructing a client", async () => { + vi.mocked(isAuthzedEnabled).mockReturnValue(false); + + await expect(reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] })).resolves.toEqual({ + status: "disabled", + }); + + expect(prisma.apiKey.findMany).not.toHaveBeenCalled(); + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("treats an empty target set as a zero-pass no-op without constructing a client", async () => { + await expect(reconcileApiKeyRelationships({})).resolves.toEqual({ + passes: 0, + status: "projected", + }); + + expect(prisma.apiKey.findMany).not.toHaveBeenCalled(); + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("contains operational failures with sanitized logs and results", async () => { + clientMocks.writeRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 3, + cause: new Error("raw-sdk-message-with-private-api-key"), + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "write_relationships", + retryable: true, + }) + ); + + await expect(reconcileApiKeyRelationships({ apiKeyIds: [API_KEY_ID] })).resolves.toEqual({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }); + + const serializedLog = JSON.stringify(vi.mocked(logger.warn).mock.calls[0]); + expect(serializedLog).not.toContain(API_KEY_ID); + expect(serializedLog).not.toContain(ORGANIZATION_ID); + expect(serializedLog).not.toContain(WORKSPACE_ID); + expect(serializedLog).not.toContain("private-api-key"); + expect(serializedLog).not.toContain("raw-sdk-message"); + }); + + describe("explicitly named workspace scopes", () => { + const workspaceUpdatesFor = (workspaceId: string) => + clientMocks.writeRelationships.mock.calls + .flatMap(([updates]) => updates) + .filter( + ({ relationship }) => + relationship.resource.objectType === "workspace" && relationship.resource.objectId === workspaceId + ); + + test("deletes a scope the source no longer grants", async () => { + // A revoked scope is absent from the snapshot, so without being named nothing would target it + // and the stale relationship would survive indefinitely. + setStableSnapshot({ permission: null }); + + await expect( + reconcileApiKeyRelationships({ + apiKeyIds: [API_KEY_ID], + apiKeyWorkspaceGrants: [{ apiKeyId: API_KEY_ID, workspaceId: "revoked-workspace" }], + }) + ).resolves.toEqual({ passes: 1, status: "projected" }); + + const updates = workspaceUpdatesFor("revoked-workspace"); + expect(updates).toHaveLength(3); + expect(updates.every(({ operation }) => operation === "delete")).toBe(true); + }); + + test("still touches the granted permission when the source does hold the scope", async () => { + setStableSnapshot({ permission: "write", workspaceId: "granted-workspace" }); + + await reconcileApiKeyRelationships({ + apiKeyIds: [API_KEY_ID], + apiKeyWorkspaceGrants: [{ apiKeyId: API_KEY_ID, workspaceId: "granted-workspace" }], + }); + + // Naming a target must not force a delete: the decision still comes from the source snapshot. + const updates = workspaceUpdatesFor("granted-workspace"); + expect(updates).toHaveLength(3); + expect(updates.filter(({ operation }) => operation === "touch")).toHaveLength(1); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ relation: "writer" }), + }), + ]) + ); + }); + + test("implies the API key so a caller repairing one scope need not also name the key", async () => { + setStableSnapshot({ permission: null }); + + await reconcileApiKeyRelationships({ + apiKeyWorkspaceGrants: [{ apiKeyId: API_KEY_ID, workspaceId: "revoked-workspace" }], + }); + + expect(prisma.apiKey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: { in: [API_KEY_ID] } } }) + ); + expect(workspaceUpdatesFor("revoked-workspace")).toHaveLength(3); + }); + + test("reconciles both a named scope and one discovered from the source", async () => { + setStableSnapshot({ permission: "read", workspaceId: "granted-workspace" }); + + await reconcileApiKeyRelationships({ + apiKeyWorkspaceGrants: [{ apiKeyId: API_KEY_ID, workspaceId: "revoked-workspace" }], + }); + + expect(workspaceUpdatesFor("granted-workspace")).toHaveLength(3); + expect(workspaceUpdatesFor("revoked-workspace")).toHaveLength(3); + }); + }); +}); diff --git a/apps/web/lib/authzed/api-key.ts b/apps/web/lib/authzed/api-key.ts new file mode 100644 index 000000000000..7f58ade97f95 --- /dev/null +++ b/apps/web/lib/authzed/api-key.ts @@ -0,0 +1,267 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import type { ApiKeyPermission } from "@formbricks/database/prisma"; +import { type TAuthzedRelationshipFilter, type TAuthzedRelationshipUpdate, getAuthzedClient } from "./client"; +import { deleteOrganizationParentRelationships } from "./organization-parent"; +import { + AUTHZED_MAX_RECONCILIATION_PASSES, + AuthzedProjectionUnstableError, + type TAuthzedProjectionResult, + runBestEffortProjection, +} from "./projection"; +import { deleteRelationshipsInBoundedBatches, packRelationshipUpdateGroups } from "./relationship-batches"; +import { + ORGANIZATION_ACCESS_RELATIONS, + type TOrganizationAccessSnapshot, + WORKSPACE_API_KEY_RELATIONS as WORKSPACE_RELATIONS, + normalizeOrganizationAccess, +} from "./relationship-map"; + +const WORKSPACE_RELATION_NAMES = Object.values(WORKSPACE_RELATIONS); + +export type TApiKeyWorkspaceProjectionTarget = Readonly<{ + apiKeyId: string; + workspaceId: string; +}>; + +export type TApiKeyProjectionTargets = Readonly<{ + apiKeyIds?: ReadonlyArray; + /** + * Workspace scopes to reconcile even if PostgreSQL no longer grants them. + * + * Workspace targets are otherwise discovered from the key's current grants, so a scope revoked + * outside a mutation hook leaves an unreachable relationship: it is absent from the snapshot, so + * nothing names it, so nothing deletes it. Naming a pair here seeds it as a target, and an absent + * grant then deletes all three workspace relations. Each named key is implied as a target. + */ + apiKeyWorkspaceGrants?: ReadonlyArray; +}>; + +type TApiKeyWorkspaceSnapshot = Readonly<{ + permission: ApiKeyPermission; + workspaceId: string; +}>; + +type TApiKeySnapshot = ReadonlyArray< + Readonly<{ + apiKeyWorkspaces: ReadonlyArray; + id: string; + organizationAccess: TOrganizationAccessSnapshot; + organizationId: string; + }> +>; + +type TNormalizedTargets = Readonly<{ + apiKeyIds: ReadonlyArray; + /** Workspace IDs to reconcile per API key, independent of what the source snapshot grants. */ + seededWorkspaceIds: ReadonlyMap>; +}>; + +const normalizeTargets = (targets: TApiKeyProjectionTargets): TNormalizedTargets => { + const apiKeyIds = new Set(targets.apiKeyIds ?? []); + const seededWorkspaceIds = new Map>(); + + for (const { apiKeyId, workspaceId } of targets.apiKeyWorkspaceGrants ?? []) { + // A named grant implies its key, so a caller repairing one stale scope need not also list the key. + apiKeyIds.add(apiKeyId); + const workspaceIds = seededWorkspaceIds.get(apiKeyId) ?? new Set(); + workspaceIds.add(workspaceId); + seededWorkspaceIds.set(apiKeyId, workspaceIds); + } + + return { + apiKeyIds: [...apiKeyIds].sort((left, right) => left.localeCompare(right)), + seededWorkspaceIds, + }; +}; + +const readSnapshot = async (apiKeyIds: ReadonlyArray): Promise => { + const apiKeys = await prisma.apiKey.findMany({ + where: { + id: { + in: [...apiKeyIds], + }, + }, + select: { + apiKeyWorkspaces: { + select: { + permission: true, + workspaceId: true, + }, + orderBy: { + workspaceId: "asc", + }, + }, + id: true, + organizationAccess: true, + organizationId: true, + }, + orderBy: { + id: "asc", + }, + }); + + return apiKeys.map((apiKey) => ({ + apiKeyWorkspaces: apiKey.apiKeyWorkspaces, + id: apiKey.id, + organizationAccess: normalizeOrganizationAccess(apiKey.organizationAccess), + organizationId: apiKey.organizationId, + })); +}; + +const snapshotsMatch = (left: TApiKeySnapshot, right: TApiKeySnapshot): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const createParentUpdate = (apiKeyId: string, organizationId: string): TAuthzedRelationshipUpdate => ({ + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: apiKeyId, objectType: "api_key" }, + subject: { objectId: organizationId, objectType: "organization" }, + }, +}); + +const createOrganizationAccessUpdates = ( + apiKeyId: string, + organizationId: string, + organizationAccess: TOrganizationAccessSnapshot +): ReadonlyArray => + (Object.keys(ORGANIZATION_ACCESS_RELATIONS) as ReadonlyArray).map( + (permission) => ({ + operation: organizationAccess[permission] ? "touch" : "delete", + relationship: { + relation: ORGANIZATION_ACCESS_RELATIONS[permission], + resource: { objectId: organizationId, objectType: "organization" }, + subject: { objectId: apiKeyId, objectType: "api_key" }, + }, + }) + ); + +const createWorkspaceUpdates = ( + apiKeyId: string, + workspaceId: string, + permission: ApiKeyPermission | null +): ReadonlyArray => + WORKSPACE_RELATION_NAMES.map((relation) => ({ + operation: permission !== null && relation === WORKSPACE_RELATIONS[permission] ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: apiKeyId, objectType: "api_key" }, + }, + })); + +const addObservedWorkspaceTargets = ( + observedWorkspaceIds: Map>, + snapshot: TApiKeySnapshot +): void => { + for (const apiKey of snapshot) { + const workspaceIds = observedWorkspaceIds.get(apiKey.id); + if (!workspaceIds) { + continue; + } + + for (const grant of apiKey.apiKeyWorkspaces) { + workspaceIds.add(grant.workspaceId); + } + } +}; + +const writeSnapshot = async ( + apiKeyIds: ReadonlyArray, + snapshot: TApiKeySnapshot, + observedWorkspaceIds: ReadonlyMap> +): Promise => { + const client = getAuthzedClient(); + const apiKeysById = new Map(snapshot.map((apiKey) => [apiKey.id, apiKey])); + const updateGroups: TAuthzedRelationshipUpdate[][] = []; + + for (const apiKey of snapshot) { + updateGroups.push([createParentUpdate(apiKey.id, apiKey.organizationId)]); + updateGroups.push([ + ...createOrganizationAccessUpdates(apiKey.id, apiKey.organizationId, apiKey.organizationAccess), + ]); + + const currentGrants = new Map( + apiKey.apiKeyWorkspaces.map((grant) => [grant.workspaceId, grant.permission]) + ); + const workspaceIds = [...(observedWorkspaceIds.get(apiKey.id) ?? [])].sort((left, right) => + left.localeCompare(right) + ); + + for (const workspaceId of workspaceIds) { + updateGroups.push([ + ...createWorkspaceUpdates(apiKey.id, workspaceId, currentGrants.get(workspaceId) ?? null), + ]); + } + } + + await deleteOrganizationParentRelationships( + client, + snapshot.map(({ id }) => ({ resourceId: id, resourceType: "api_key" })) + ); + await deleteRelationshipsInBoundedBatches( + client, + snapshot.flatMap(({ id }) => + Object.values(ORGANIZATION_ACCESS_RELATIONS).map((relation) => ({ + relation, + resourceType: "organization", + subject: { objectId: id, objectType: "api_key" }, + })) + ) + ); + + for (const batch of packRelationshipUpdateGroups(updateGroups)) { + await client.writeRelationships(batch); + } + + const deletionFilters: TAuthzedRelationshipFilter[] = []; + for (const apiKeyId of apiKeyIds) { + if (apiKeysById.has(apiKeyId)) { + continue; + } + + deletionFilters.push({ resourceId: apiKeyId, resourceType: "api_key" }); + deletionFilters.push({ + resourceType: "organization", + subject: { objectId: apiKeyId, objectType: "api_key" }, + }); + deletionFilters.push({ + resourceType: "workspace", + subject: { objectId: apiKeyId, objectType: "api_key" }, + }); + } + + await deleteRelationshipsInBoundedBatches(client, deletionFilters); +}; + +export const reconcileApiKeyRelationships = async ( + targets: TApiKeyProjectionTargets +): Promise => + runBestEffortProjection("reconcile_api_key_relationships", "api_key", async () => { + const { apiKeyIds, seededWorkspaceIds } = normalizeTargets(targets); + if (apiKeyIds.length === 0) { + return 0; + } + + // Workspace scopes are immutable today, so a current snapshot contains every workspace a normal + // projection needs. A scope that disappeared anyway — removed outside a mutation hook, or by a + // future edit path — is invisible to the snapshot, which is what `apiKeyWorkspaceGrants` is for: + // seeded targets are reconciled regardless, then extended by whatever each pass observes. + const observedWorkspaceIds = new Map( + apiKeyIds.map((apiKeyId) => [apiKeyId, new Set(seededWorkspaceIds.get(apiKeyId) ?? [])]) + ); + + for (let pass = 1; pass <= AUTHZED_MAX_RECONCILIATION_PASSES; pass++) { + const sourceSnapshot = await readSnapshot(apiKeyIds); + addObservedWorkspaceTargets(observedWorkspaceIds, sourceSnapshot); + await writeSnapshot(apiKeyIds, sourceSnapshot, observedWorkspaceIds); + + const verifiedSnapshot = await readSnapshot(apiKeyIds); + if (snapshotsMatch(sourceSnapshot, verifiedSnapshot)) { + return pass; + } + } + + throw new AuthzedProjectionUnstableError(); + }); diff --git a/apps/web/lib/authzed/backfill-apply.ts b/apps/web/lib/authzed/backfill-apply.ts new file mode 100644 index 000000000000..45319995b24e --- /dev/null +++ b/apps/web/lib/authzed/backfill-apply.ts @@ -0,0 +1,29 @@ +import "server-only"; +import { reconcileApiKeyRelationships } from "./api-key"; +import type { TAuthzedBackfillApply } from "./backfill"; +import { + deleteFeedbackDirectoryAssignmentRelationships, + reconcileFeedbackDirectoryRelationships, +} from "./feedback-directory"; +import { reconcileOrganizationMemberships } from "./organization-membership"; +import { reconcileTeamWorkspaceRelationships } from "./team-workspace"; + +const INERT_RESULT = { passes: 0, status: "projected" } as const; + +/** No-op write capability used when the shared orchestrator runs in dry-run mode. */ +export const createAuthzedBackfillNoopApply = (): TAuthzedBackfillApply => ({ + deleteFeedbackDirectoryAssignmentResources: async () => INERT_RESULT, + reconcileApiKeys: async () => INERT_RESULT, + reconcileFeedbackDirectories: async () => INERT_RESULT, + reconcileMemberships: async () => INERT_RESULT, + reconcileTeamWorkspace: async () => INERT_RESULT, +}); + +/** Internal write capability shared by the operator CLI and the scheduled attributable repair. */ +export const createAuthzedBackfillApply = (): TAuthzedBackfillApply => ({ + deleteFeedbackDirectoryAssignmentResources: deleteFeedbackDirectoryAssignmentRelationships, + reconcileApiKeys: reconcileApiKeyRelationships, + reconcileFeedbackDirectories: reconcileFeedbackDirectoryRelationships, + reconcileMemberships: reconcileOrganizationMemberships, + reconcileTeamWorkspace: reconcileTeamWorkspaceRelationships, +}); diff --git a/apps/web/lib/authzed/backfill-boundary.test.ts b/apps/web/lib/authzed/backfill-boundary.test.ts new file mode 100644 index 000000000000..41aa279f4530 --- /dev/null +++ b/apps/web/lib/authzed/backfill-boundary.test.ts @@ -0,0 +1,127 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, test } from "vitest"; + +/** + * The backfill tooling performs **no authorization check**. It takes an organization or workspace ID and + * rewrites that tenant's permission graph, which is correct for an operator command running with the + * AuthZed system credential and catastrophic behind an HTTP surface: a "repair my organization" endpoint + * or server action wired to it would be a maximum-impact BOLA — rewrite any tenant's permissions by ID. + * + * `import "server-only"` does not prevent this. It blocks *client* imports, not request-path ones. So the + * boundary is asserted here instead: only the tooling's own modules and its command entry points may + * reach it. If exposing it ever becomes necessary it needs `assertCan(actor, "organization.manage", …)` + * in front and the whole-deployment scope removed — not an exemption added to this list. + */ + +const WEB_ROOT = new URL("../../", import.meta.url).pathname; + +/** Modules that may reach the backfill: the tooling itself, and the CLI entry points. */ +const ALLOWED_IMPORTER_PREFIXES = ["lib/authzed/", "scripts/"]; + +/** Test-only tooling that intentionally converges the disposable integration fixture. */ +const ALLOWED_IMPORTERS = ["integration/authzed.ts"]; + +const RESTRICTED_MODULES = ["backfill", "backfill-cli", "backfill-diff", "backfill-source"]; + +/** + * Every directory holding application source, plus `apps/web`'s own root modules. + * + * `instrumentation*.ts` and `proxy.ts` live at the root rather than under a directory and are as + * request-path as anything in `app/` — omitting them would leave the most sensitive files unchecked. + */ +const SEARCH_ROOTS = [".", "app", "integration", "lib", "modules", "scripts"]; + +const collectSourceFiles = (directory: string, recurse: boolean): ReadonlyArray => { + const entries: string[] = []; + + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryName = entry.name; + + if (entryName === "node_modules" || entryName === ".next") { + continue; + } + + const absolute = join(directory, entryName); + if (entry.isDirectory()) { + if (recurse) { + entries.push(...collectSourceFiles(absolute, true)); + } + continue; + } + if (/\.tsx?$/.test(entryName) && !/\.test\.tsx?$/.test(entryName)) { + entries.push(absolute); + } + } + + return entries; +}; + +/** + * Any reference to a restricted module as a module specifier. + * + * Deliberately not anchored to `from`: a static import is only one of the ways in. `await import(…)`, + * `require(…)`, and a bare side-effect import all reach the same module, and a fence that only caught the + * tidy spelling would be trivially — and silently — stepped around. + */ +const restrictedSpecifierPattern = (moduleName: string): RegExp => + // Both spellings: an aliased path (`@/lib/authzed/backfill`) and a relative one (`./backfill`). The + // relative form is what a re-export inside `lib/authzed/` would actually use, and it is the form that + // matters most — the offender scan below skips that directory, so the barrel check is the only thing + // standing between a one-line `export * from "./backfill"` and a request-path import. + new RegExp(String.raw`["'](?:[^"']*(?:lib/)?authzed/|\./)${moduleName}["']`); + +const importsRestrictedModule = (source: string): boolean => + RESTRICTED_MODULES.some((moduleName) => restrictedSpecifierPattern(moduleName).test(source)); + +describe("backfill module boundary", () => { + const files = SEARCH_ROOTS.flatMap((root) => collectSourceFiles(join(WEB_ROOT, root), root !== ".")); + + test("finds source files to check, so a broken search cannot pass silently", () => { + expect(files.length).toBeGreaterThan(500); + }); + + test("searches apps/web's root modules, which hold the request-path proxy and instrumentation", () => { + const rootModules = files + .map((absolute) => relative(WEB_ROOT, absolute)) + .filter((relativePath) => !relativePath.includes("/")); + + expect(rootModules).toContain("proxy.ts"); + expect(rootModules).toContain("instrumentation.ts"); + }); + + test("is imported only by the tooling itself and its command entry points", () => { + const offenders = files + .map((absolute) => ({ absolute, relativePath: relative(WEB_ROOT, absolute) })) + .filter( + ({ relativePath }) => + !ALLOWED_IMPORTERS.includes(relativePath) && + !ALLOWED_IMPORTER_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) + ) + .filter(({ absolute }) => importsRestrictedModule(readFileSync(absolute, "utf8"))) + .map(({ relativePath }) => relativePath); + + expect(offenders).toEqual([]); + }); + + test("is not re-exported from the barrel, which would launder it past the allowed prefix", () => { + // `lib/authzed/` is an allowed importer, so a re-export from inside it would make the tooling + // reachable as `@/lib/authzed` from anywhere — passing every check above. + expect(importsRestrictedModule(readFileSync(join(WEB_ROOT, "lib/authzed/index.ts"), "utf8"))).toBe(false); + }); + + test.each([ + ['export * from "./backfill";', "a star re-export"], + ['export { runAuthzedBackfill } from "./backfill";', "a named re-export"], + ['const m = await import("./backfill-cli");', "a dynamic import"], + ['import "./backfill-source";', "a bare side-effect import"], + ['import { runAuthzedBackfill } from "@/lib/authzed/backfill";', "an aliased import"], + ])("detects %s as reaching a restricted module", (source) => { + // Sentinels: the fence is only worth having if it recognizes the forms someone would actually write. + expect(importsRestrictedModule(source)).toBe(true); + }); + + test("does not flag an unrelated relative import", () => { + expect(importsRestrictedModule('import { getAuthzedClient } from "./client";')).toBe(false); + }); +}); diff --git a/apps/web/lib/authzed/backfill-cli-command.ts b/apps/web/lib/authzed/backfill-cli-command.ts new file mode 100644 index 000000000000..1922de056967 --- /dev/null +++ b/apps/web/lib/authzed/backfill-cli-command.ts @@ -0,0 +1,155 @@ +import "server-only"; +import { AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN } from "./constants"; + +export type TAuthzedBackfillCliCommand = Readonly<{ + afterOrganizationId?: string; + expectedEndpoint?: string; + maxPrune: number; + mode: "apply" | "dry_run"; + organizationId?: string; + prune: boolean; + workspaceId?: string; +}>; + +const CUID_PATTERN = /^[a-z0-9]{20,40}$/; +const POSITIVE_INTEGER_PATTERN = /^[1-9]\d{0,6}$/; + +const countFlag = (args: ReadonlyArray, name: string): number => + args.filter((arg) => arg.startsWith(`--${name}=`)).length; + +const readFlag = (args: ReadonlyArray, name: string): string | undefined => { + const prefix = `--${name}=`; + return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +}; + +const KNOWN_BOOLEAN_FLAGS = new Set(["--apply", "--confirm-prune", "--prune"]); + +const VALUE_FLAG_NAMES = [ + "after-organization-id", + "expected-endpoint", + "max-prune", + "organization-id", + "scope", + "workspace-id", +] as const; + +type TFlagSelection = Readonly<{ + afterOrganizationId?: string; + expectedEndpoint?: string; + organizationId?: string; + scope?: string; + workspaceId?: string; +}>; + +const hasOnlyKnownArguments = (args: ReadonlyArray): boolean => + args.every( + (arg) => KNOWN_BOOLEAN_FLAGS.has(arg) || VALUE_FLAG_NAMES.some((name) => arg.startsWith(`--${name}=`)) + ); + +const hasRepeatedFlag = (args: ReadonlyArray): boolean => + VALUE_FLAG_NAMES.some((name) => countFlag(args, name) > 1); + +const isScopeNamedUnambiguously = ({ organizationId, scope, workspaceId }: TFlagSelection): boolean => { + if (scope !== undefined && scope !== "all") { + return false; + } + + return ( + [scope === "all", organizationId !== undefined, workspaceId !== undefined].filter(Boolean).length <= 1 + ); +}; + +const areIdentifiersValid = ({ + afterOrganizationId, + organizationId, + workspaceId, +}: TFlagSelection): boolean => { + const ids = [organizationId, afterOrganizationId, workspaceId].filter( + (id): id is string => id !== undefined + ); + if (!ids.every((id) => CUID_PATTERN.test(id))) { + return false; + } + + return afterOrganizationId === undefined || (organizationId === undefined && workspaceId === undefined); +}; + +const resolveMaxPrune = (raw: string | undefined): number | undefined => { + if (raw === undefined) { + return AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN; + } + if (!POSITIVE_INTEGER_PATTERN.test(raw)) { + return undefined; + } + + const requested = Number(raw); + return requested > AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN ? undefined : requested; +}; + +const isPruneRequestPermitted = ({ + confirmed, + mode, + prune, + selection, +}: Readonly<{ + confirmed: boolean; + mode: "apply" | "dry_run"; + prune: boolean; + selection: TFlagSelection; +}>): boolean => { + if (!prune) { + return !confirmed; + } + if (mode !== "apply" || !confirmed || !selection.expectedEndpoint) { + return false; + } + + return ( + selection.organizationId !== undefined || selection.workspaceId !== undefined || selection.scope === "all" + ); +}; + +/** + * Parse argv without loading AuthZed configuration, the SDK client, or PostgreSQL. + * + * A dry run is the default. Pruning requires apply, confirmation, an explicit scope, and the expected endpoint; + * repeated or ambiguous flags are rejected rather than silently resolved. + */ +export const parseAuthzedBackfillCommand = ( + args: ReadonlyArray +): TAuthzedBackfillCliCommand | undefined => { + if (!hasOnlyKnownArguments(args) || hasRepeatedFlag(args)) { + return undefined; + } + + const mode = args.includes("--apply") ? "apply" : "dry_run"; + const prune = args.includes("--prune"); + const confirmed = args.includes("--confirm-prune"); + + const selection: TFlagSelection = { + afterOrganizationId: readFlag(args, "after-organization-id"), + expectedEndpoint: readFlag(args, "expected-endpoint"), + organizationId: readFlag(args, "organization-id"), + scope: readFlag(args, "scope"), + workspaceId: readFlag(args, "workspace-id"), + }; + + if (!isScopeNamedUnambiguously(selection) || !areIdentifiersValid(selection)) { + return undefined; + } + + const maxPrune = resolveMaxPrune(readFlag(args, "max-prune")); + if (maxPrune === undefined || !isPruneRequestPermitted({ confirmed, mode, prune, selection })) { + return undefined; + } + + return { + afterOrganizationId: selection.afterOrganizationId, + expectedEndpoint: selection.expectedEndpoint, + maxPrune, + mode, + organizationId: selection.organizationId, + prune, + workspaceId: selection.workspaceId, + }; +}; diff --git a/apps/web/lib/authzed/backfill-cli.test.ts b/apps/web/lib/authzed/backfill-cli.test.ts new file mode 100644 index 000000000000..2854e2e8de20 --- /dev/null +++ b/apps/web/lib/authzed/backfill-cli.test.ts @@ -0,0 +1,367 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TAuthzedBackfillResult } from "./backfill"; +import { parseAuthzedBackfillCommand, runAuthzedBackfillCli } from "./backfill-cli"; +import { AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; + +// Neutralize the real default dependencies at import time; behaviour comes from per-test overrides. +vi.mock("./api-key", () => ({ reconcileApiKeyRelationships: vi.fn() })); +vi.mock("./backfill", () => ({ runAuthzedBackfill: vi.fn() })); +vi.mock("./client", () => ({ closeAuthzedClient: vi.fn(), getAuthzedClient: vi.fn() })); +vi.mock("./config", () => ({ isAuthzedEnabled: vi.fn() })); +vi.mock("./feedback-directory", () => ({ + deleteFeedbackDirectoryAssignmentRelationships: vi.fn(), + reconcileFeedbackDirectoryRelationships: vi.fn(), +})); +vi.mock("./organization-membership", () => ({ reconcileOrganizationMemberships: vi.fn() })); +vi.mock("./team-workspace", () => ({ reconcileTeamWorkspaceRelationships: vi.fn() })); + +const ORGANIZATION_ID = "clhx8n2p40000qwer1234asdf"; +const OTHER_ORGANIZATION_ID = "clhx8n2p40001qwer1234asdf"; +const WORKSPACE_ID = "clhx8n2p40002qwer1234asdf"; +const ENDPOINT = "spicedb.internal:50051"; + +const result = (overrides: Partial = {}): TAuthzedBackfillResult => ({ + completedAtSnapshot: "revision-1", + counters: { + failed: 0, + ignored: 0, + invalid: 0, + mismatchedParents: 0, + mismatchedPermissions: 0, + missing: 0, + orphaned: 0, + pruned: 0, + reconciled: 1, + scanned: 1, + skipped: 0, + unmanaged: 0, + }, + failures: [], + lastOrganizationId: ORGANIZATION_ID, + mismatchedParents: [], + mismatchedPermissions: [], + mode: "apply", + orphanScope: "all", + orphans: [], + scope: "all", + status: "reconciled", + truncated: false, + unmanaged: [], + ...overrides, +}); + +const command = (overrides: Partial[0]> = {}) => ({ + maxPrune: AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN, + mode: "dry_run" as const, + prune: false, + ...overrides, +}); + +const deps = (overrides = {}) => ({ + closeClient: vi.fn(), + isEnabled: vi.fn().mockReturnValue(true), + resolveEndpoint: vi.fn().mockReturnValue(ENDPOINT), + run: vi.fn().mockResolvedValue(result()), + writeOutput: vi.fn(), + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("parseAuthzedBackfillCommand", () => { + test("defaults to a dry run over every organization, so a mistyped invocation is inert", () => { + expect(parseAuthzedBackfillCommand([])).toEqual({ + afterOrganizationId: undefined, + expectedEndpoint: undefined, + maxPrune: AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN, + mode: "dry_run", + organizationId: undefined, + prune: false, + }); + }); + + test("accepts an applying run without pruning", () => { + expect(parseAuthzedBackfillCommand(["--apply"])).toMatchObject({ mode: "apply", prune: false }); + }); + + test("accepts a confirmed prune scoped to one workspace", () => { + // The workspace scope satisfies the explicit-scope requirement, and it is the path that removes + // every relationship on a stale workspace ID — so it needs the same coverage as the organization. + expect( + parseAuthzedBackfillCommand([ + "--apply", + "--prune", + "--confirm-prune", + `--workspace-id=${WORKSPACE_ID}`, + `--expected-endpoint=${ENDPOINT}`, + ]) + ).toMatchObject({ mode: "apply", prune: true, workspaceId: WORKSPACE_ID }); + }); + + test.each([ + ["a workspace combined with the full sweep", [`--workspace-id=${WORKSPACE_ID}`, "--scope=all"]], + [ + "a workspace combined with an organization", + [`--workspace-id=${WORKSPACE_ID}`, `--organization-id=${ORGANIZATION_ID}`], + ], + [ + "a workspace combined with a resume cursor", + [`--workspace-id=${WORKSPACE_ID}`, `--after-organization-id=${ORGANIZATION_ID}`], + ], + ["a malformed workspace id", ["--workspace-id=not-a-cuid"]], + ["a repeated workspace id", [`--workspace-id=${WORKSPACE_ID}`, `--workspace-id=${WORKSPACE_ID}`]], + ])("refuses %s", (_label, args) => { + expect(parseAuthzedBackfillCommand(args)).toBeUndefined(); + }); + + test("refuses a workspace prune that is missing a confirmation", () => { + expect( + parseAuthzedBackfillCommand([ + "--apply", + "--prune", + `--workspace-id=${WORKSPACE_ID}`, + `--expected-endpoint=${ENDPOINT}`, + ]) + ).toBeUndefined(); + }); + + test("accepts a fully-confirmed prune", () => { + expect( + parseAuthzedBackfillCommand([ + "--apply", + "--prune", + "--confirm-prune", + "--scope=all", + `--expected-endpoint=${ENDPOINT}`, + ]) + ).toMatchObject({ expectedEndpoint: ENDPOINT, mode: "apply", prune: true }); + }); + + test("accepts a confirmed prune scoped to one organization", () => { + expect( + parseAuthzedBackfillCommand([ + "--apply", + "--prune", + "--confirm-prune", + `--organization-id=${ORGANIZATION_ID}`, + `--expected-endpoint=${ENDPOINT}`, + ]) + ).toMatchObject({ organizationId: ORGANIZATION_ID, prune: true }); + }); + + test.each([ + ["without --apply", ["--prune", "--confirm-prune", "--scope=all", `--expected-endpoint=${ENDPOINT}`]], + ["without --confirm-prune", ["--apply", "--prune", "--scope=all", `--expected-endpoint=${ENDPOINT}`]], + [ + "without an explicit scope", + ["--apply", "--prune", "--confirm-prune", `--expected-endpoint=${ENDPOINT}`], + ], + ["without --expected-endpoint", ["--apply", "--prune", "--confirm-prune", "--scope=all"]], + ])("refuses a prune %s", (_label, args) => { + // Removing relationships must never be reachable by a shorter command than the full spelling. + expect(parseAuthzedBackfillCommand(args)).toBeUndefined(); + }); + + test("refuses --confirm-prune without --prune, so the confirmation cannot be left lying around", () => { + expect(parseAuthzedBackfillCommand(["--apply", "--confirm-prune"])).toBeUndefined(); + }); + + test.each([ + ["an unknown flag", ["--force"]], + ["a bare argument", ["all"]], + ["a misspelled flag", ["--dry-run"]], + ["an unsupported scope value", ["--scope=organization"]], + ["a malformed organization id", ["--organization-id=not-a-cuid!"]], + ["a malformed resume cursor", ["--after-organization-id=nope"]], + ["both a scope and an organization", ["--scope=all", `--organization-id=${ORGANIZATION_ID}`]], + [ + "an organization together with a resume cursor", + [`--organization-id=${ORGANIZATION_ID}`, `--after-organization-id=${OTHER_ORGANIZATION_ID}`], + ], + ["a non-numeric prune cap", ["--max-prune=lots"]], + ["a zero prune cap", ["--max-prune=0"]], + // Silently taking the first would let an operator who typed a value twice act on a different value + // than the one they last wrote. + ["a repeated prune cap", ["--max-prune=1", "--max-prune=400"]], + [ + "a repeated organization", + [`--organization-id=${ORGANIZATION_ID}`, `--organization-id=${OTHER_ORGANIZATION_ID}`], + ], + ["a repeated endpoint", [`--expected-endpoint=${ENDPOINT}`, "--expected-endpoint=other:50051"]], + ["a repeated scope", ["--scope=all", "--scope=all"]], + ])("refuses %s", (_label, args) => { + expect(parseAuthzedBackfillCommand(args)).toBeUndefined(); + }); + + test("allows lowering the prune cap", () => { + expect(parseAuthzedBackfillCommand(["--max-prune=10"])).toMatchObject({ maxPrune: 10 }); + }); + + test("refuses raising the prune cap above the built-in bound", () => { + // The cap exists because a large orphan count is a symptom; an operator must not be able to + // configure the symptom away. + expect( + parseAuthzedBackfillCommand([`--max-prune=${AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN + 1}`]) + ).toBeUndefined(); + }); + + test("accepts a resume cursor", () => { + expect(parseAuthzedBackfillCommand([`--after-organization-id=${ORGANIZATION_ID}`])).toMatchObject({ + afterOrganizationId: ORGANIZATION_ID, + }); + }); +}); + +describe("runAuthzedBackfillCli", () => { + test.each([ + ["reconciled", 0], + ["drifted", 2], + ["failed", 1], + ] as const)("returns exit code %s -> %i and serializes the result exactly", async (status, exitCode) => { + const backfillResult = result({ status }); + const dependencies = deps({ run: vi.fn().mockResolvedValue(backfillResult) }); + + await expect(runAuthzedBackfillCli(command(), dependencies)).resolves.toBe(exitCode); + + expect(dependencies.writeOutput).toHaveBeenCalledOnce(); + expect(dependencies.writeOutput).toHaveBeenCalledWith(`${JSON.stringify(backfillResult)}\n`); + expect(dependencies.closeClient).toHaveBeenCalledOnce(); + }); + + test("refuses to run at all when AuthZed is disabled", async () => { + // A per-unit `disabled` result would otherwise read as "not failed" and the run would claim to have + // reconciled organizations it never touched. + const dependencies = deps({ isEnabled: vi.fn().mockReturnValue(false) }); + + await expect(runAuthzedBackfillCli(command(), dependencies)).resolves.toBe(1); + + expect(dependencies.run).not.toHaveBeenCalled(); + expect(dependencies.writeOutput).toHaveBeenCalledWith( + `${JSON.stringify({ code: AUTHZED_ERROR_CODES.DISABLED, retryable: false, status: "failed" })}\n` + ); + }); + + test("refuses to run when the named endpoint is not the configured one", async () => { + // The guard against a stale .env aiming the destructive path at the wrong instance. + const dependencies = deps({ resolveEndpoint: vi.fn().mockReturnValue("spicedb.production:50051") }); + + await expect( + runAuthzedBackfillCli(command({ expectedEndpoint: ENDPOINT, mode: "apply", prune: true }), dependencies) + ).resolves.toBe(1); + + expect(dependencies.run).not.toHaveBeenCalled(); + // A distinct code from a mistyped flag: aiming the destructive path at the wrong instance is a very + // different mistake, and the operator should be able to tell which one happened. + expect(dependencies.writeOutput).toHaveBeenCalledWith( + `${JSON.stringify({ code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, retryable: false, status: "failed" })}\n` + ); + }); + + test("proceeds when the named endpoint matches", async () => { + const dependencies = deps(); + + await expect( + runAuthzedBackfillCli(command({ expectedEndpoint: ENDPOINT, mode: "apply" }), dependencies) + ).resolves.toBe(0); + + expect(dependencies.run).toHaveBeenCalledOnce(); + }); + + test("supplies inert reconcilers for a dry run", async () => { + const dependencies = deps(); + + await runAuthzedBackfillCli(command({ mode: "dry_run" }), dependencies); + + const [request, apply] = dependencies.run.mock.calls[0]; + expect(request.mode).toBe("dry_run"); + await expect(apply.reconcileMemberships({})).resolves.toEqual({ passes: 0, status: "projected" }); + await expect(apply.reconcileTeamWorkspace({})).resolves.toEqual({ passes: 0, status: "projected" }); + await expect(apply.reconcileApiKeys({})).resolves.toEqual({ passes: 0, status: "projected" }); + await expect(apply.reconcileFeedbackDirectories({})).resolves.toEqual({ passes: 0, status: "projected" }); + await expect(apply.deleteFeedbackDirectoryAssignmentResources([])).resolves.toEqual({ + passes: 0, + status: "projected", + }); + }); + + test("translates a named workspace into a single-workspace scope", async () => { + const dependencies = deps(); + + await runAuthzedBackfillCli(command({ workspaceId: WORKSPACE_ID }), dependencies); + + expect(dependencies.run.mock.calls[0][0].scope).toEqual({ + kind: "workspace", + workspaceId: WORKSPACE_ID, + }); + }); + + test("translates a named organization into a single-organization scope", async () => { + const dependencies = deps(); + + await runAuthzedBackfillCli(command({ organizationId: ORGANIZATION_ID }), dependencies); + + expect(dependencies.run.mock.calls[0][0].scope).toEqual({ + kind: "organization", + organizationId: ORGANIZATION_ID, + }); + }); + + test("translates a resume cursor into a full scope that starts after it", async () => { + const dependencies = deps(); + + await runAuthzedBackfillCli(command({ afterOrganizationId: ORGANIZATION_ID }), dependencies); + + expect(dependencies.run.mock.calls[0][0].scope).toEqual({ + afterOrganizationId: ORGANIZATION_ID, + kind: "all", + }); + }); + + test("prints only the stable error contract and closes the client on failure", async () => { + const secret = "never-log-this-authzed-token"; + const dependencies = deps({ + run: vi.fn().mockRejectedValue( + new AuthzedError({ + attempts: 1, + cause: new Error(secret), + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "read_relationships", + retryable: true, + }) + ), + }); + + await expect(runAuthzedBackfillCli(command(), dependencies)).resolves.toBe(1); + + expect(dependencies.writeOutput).toHaveBeenCalledWith( + `${JSON.stringify({ code: AUTHZED_ERROR_CODES.UNAVAILABLE, retryable: true, status: "failed" })}\n` + ); + expect(JSON.stringify(dependencies.writeOutput.mock.calls)).not.toContain(secret); + expect(dependencies.closeClient).toHaveBeenCalledOnce(); + }); + + test("maps a non-AuthZed failure onto the same sanitized contract", async () => { + const dependencies = deps({ run: vi.fn().mockRejectedValue(new Error("Organization not found")) }); + + await expect(runAuthzedBackfillCli(command(), dependencies)).resolves.toBe(1); + + expect(JSON.stringify(dependencies.writeOutput.mock.calls)).not.toContain("Organization not found"); + }); + + test("a cleanup failure does not replace the result or the exit code", async () => { + const backfillResult = result({ status: "drifted" }); + const dependencies = deps({ + closeClient: vi.fn().mockImplementation(() => { + throw new Error("channel already closed"); + }), + run: vi.fn().mockResolvedValue(backfillResult), + }); + + await expect(runAuthzedBackfillCli(command(), dependencies)).resolves.toBe(2); + + expect(dependencies.writeOutput).toHaveBeenCalledWith(`${JSON.stringify(backfillResult)}\n`); + }); +}); diff --git a/apps/web/lib/authzed/backfill-cli.ts b/apps/web/lib/authzed/backfill-cli.ts new file mode 100644 index 000000000000..c669bb630907 --- /dev/null +++ b/apps/web/lib/authzed/backfill-cli.ts @@ -0,0 +1,154 @@ +import "server-only"; +import { env } from "@/lib/env"; +import { + type TAuthzedBackfillApply, + type TAuthzedBackfillRequest, + type TAuthzedBackfillResult, + runAuthzedBackfill, +} from "./backfill"; +import { createAuthzedBackfillApply, createAuthzedBackfillNoopApply } from "./backfill-apply"; +import type { TAuthzedBackfillCliCommand } from "./backfill-cli-command"; +import { closeAuthzedClient, configureAuthzedClientForBulkWork, getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { AUTHZED_ERROR_CODES, AuthzedError, type TAuthzedErrorCode, mapAuthzedError } from "./errors"; + +export { parseAuthzedBackfillCommand } from "./backfill-cli-command"; +export type { TAuthzedBackfillCliCommand } from "./backfill-cli-command"; + +/** + * Command layer for relationship backfill and repair. + * + * Argument parsing lives in a side-effect-free sibling module so invalid commands can be rejected before + * environment validation, SDK construction, or database access. + * + * The exit-code contract matches `authzed:schema`: 0 clean, 2 drift remains, 1 failed or misused. + */ + +type TAuthzedBackfillCliFailure = Readonly<{ + code: TAuthzedErrorCode; + retryable: boolean; + status: "failed"; +}>; + +type TAuthzedBackfillCliDependencies = Readonly<{ + closeClient: () => void; + isEnabled: () => boolean; + resolveEndpoint: () => string | undefined; + run: (request: TAuthzedBackfillRequest, apply: TAuthzedBackfillApply) => Promise; + writeOutput: (output: string) => void; +}>; + +/** + * Real reconcilers. Selected once, in `runAuthzedBackfillCli`, and only for an applying run. + * + * The orchestrator can reach a mutation only through this object, so a dry run supplying + * `createInertApply()` cannot write regardless of any flag it is passed. + */ +const defaultDependencies: TAuthzedBackfillCliDependencies = { + closeClient: closeAuthzedClient, + isEnabled: isAuthzedEnabled, + resolveEndpoint: () => env.AUTHZED_ENDPOINT, + // Widened before the first client is built, so the reconcilers this hands to the orchestrator — which + // reach the channel through `getAuthzedClient()` themselves — write under the same bulk deadline the + // sweep reads under. + run: (request, apply) => { + configureAuthzedClientForBulkWork(); + + return runAuthzedBackfill(request, { apply, client: getAuthzedClient() }); + }, + writeOutput: (output) => process.stdout.write(output), +}; + +const toFailureResult = (error: unknown): TAuthzedBackfillCliFailure => { + const authzedError = error instanceof AuthzedError ? error : mapAuthzedError(error, "backfill_cli", 1); + + return { code: authzedError.code, retryable: authzedError.retryable, status: "failed" }; +}; + +const invalidRequest = (): TAuthzedBackfillCliFailure => ({ + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + retryable: false, + status: "failed", +}); + +/** The three scopes are mutually exclusive, enforced during parsing. */ +const resolveScope = (command: TAuthzedBackfillCliCommand): TAuthzedBackfillRequest["scope"] => { + if (command.workspaceId) { + return { kind: "workspace", workspaceId: command.workspaceId }; + } + if (command.organizationId) { + return { kind: "organization", organizationId: command.organizationId }; + } + return { afterOrganizationId: command.afterOrganizationId, kind: "all" }; +}; + +const toExitCode = (status: TAuthzedBackfillResult["status"]): number => { + switch (status) { + case "reconciled": + return 0; + case "drifted": + return 2; + case "failed": + return 1; + } +}; + +export const runAuthzedBackfillCli = async ( + command: TAuthzedBackfillCliCommand, + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + let result: TAuthzedBackfillResult | TAuthzedBackfillCliFailure = invalidRequest(); + let exitCode = 1; + + try { + // Checked up front. Left to the per-unit result, a disabled instance would report every + // organization as reconciled, because that is what "not failed" looks like from the outside. + if (!dependencies.isEnabled()) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.DISABLED, + operation: "backfill_cli", + retryable: false, + }); + } + + if ( + command.expectedEndpoint !== undefined && + command.expectedEndpoint !== dependencies.resolveEndpoint() + ) { + // The operator named an instance other than the configured one. Refuse rather than guess — and + // report a distinct code, because "you aimed this at the wrong SpiceDB" and "you mistyped a flag" + // want very different reactions. + // `exitCode` is already 1 from its initializer, which is what this branch wants. + result = { + code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, + retryable: false, + status: "failed", + }; + } else { + result = await dependencies.run( + { + maxPrune: command.maxPrune, + mode: command.mode, + prune: command.prune, + scope: resolveScope(command), + }, + command.mode === "apply" ? createAuthzedBackfillApply() : createAuthzedBackfillNoopApply() + ); + exitCode = toExitCode(result.status); + } + } catch (error) { + result = toFailureResult(error); + exitCode = 1; + } finally { + try { + dependencies.closeClient(); + } catch { + // Cleanup failures must not replace the backfill's result or exit code. + } + } + + dependencies.writeOutput(`${JSON.stringify(result)}\n`); + return exitCode; +}; diff --git a/apps/web/lib/authzed/backfill-diff.test.ts b/apps/web/lib/authzed/backfill-diff.test.ts new file mode 100644 index 000000000000..7c425e79d795 --- /dev/null +++ b/apps/web/lib/authzed/backfill-diff.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, test } from "vitest"; +import { + findMismatchedPermissionRelations, + getManagedResourceTypes, + isUnprojectedResourceType, + summarizeObservation, + toSourceRef, +} from "./backfill-diff"; +import type { TAuthzedRelationship } from "./client"; + +const tuple = ( + resourceType: string, + resourceId: string, + relation: string, + subjectType: string, + subjectId: string, + subjectRelation?: string +): TAuthzedRelationship => ({ + relation, + resource: { objectId: resourceId, objectType: resourceType }, + subject: { + objectId: subjectId, + objectType: subjectType, + ...(subjectRelation ? { relation: subjectRelation } : {}), + }, +}); + +describe("toSourceRef", () => { + test.each(["owner", "manager", "member", "billing"])( + "maps organization#%s@user to the membership row", + (relation) => { + expect(toSourceRef(tuple("organization", "org-1", relation, "user", "user-1"))).toEqual({ + kind: "membership", + organizationId: "org-1", + userId: "user-1", + }); + } + ); + + test.each(["api_key_reader", "api_key_writer"])( + "maps organization#%s@api_key to the API key, since the flags live on the key's record", + (relation) => { + expect(toSourceRef(tuple("organization", "org-1", relation, "api_key", "key-1"))).toEqual({ + apiKeyId: "key-1", + kind: "apiKey", + }); + } + ); + + test("maps team#organization to the team row", () => { + expect(toSourceRef(tuple("team", "team-1", "organization", "organization", "org-1"))).toEqual({ + kind: "team", + teamId: "team-1", + }); + }); + + test.each(["admin", "contributor"])("maps team#%s@user to the team membership row", (relation) => { + expect(toSourceRef(tuple("team", "team-1", relation, "user", "user-1"))).toEqual({ + kind: "teamMembership", + teamId: "team-1", + userId: "user-1", + }); + }); + + test("maps workspace#organization to the workspace row", () => { + expect(toSourceRef(tuple("workspace", "ws-1", "organization", "organization", "org-1"))).toEqual({ + kind: "workspace", + workspaceId: "ws-1", + }); + }); + + test.each(["reader_team", "writer_team", "manager_team"])( + "maps workspace#%s@team#member to the workspace-team grant", + (relation) => { + expect(toSourceRef(tuple("workspace", "ws-1", relation, "team", "team-1", "member"))).toEqual({ + kind: "workspaceTeamGrant", + teamId: "team-1", + workspaceId: "ws-1", + }); + } + ); + + test.each(["reader", "writer", "manager"])( + "maps workspace#%s@api_key to the API-key workspace grant", + (relation) => { + expect(toSourceRef(tuple("workspace", "ws-1", relation, "api_key", "key-1"))).toEqual({ + apiKeyId: "key-1", + kind: "apiKeyWorkspaceGrant", + workspaceId: "ws-1", + }); + } + ); + + test("maps api_key#organization to the API key row", () => { + expect(toSourceRef(tuple("api_key", "key-1", "organization", "organization", "org-1"))).toEqual({ + apiKeyId: "key-1", + kind: "apiKey", + }); + }); + + test("maps feedback directory parents and all assignment edges", () => { + expect( + toSourceRef(tuple("feedback_directory", "directory-1", "organization", "organization", "org-1")) + ).toEqual({ feedbackDirectoryId: "directory-1", kind: "feedbackDirectory" }); + + expect( + toSourceRef( + tuple("feedback_directory", "directory-1", "assignment", "feedback_directory_assignment", "fdwa-1") + ) + ).toEqual({ + assignmentId: "fdwa-1", + feedbackDirectoryId: "directory-1", + kind: "feedbackDirectoryAssignment", + }); + expect( + toSourceRef( + tuple("feedback_directory_assignment", "fdwa-1", "directory", "feedback_directory", "directory-1") + ) + ).toEqual({ + assignmentId: "fdwa-1", + feedbackDirectoryId: "directory-1", + kind: "feedbackDirectoryAssignment", + }); + expect( + toSourceRef(tuple("feedback_directory_assignment", "fdwa-1", "workspace", "workspace", "workspace-1")) + ).toEqual({ + assignmentId: "fdwa-1", + kind: "feedbackDirectoryAssignment", + workspaceId: "workspace-1", + }); + }); + + test("distinguishes an api-key workspace grant from a team workspace grant", () => { + // The relations differ only by suffix and the subject type, and confusing them would name the + // wrong source record — so a present grant would look absent, and pruning would revoke it. + expect(toSourceRef(tuple("workspace", "ws-1", "manager", "api_key", "key-1"))).toEqual({ + apiKeyId: "key-1", + kind: "apiKeyWorkspaceGrant", + workspaceId: "ws-1", + }); + expect(toSourceRef(tuple("workspace", "ws-1", "manager_team", "team", "team-1", "member"))).toEqual({ + kind: "workspaceTeamGrant", + teamId: "team-1", + workspaceId: "ws-1", + }); + }); + + test.each([ + ["an unknown resource type", tuple("chart", "chart-1", "workspace", "workspace", "ws-1")], + ["an unknown relation", tuple("organization", "org-1", "superuser", "user", "user-1")], + ["a role relation with the wrong subject type", tuple("organization", "org-1", "owner", "api_key", "k")], + [ + "an api-key flag with the wrong subject type", + tuple("organization", "o", "api_key_reader", "user", "u"), + ], + ["a team grant with the wrong subject type", tuple("workspace", "ws-1", "reader_team", "user", "u")], + ["a workspace grant with the wrong subject type", tuple("workspace", "ws-1", "reader", "user", "u")], + ["a parent relation with the wrong subject type", tuple("team", "team-1", "organization", "user", "u")], + ["an api_key resource with an unexpected relation", tuple("api_key", "key-1", "reader", "user", "u")], + [ + "a feedback directory with an unexpected relation", + tuple("feedback_directory", "directory-1", "reader", "user", "u"), + ], + [ + "a feedback directory assignment with an unexpected relation", + tuple("feedback_directory_assignment", "fdwa-1", "reader", "user", "u"), + ], + ])("declines to name a source record for %s", (_label, relationship) => { + expect(toSourceRef(relationship)).toBeNull(); + }); +}); + +describe("resource type classification", () => { + test.each([ + "api_key", + "feedback_directory", + "feedback_directory_assignment", + "organization", + "team", + "workspace", + ])("treats %s as managed", (resourceType) => { + expect(getManagedResourceTypes()).toContain(resourceType); + expect(isUnprojectedResourceType(resourceType)).toBe(false); + }); + + test.each(["survey", "dashboard", "response"])( + "treats %s as deliberately unprojected, not managed", + (resourceType) => { + // These exist in the schema for later resource-level sharing. Pruning them would delete + // relationships a future projector is expected to own. + // No type may be both: managed means the sweep reads it and may prune what it finds. + expect(isUnprojectedResourceType(resourceType)).toBe(true); + expect(getManagedResourceTypes()).not.toContain(resourceType); + } + ); + + test("exposes the managed types for a resource-type sweep", () => { + expect([...getManagedResourceTypes()].sort()).toEqual([ + "api_key", + "feedback_directory", + "feedback_directory_assignment", + "organization", + "team", + "workspace", + ]); + }); +}); + +describe("cross-tenant organization access", () => { + test("distinguishes the two ownership shapes, which need different remediation", () => { + // Both say "this key belongs to that organization", but the organization sits on opposite sides, so + // `zed relationship delete` takes different arguments. Reporting them identically would send an + // operator to delete a relationship that does not exist while the escalation survives. + const summary = summarizeObservation([ + { + relation: "organization", + resource: { objectId: "key-1", objectType: "api_key" }, + subject: { objectId: "org-a", objectType: "organization" }, + }, + { + relation: "api_key_writer", + resource: { objectId: "org-a", objectType: "organization" }, + subject: { objectId: "key-1", objectType: "api_key" }, + }, + ]); + + // Deterministically ordered, so `api_key_writer` precedes `organization`. + expect(summary.parentEdges).toEqual([ + { childId: "key-1", childType: "api_key", organizationId: "org-a", relation: "api_key_writer" }, + { childId: "key-1", childType: "api_key", organizationId: "org-a", relation: "organization" }, + ]); + }); + + test("reports an organization access grant naming a key from another organization", () => { + // `organization:A#api_key_writer@api_key:K` implies "K belongs to A". Reduced to "does K exist?" it + // read as sourced whenever K existed anywhere, so a cross-tenant grant survived apply and prune. + const summary = summarizeObservation([ + { + relation: "api_key_writer", + resource: { objectId: "org-a", objectType: "organization" }, + subject: { objectId: "key-1", objectType: "api_key" }, + }, + ]); + + expect(summary.parentEdges).toEqual([ + { childId: "key-1", childType: "api_key", organizationId: "org-a", relation: "api_key_writer" }, + ]); + }); + + test("still names the key as a source record, so a deleted key is found too", () => { + const summary = summarizeObservation([ + { + relation: "api_key_reader", + resource: { objectId: "org-a", objectType: "organization" }, + subject: { objectId: "key-1", objectType: "api_key" }, + }, + ]); + + expect(summary.sourceRefs).toEqual([{ apiKeyId: "key-1", kind: "apiKey" }]); + }); +}); + +describe("summarizeObservation", () => { + test("collects the source records an observation implies", () => { + const summary = summarizeObservation([ + tuple("organization", "org-1", "owner", "user", "user-1"), + tuple("team", "team-1", "organization", "organization", "org-1"), + tuple("workspace", "ws-1", "reader", "api_key", "key-1"), + ]); + + expect(summary.sourceRefs).toHaveLength(3); + expect(summary.sourceRefs).toEqual( + expect.arrayContaining([ + { kind: "membership", organizationId: "org-1", userId: "user-1" }, + { kind: "team", teamId: "team-1" }, + { apiKeyId: "key-1", kind: "apiKeyWorkspaceGrant", workspaceId: "ws-1" }, + ]) + ); + expect(summary.ignored).toBe(0); + expect(summary.unmanaged).toEqual([]); + }); + + test("deduplicates records implied by more than one relationship", () => { + const summary = summarizeObservation([ + tuple("api_key", "key-1", "organization", "organization", "org-1"), + tuple("organization", "org-1", "api_key_reader", "api_key", "key-1"), + tuple("organization", "org-1", "api_key_writer", "api_key", "key-1"), + ]); + + // All three imply the same API key record, so it is looked up once. + expect(summary.sourceRefs).toEqual([{ apiKeyId: "key-1", kind: "apiKey" }]); + }); + + test("counts unprojected resource types as ignored without naming a record", () => { + const summary = summarizeObservation([ + tuple("survey", "survey-1", "workspace", "workspace", "ws-1"), + tuple("dashboard", "dash-1", "workspace", "workspace", "ws-1"), + tuple("response", "resp-1", "survey", "survey", "survey-1"), + ]); + + expect(summary).toEqual({ + ignored: 3, + managedRelationships: [], + parentEdges: [], + sourceRefs: [], + unmanaged: [], + }); + }); + + test("reports unrecognized relationships without naming a record for them", () => { + const summary = summarizeObservation([ + tuple("organization", "org-1", "superuser", "user", "user-1"), + tuple("chart", "chart-1", "workspace", "workspace", "ws-1"), + ]); + + // Reported so they are visible, but never reconciled: the tooling cannot know which source record, + // if any, should own them. + expect(summary.sourceRefs).toEqual([]); + expect(summary.unmanaged).toEqual([ + { objectId: "chart-1", objectType: "chart", relation: "workspace" }, + { objectId: "org-1", objectType: "organization", relation: "superuser" }, + ]); + }); + + test("enriches a complete feedback directory assignment from its three graph edges", () => { + const assignmentId = "fdwa-1"; + const summary = summarizeObservation([ + tuple("feedback_directory", "directory-1", "assignment", "feedback_directory_assignment", assignmentId), + tuple("feedback_directory_assignment", assignmentId, "directory", "feedback_directory", "directory-1"), + tuple("feedback_directory_assignment", assignmentId, "workspace", "workspace", "workspace-1"), + ]); + + expect(summary.sourceRefs).toEqual([ + { + assignmentId, + feedbackDirectoryId: "directory-1", + kind: "feedbackDirectoryAssignment", + workspaceId: "workspace-1", + }, + ]); + expect(summary.managedRelationships).toHaveLength(3); + }); + + test("does not guess an assignment parent when conflicting graph edges are observed", () => { + const assignmentId = "fdwa-ambiguous"; + const summary = summarizeObservation([ + tuple("feedback_directory", "directory-1", "assignment", "feedback_directory_assignment", assignmentId), + tuple("feedback_directory_assignment", assignmentId, "directory", "feedback_directory", "directory-2"), + tuple("feedback_directory_assignment", assignmentId, "workspace", "workspace", "workspace-1"), + ]); + + expect(summary.sourceRefs).toEqual([ + { assignmentId, kind: "feedbackDirectoryAssignment", workspaceId: "workspace-1" }, + ]); + }); + + test("orders output deterministically so repeated runs are comparable", () => { + const relationships = [ + tuple("workspace", "ws-2", "organization", "organization", "org-1"), + tuple("organization", "org-1", "owner", "user", "user-2"), + tuple("workspace", "ws-1", "organization", "organization", "org-1"), + tuple("organization", "org-1", "owner", "user", "user-1"), + ]; + + const first = summarizeObservation(relationships); + const second = summarizeObservation([...relationships].reverse()); + + expect(first).toEqual(second); + }); + + test("returns an empty summary for an empty observation", () => { + expect(summarizeObservation([])).toEqual({ + ignored: 0, + managedRelationships: [], + parentEdges: [], + sourceRefs: [], + unmanaged: [], + }); + }); +}); + +describe("findMismatchedPermissionRelations", () => { + test("reports a stale higher workspace-team permission for an existing source pair", () => { + const expected = [tuple("workspace", "ws-1", "reader_team", "team", "team-1", "member")]; + const observed = [tuple("workspace", "ws-1", "manager_team", "team", "team-1", "member")]; + + expect(findMismatchedPermissionRelations(expected, observed)).toEqual([ + { + expectedRelations: ["reader_team"], + observedRelations: ["manager_team"], + source: { kind: "workspaceTeamGrant", teamId: "team-1", workspaceId: "ws-1" }, + }, + ]); + }); + + test("compares independent API-key organization flags as a complete set", () => { + const parent = tuple("api_key", "key-1", "organization", "organization", "org-1"); + const expected = [parent, tuple("organization", "org-1", "api_key_reader", "api_key", "key-1")]; + const observed = [ + parent, + tuple("organization", "org-1", "api_key_reader", "api_key", "key-1"), + tuple("organization", "org-1", "api_key_writer", "api_key", "key-1"), + ]; + + expect(findMismatchedPermissionRelations(expected, observed)).toEqual([ + { + expectedRelations: ["api_key_reader"], + observedRelations: ["api_key_reader", "api_key_writer"], + source: { apiKeyId: "key-1", kind: "apiKey" }, + }, + ]); + }); + + test("compares all three feedback assignment edges as one exact relationship set", () => { + const assignmentId = "fdwa-assignment-1"; + const expected = [ + tuple("feedback_directory", "directory-1", "assignment", "feedback_directory_assignment", assignmentId), + tuple("feedback_directory_assignment", assignmentId, "directory", "feedback_directory", "directory-1"), + tuple("feedback_directory_assignment", assignmentId, "workspace", "workspace", "workspace-1"), + ]; + const observed = [ + expected[0], + expected[1], + tuple("feedback_directory_assignment", assignmentId, "workspace", "workspace", "workspace-2"), + ]; + + expect(findMismatchedPermissionRelations(expected, observed)).toEqual([ + { + expectedRelations: ["assignment", "directory", "workspace"], + observedRelations: ["assignment", "directory", "workspace"], + source: { + assignmentId, + feedbackDirectoryId: "directory-1", + kind: "feedbackDirectoryAssignment", + }, + }, + ]); + }); + + test("leaves wholly absent sources to the missing-source classification", () => { + expect( + findMismatchedPermissionRelations([tuple("organization", "org-1", "member", "user", "user-1")], []) + ).toEqual([]); + }); + + test("does not classify parent edges as permission mismatches", () => { + expect( + findMismatchedPermissionRelations( + [tuple("workspace", "ws-1", "organization", "organization", "org-1")], + [tuple("workspace", "ws-1", "organization", "organization", "org-2")] + ) + ).toEqual([]); + }); +}); diff --git a/apps/web/lib/authzed/backfill-diff.ts b/apps/web/lib/authzed/backfill-diff.ts new file mode 100644 index 000000000000..a5321cf6e66d --- /dev/null +++ b/apps/web/lib/authzed/backfill-diff.ts @@ -0,0 +1,536 @@ +import "server-only"; +import type { TAuthzedRelationship } from "./client"; +import { + ORGANIZATION_ACCESS_RELATIONS, + ORGANIZATION_RELATIONS, + TEAM_RELATIONS, + WORKSPACE_API_KEY_RELATIONS, + WORKSPACE_TEAM_RELATIONS, +} from "./relationship-map"; + +/** + * Turning observed SpiceDB relationships into reconciler targets. + * + * Pure and synchronous on purpose. This is where the decision that leads to a deletion is computed, + * so it is kept free of PostgreSQL, the AuthZed facade, and I/O of any kind: it can be exhaustively + * tested with object literals, and it cannot itself mutate anything. + * + * Note what this module does *not* do. It never decides that a relationship is stale. It only names + * the source record a relationship implies, so a reconciler can look that record up in PostgreSQL and + * decide. That indirection is what makes repair safe — a record recreated between the read and the + * reconcile is written, not deleted. + */ + +/** + * Resource types the schema defines but no projector writes yet. + * + * Deliberately unprojected during the current-model migration: resource-level access is resolved + * through PostgreSQL parent lookups instead. Reconciliation must classify these as ignored rather + * than orphaned — pruning them would delete relationships a future projector is expected to own. + */ +const UNPROJECTED_RESOURCE_TYPES = ["dashboard", "response", "survey"] as const; + +export type TAuthzedRelationshipRef = Readonly<{ + objectId: string; + objectType: string; + relation: string; +}>; + +/** + * A source record implied by an observed relationship. + * + * Named after the PostgreSQL record rather than the relationship, because that is what an operator + * needs in order to act: "this membership has no row" is diagnosable, "this tuple looks wrong" is not. + */ +export type TAuthzedSourceRef = + | Readonly<{ apiKeyId: string; kind: "apiKey" }> + | Readonly<{ apiKeyId: string; kind: "apiKeyWorkspaceGrant"; workspaceId: string }> + | Readonly<{ feedbackDirectoryId: string; kind: "feedbackDirectory" }> + | Readonly<{ + assignmentId: string; + feedbackDirectoryId?: string; + kind: "feedbackDirectoryAssignment"; + workspaceId?: string; + }> + | Readonly<{ kind: "membership"; organizationId: string; userId: string }> + | Readonly<{ kind: "team"; teamId: string }> + | Readonly<{ kind: "teamMembership"; teamId: string; userId: string }> + | Readonly<{ kind: "workspace"; workspaceId: string }> + | Readonly<{ kind: "workspaceTeamGrant"; teamId: string; workspaceId: string }>; + +export type TAuthzedObservationSummary = Readonly<{ + /** Relationships on deliberately-unprojected resource types. Counted, never acted on. */ + ignored: number; + /** Managed relationships retained for exact relation-set comparison against PostgreSQL. */ + managedRelationships: ReadonlyArray; + /** Every parent edge observed, so the organization each resource claims can be verified. */ + parentEdges: ReadonlyArray; + /** Deduplicated, deterministically ordered source records the observation implies. */ + sourceRefs: ReadonlyArray; + /** + * Relationships this vocabulary does not recognize. + * + * Something other than Formbricks writing to this SpiceDB, or a schema change that landed without a + * matching projector. Reported so it is visible; never reconciled and never pruned, because the + * tooling cannot know what source record — if any — should own them. + */ + unmanaged: ReadonlyArray; +}>; + +export type TAuthzedPermissionMismatch = Readonly<{ + expectedRelations: ReadonlyArray; + observedRelations: ReadonlyArray; + source: TAuthzedSourceRef; +}>; + +const ORGANIZATION_ROLE_RELATIONS = new Set(Object.values(ORGANIZATION_RELATIONS)); +const ORGANIZATION_API_KEY_RELATIONS = new Set(Object.values(ORGANIZATION_ACCESS_RELATIONS)); +const TEAM_ROLE_RELATIONS = new Set(Object.values(TEAM_RELATIONS)); +const WORKSPACE_TEAM_GRANT_RELATIONS = new Set(Object.values(WORKSPACE_TEAM_RELATIONS)); +const WORKSPACE_API_KEY_GRANT_RELATIONS = new Set(Object.values(WORKSPACE_API_KEY_RELATIONS)); + +/** The relation naming a resource's owning organization, shared by `api_key`, `team`, and `workspace`. */ +const PARENT_RELATION = "organization"; + +export const isUnprojectedResourceType = (resourceType: string): boolean => + (UNPROJECTED_RESOURCE_TYPES as readonly string[]).includes(resourceType); + +/** + * Resolves the source record a relationship on one resource type implies, or `null` when the + * vocabulary does not recognize that particular relation/subject pairing. + */ +type TSourceRefResolver = (relationship: TAuthzedRelationship) => TAuthzedSourceRef | null; + +const toOrganizationSourceRef: TSourceRefResolver = ({ relation, resource, subject }) => { + if (subject.objectType === "user" && ORGANIZATION_ROLE_RELATIONS.has(relation)) { + return { kind: "membership", organizationId: resource.objectId, userId: subject.objectId }; + } + if (subject.objectType === "api_key" && ORGANIZATION_API_KEY_RELATIONS.has(relation)) { + // Access flags live on the key's own record, so the key is the thing to look up. + return { apiKeyId: subject.objectId, kind: "apiKey" }; + } + + return null; +}; + +const toTeamSourceRef: TSourceRefResolver = ({ relation, resource, subject }) => { + if (subject.objectType === "organization" && relation === PARENT_RELATION) { + return { kind: "team", teamId: resource.objectId }; + } + if (subject.objectType === "user" && TEAM_ROLE_RELATIONS.has(relation)) { + return { kind: "teamMembership", teamId: resource.objectId, userId: subject.objectId }; + } + + return null; +}; + +const toWorkspaceSourceRef: TSourceRefResolver = ({ relation, resource, subject }) => { + if (subject.objectType === "organization" && relation === PARENT_RELATION) { + return { kind: "workspace", workspaceId: resource.objectId }; + } + if (subject.objectType === "team" && WORKSPACE_TEAM_GRANT_RELATIONS.has(relation)) { + return { kind: "workspaceTeamGrant", teamId: subject.objectId, workspaceId: resource.objectId }; + } + if (subject.objectType === "api_key" && WORKSPACE_API_KEY_GRANT_RELATIONS.has(relation)) { + return { + apiKeyId: subject.objectId, + kind: "apiKeyWorkspaceGrant", + workspaceId: resource.objectId, + }; + } + + return null; +}; + +const toApiKeySourceRef: TSourceRefResolver = ({ relation, resource, subject }) => + subject.objectType === "organization" && relation === PARENT_RELATION + ? { apiKeyId: resource.objectId, kind: "apiKey" } + : null; + +const toFeedbackDirectorySourceRef: TSourceRefResolver = ({ relation, resource, subject }) => { + if (relation === PARENT_RELATION && subject.objectType === "organization") { + return { feedbackDirectoryId: resource.objectId, kind: "feedbackDirectory" }; + } + if (relation === "assignment" && subject.objectType === "feedback_directory_assignment") { + return { + assignmentId: subject.objectId, + feedbackDirectoryId: resource.objectId, + kind: "feedbackDirectoryAssignment", + }; + } + + return null; +}; + +const toFeedbackDirectoryAssignmentSourceRef: TSourceRefResolver = ({ relation, resource, subject }) => { + if (relation === "directory" && subject.objectType === "feedback_directory") { + return { + assignmentId: resource.objectId, + feedbackDirectoryId: subject.objectId, + kind: "feedbackDirectoryAssignment", + }; + } + if (relation === "workspace" && subject.objectType === "workspace") { + return { + assignmentId: resource.objectId, + kind: "feedbackDirectoryAssignment", + workspaceId: subject.objectId, + }; + } + + return null; +}; + +/** + * The vocabulary in one table: which resource types imply a source record, and how. + * + * `MANAGED_RESOURCE_TYPES` is derived from these keys rather than listed separately, so a resource + * type can never be swept without a resolver that knows how to interpret what the sweep finds. + * + * Both the relation name and the subject type matter in every resolver. `workspace#reader@api_key` + * and `workspace#reader_team@team#member` are distinct grants, and the API-key relations are + * deliberately unsuffixed while the team relations are not. + */ +const SOURCE_REF_RESOLVERS = { + api_key: toApiKeySourceRef, + feedback_directory: toFeedbackDirectorySourceRef, + feedback_directory_assignment: toFeedbackDirectoryAssignmentSourceRef, + organization: toOrganizationSourceRef, + team: toTeamSourceRef, + workspace: toWorkspaceSourceRef, +} as const satisfies Readonly>; + +/** + * Code-unit order, deliberately not `localeCompare`. + * + * `localeCompare` resolves its collation from the host's default locale and available ICU data, so two + * machines can order the same keys differently — which would undercut the very guarantee this sort + * exists to provide. + */ +const byCodeUnit = (left: string, right: string): number => { + if (left === right) { + return 0; + } + + return left < right ? -1 : 1; +}; + +/** + * Resource types Formbricks projects today and may therefore reconcile. + * + * Sorted explicitly for two reasons: the sweep's order must not depend on how the resolver literal above + * happens to be written, and it must be identical on every machine, so two passes over unchanged state + * produce identical output. + */ +const MANAGED_RESOURCE_TYPES: ReadonlyArray = Object.keys(SOURCE_REF_RESOLVERS).sort(byCodeUnit); + +export const getManagedResourceTypes = (): ReadonlyArray => MANAGED_RESOURCE_TYPES; + +/** Name the source record an observed relationship implies, or `null` if it names none. */ +export const toSourceRef = (relationship: TAuthzedRelationship): TAuthzedSourceRef | null => { + const resolve: TSourceRefResolver | undefined = + SOURCE_REF_RESOLVERS[relationship.resource.objectType as keyof typeof SOURCE_REF_RESOLVERS]; + + return resolve?.(relationship) ?? null; +}; + +/** + * An observed parent edge: a resource claiming to belong to an organization. + * + * Tracked separately from the source records because the organization on the *right* of the edge is + * information a source-record reference throws away. `team:T#organization@organization:O` implies + * "Team T exists", and an existence check confirms that even when `O` is the wrong organization — + * so without this the edge is invisible. + * + * It matters because `organization` is a SpiceDB relation, i.e. a set, and the schema grants + * `workspace#manage` through `organization->manage`. A second, wrong parent edge on a workspace therefore + * hands every owner and manager of that organization full access to another tenant's workspace, with + * nothing in PostgreSQL to show for it. + */ +export type TAuthzedParentEdge = Readonly<{ + childId: string; + childType: "api_key" | "feedback_directory" | "team" | "workspace"; + organizationId: string; + /** + * The relation that asserted the ownership. + * + * Two relationship shapes can state it, and they are removed by *different* `zed` commands because the + * organization sits on opposite sides: + * + * - `organization` — the child's own parent edge, `: # organization @ organization:O` + * - anything else — an organization-level access grant, `organization:O # @ :` + * + * Reported so an operator acting on a finding deletes the relationship that actually exists. Without it + * both cases render identically and only one of them matches the documented remediation. + */ + relation: string; +}>; + +/** Stable identity for deduplication and ordering. Field order is fixed by the union's key order. */ +/** + * A stable identity for one source record, used to deduplicate and to diff. + * + * `JSON.stringify` over a literal whose keys are written in a fixed order in `toSourceRef` and + * `toSourceRefs`. Two refs for the same record must serialize identically, so both constructors keep + * their key order aligned — the test asserting round-trip equality of every kind is what holds that. + */ +export const sourceRefKey = (ref: TAuthzedSourceRef): string => + ref.kind === "feedbackDirectoryAssignment" + ? JSON.stringify({ assignmentId: ref.assignmentId, kind: ref.kind }) + : JSON.stringify(ref); + +/** The parent edge an observed relationship asserts, if it asserts one. */ +const toParentEdge = (relationship: TAuthzedRelationship): TAuthzedParentEdge | null => { + const { relation, resource, subject } = relationship; + + // Two relationship shapes state the same fact — "this API key belongs to that organization" — and both + // have to be verified against PostgreSQL: + // + // api_key:K # organization @ organization:A the key's own parent edge + // organization:A # api_key_writer @ api_key:K an organization-level access grant + // + // The second reduces to `{kind: "apiKey"}` as a source ref, which only asks "does K exist?" — true + // whenever K exists under *any* organization. So a grant naming a foreign key read as sourced, survived + // apply and prune, and went on granting `manage_access` over another tenant's organization. Emitting it + // as the same edge shape routes it through the check that already validates `api_key` parents. + if ( + resource.objectType === "organization" && + subject.objectType === "api_key" && + ORGANIZATION_API_KEY_RELATIONS.has(relation) + ) { + return { + childId: subject.objectId, + childType: "api_key", + organizationId: resource.objectId, + relation, + }; + } + + if (relation !== PARENT_RELATION || subject.objectType !== "organization") { + return null; + } + if ( + resource.objectType !== "api_key" && + resource.objectType !== "feedback_directory" && + resource.objectType !== "team" && + resource.objectType !== "workspace" + ) { + return null; + } + + return { + childId: resource.objectId, + childType: resource.objectType, + organizationId: subject.objectId, + relation, + }; +}; + +/** + * Source records PostgreSQL holds that SpiceDB has no relationship for. + * + * The other half of the drift picture. Without it a report can only find *stale* relationships, so an + * entirely empty SpiceDB reads as clean against a fully populated PostgreSQL — which is precisely the + * state the backfill exists to fix. + * + * Deliberately a set difference over source *records*, not over relationships. It answers "is this + * record projected at all?" and **not** "is it projected with the right relation": a membership stored + * as `owner` in PostgreSQL but `member` in SpiceDB appears on neither side of this diff, because both + * map to the same record. Converging a relation is what applying does unconditionally by writing the + * current value; detecting a wrong one would mean rebuilding every expected relationship here, which is + * the projectors' job and not worth duplicating. + */ +export const findUnprojectedSourceRefs = ( + expected: ReadonlyArray, + observed: ReadonlyArray +): ReadonlyArray => { + const observedKeys = new Set(observed.map(sourceRefKey)); + const unprojected = new Map(); + + for (const ref of expected) { + const key = sourceRefKey(ref); + if (!observedKeys.has(key)) { + unprojected.set(key, ref); + } + } + + return [...unprojected.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, ref]) => ref); +}; + +const relationshipKey = ({ relation, resource, subject }: TAuthzedRelationship): string => + JSON.stringify({ relation, resource, subject }); + +const relationNames = (relationships: ReadonlyArray): ReadonlyArray => + [...new Set(relationships.map(({ relation }) => relation))].sort(byCodeUnit); + +/** + * Find source records that exist on both sides but carry a different exact permission relation set. + * + * Parent edges are deliberately excluded: their correctness is verified by `findMismatchedParentEdges`, + * which can distinguish an absent row from a cross-tenant parent. Everything else is compared as a full + * relationship tuple, so an API key with two independent organization flags and a role ladder with one + * selected value are both handled without special cases. + */ +export const findMismatchedPermissionRelations = ( + expected: ReadonlyArray, + observed: ReadonlyArray +): ReadonlyArray => { + type TRelationshipGroup = { + permissionRelationships: TAuthzedRelationship[]; + source: TAuthzedSourceRef; + }; + + const groupBySource = ( + relationships: ReadonlyArray + ): ReadonlyMap => { + const groups = new Map(); + + for (const relationship of relationships) { + const source = toSourceRef(relationship); + if (!source) { + continue; + } + + const key = sourceRefKey(source); + const group = groups.get(key) ?? { permissionRelationships: [], source }; + if (relationship.relation !== PARENT_RELATION) { + group.permissionRelationships.push(relationship); + } + groups.set(key, group); + } + + return groups; + }; + + const expectedGroups = groupBySource(expected); + const observedGroups = groupBySource(observed); + const mismatches: Array = []; + + for (const [sourceKey, expectedGroup] of expectedGroups) { + const observedGroup = observedGroups.get(sourceKey); + // A wholly absent source is reported as `missing`, not duplicated as a permission mismatch. + if (!observedGroup) { + continue; + } + + const expectedKeys = expectedGroup.permissionRelationships.map(relationshipKey).sort(byCodeUnit); + const observedKeys = observedGroup.permissionRelationships.map(relationshipKey).sort(byCodeUnit); + if (JSON.stringify(expectedKeys) === JSON.stringify(observedKeys)) { + continue; + } + + mismatches.push([ + sourceKey, + { + expectedRelations: relationNames(expectedGroup.permissionRelationships), + observedRelations: relationNames(observedGroup.permissionRelationships), + source: expectedGroup.source, + }, + ]); + } + + mismatches.sort(([left], [right]) => byCodeUnit(left, right)); + return mismatches.map(([, mismatch]) => mismatch); +}; + +const toRelationshipRef = (relationship: TAuthzedRelationship): TAuthzedRelationshipRef => ({ + objectId: relationship.resource.objectId, + objectType: relationship.resource.objectType, + relation: relationship.relation, +}); + +const onlyValue = (values: ReadonlySet | undefined): string | undefined => { + if (values?.size !== 1) { + return undefined; + } + + return values.values().next().value; +}; + +/** + * Classify an observation and collect the source records it implies. + * + * Deduplicated and deterministically ordered so a run is reproducible and two runs over unchanged + * state produce identical output. + */ +export const summarizeObservation = ( + relationships: ReadonlyArray +): TAuthzedObservationSummary => { + const sourceRefs = new Map(); + const managedRelationships = new Map(); + const unmanaged = new Map(); + const parentEdges = new Map(); + let ignored = 0; + + const assignmentDirectories = new Map>(); + const assignmentWorkspaces = new Map>(); + for (const { relation, resource, subject } of relationships) { + if (resource.objectType === "feedback_directory" && relation === "assignment") { + const directories = assignmentDirectories.get(subject.objectId) ?? new Set(); + directories.add(resource.objectId); + assignmentDirectories.set(subject.objectId, directories); + } else if (resource.objectType === "feedback_directory_assignment" && relation === "directory") { + const directories = assignmentDirectories.get(resource.objectId) ?? new Set(); + directories.add(subject.objectId); + assignmentDirectories.set(resource.objectId, directories); + } else if (resource.objectType === "feedback_directory_assignment" && relation === "workspace") { + const workspaces = assignmentWorkspaces.get(resource.objectId) ?? new Set(); + workspaces.add(subject.objectId); + assignmentWorkspaces.set(resource.objectId, workspaces); + } + } + + for (const relationship of relationships) { + if (isUnprojectedResourceType(relationship.resource.objectType)) { + ignored++; + continue; + } + + const parentEdge = toParentEdge(relationship); + if (parentEdge) { + parentEdges.set(JSON.stringify(parentEdge), parentEdge); + } + + const sourceRef = toSourceRef(relationship); + if (sourceRef) { + const enrichedSourceRef = (() => { + if (sourceRef.kind !== "feedbackDirectoryAssignment") { + return sourceRef; + } + + const feedbackDirectoryId = onlyValue(assignmentDirectories.get(sourceRef.assignmentId)); + const workspaceId = onlyValue(assignmentWorkspaces.get(sourceRef.assignmentId)); + + return { + ...sourceRef, + ...(feedbackDirectoryId ? { feedbackDirectoryId } : {}), + ...(workspaceId ? { workspaceId } : {}), + }; + })(); + sourceRefs.set(sourceRefKey(enrichedSourceRef), enrichedSourceRef); + managedRelationships.set(relationshipKey(relationship), relationship); + continue; + } + + const ref = toRelationshipRef(relationship); + unmanaged.set(JSON.stringify(ref), ref); + } + + return { + ignored, + managedRelationships: [...managedRelationships.entries()] + .sort(([left], [right]) => byCodeUnit(left, right)) + .map(([, relationship]) => relationship), + parentEdges: [...parentEdges.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, edge]) => edge), + sourceRefs: [...sourceRefs.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, ref]) => ref), + unmanaged: [...unmanaged.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, ref]) => ref), + }; +}; diff --git a/apps/web/lib/authzed/backfill-source.test.ts b/apps/web/lib/authzed/backfill-source.test.ts new file mode 100644 index 000000000000..51d80bea376e --- /dev/null +++ b/apps/web/lib/authzed/backfill-source.test.ts @@ -0,0 +1,608 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import type { TAuthzedSourceRef } from "./backfill-diff"; +import { + findMismatchedParentEdges, + findMissingSourceRefs, + organizationExists, + readOrganizationIdPage, + readOrganizationSource, + readWorkspaceSource, +} from "./backfill-source"; +import { AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE, AUTHZED_TARGET_CHUNK_SIZE } from "./constants"; +import { getFeedbackDirectoryAssignmentObjectId } from "./feedback-directory-assignment-id"; + +vi.mock("node:crypto", async (importOriginal) => importOriginal()); + +vi.mock("@formbricks/database", () => ({ + prisma: { + apiKey: { findMany: vi.fn() }, + apiKeyWorkspace: { findMany: vi.fn() }, + feedbackDirectory: { findMany: vi.fn() }, + feedbackDirectoryWorkspace: { findMany: vi.fn() }, + membership: { findMany: vi.fn() }, + organization: { count: vi.fn(), findMany: vi.fn() }, + team: { findMany: vi.fn() }, + teamUser: { findMany: vi.fn() }, + workspace: { findMany: vi.fn(), findUnique: vi.fn() }, + workspaceTeam: { findMany: vi.fn() }, + }, +})); + +const ORGANIZATION_ID = "org-1"; + +const setEmptySource = (): void => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.team.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.workspace.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.teamUser.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.apiKeyWorkspace.findMany).mockResolvedValue([] as never); +}; + +beforeEach(() => { + vi.clearAllMocks(); + setEmptySource(); +}); + +describe("readOrganizationIdPage", () => { + test("reads the first page without a cursor, ordered so the sweep is stable", async () => { + vi.mocked(prisma.organization.findMany).mockResolvedValue([{ id: "org-1" }, { id: "org-2" }] as never); + + await expect(readOrganizationIdPage()).resolves.toEqual(["org-1", "org-2"]); + expect(prisma.organization.findMany).toHaveBeenCalledWith({ + where: undefined, + select: { id: true }, + orderBy: { id: "asc" }, + take: AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE, + }); + }); + + test("resumes strictly after the supplied cursor so no organization is repeated or skipped", async () => { + vi.mocked(prisma.organization.findMany).mockResolvedValue([{ id: "org-3" }] as never); + + await readOrganizationIdPage({ afterOrganizationId: "org-2", limit: 10 }); + + expect(prisma.organization.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10, where: { id: { gt: "org-2" } } }) + ); + }); + + test("propagates a read failure rather than reporting an empty page", async () => { + vi.mocked(prisma.organization.findMany).mockRejectedValue(new Error("connection reset")); + + await expect(readOrganizationIdPage()).rejects.toThrow("connection reset"); + }); +}); + +describe("organizationExists", () => { + test.each([ + [1, true], + [0, false], + ])("reports %i matching rows as %s", async (count, expected) => { + vi.mocked(prisma.organization.count).mockResolvedValue(count as never); + + await expect(organizationExists(ORGANIZATION_ID)).resolves.toBe(expected); + }); +}); + +describe("readOrganizationSource", () => { + test("scopes every query to the organization and reads only authorization-bearing columns", async () => { + await readOrganizationSource(ORGANIZATION_ID); + + expect(prisma.membership.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { role: true, userId: true }, + where: { organizationId: ORGANIZATION_ID }, + }) + ); + expect(prisma.teamUser.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { team: { organizationId: ORGANIZATION_ID } } }) + ); + expect(prisma.apiKeyWorkspace.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { apiKey: { organizationId: ORGANIZATION_ID } } }) + ); + + // The README commits to never reading key material or usage metadata. + const allQueries = JSON.stringify([ + vi.mocked(prisma.apiKey.findMany).mock.calls, + vi.mocked(prisma.apiKeyWorkspace.findMany).mock.calls, + ]); + for (const forbidden of ["hashedKey", "lookupHash", "lastUsedAt", "createdBy"]) { + expect(allQueries).not.toContain(forbidden); + } + }); + + test("collects every target kind owned by the organization", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([{ role: "owner", userId: "user-1" }] as never); + vi.mocked(prisma.team.findMany).mockResolvedValue([ + { id: "team-1", organizationId: ORGANIZATION_ID }, + ] as never); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([ + { id: "ws-1", organizationId: ORGANIZATION_ID }, + ] as never); + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([ + { + id: "key-1", + organizationAccess: { accessControl: { read: true, write: true } }, + organizationId: ORGANIZATION_ID, + }, + ] as never); + vi.mocked(prisma.teamUser.findMany).mockResolvedValue([ + { role: "admin", teamId: "team-1", userId: "user-1" }, + ] as never); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { + permission: "read", + team: { organizationId: ORGANIZATION_ID }, + teamId: "team-1", + workspaceId: "ws-1", + }, + ] as never); + vi.mocked(prisma.apiKeyWorkspace.findMany).mockResolvedValue([ + { + apiKeyId: "key-1", + permission: "read", + workspace: { organizationId: ORGANIZATION_ID }, + workspaceId: "ws-1", + }, + ] as never); + + await expect(readOrganizationSource(ORGANIZATION_ID)).resolves.toEqual({ + apiKeyIds: ["key-1"], + apiKeyWorkspaceGrants: [{ apiKeyId: "key-1", workspaceId: "ws-1" }], + expectedRelationships: expect.arrayContaining([ + expect.objectContaining({ relation: "owner" }), + expect.objectContaining({ relation: "admin" }), + expect.objectContaining({ relation: "reader_team" }), + expect.objectContaining({ relation: "reader" }), + expect.objectContaining({ relation: "api_key_reader" }), + expect.objectContaining({ relation: "api_key_writer" }), + ]), + feedbackDirectoryAssignments: [], + feedbackDirectoryIds: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + memberships: [{ organizationId: ORGANIZATION_ID, userId: "user-1" }], + teamIds: ["team-1"], + teamMemberships: [{ teamId: "team-1", userId: "user-1" }], + workspaceIds: ["ws-1"], + workspaceTeamGrants: [{ teamId: "team-1", workspaceId: "ws-1" }], + }); + }); + + test("separates a cross-organization workspace-team grant instead of projecting it", async () => { + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { + permission: "read", + team: { organizationId: ORGANIZATION_ID }, + teamId: "own-team", + workspaceId: "ws-1", + }, + { team: { organizationId: "other-org" }, teamId: "foreign-team", workspaceId: "ws-1" }, + ] as never); + + const source = await readOrganizationSource(ORGANIZATION_ID); + + // The foreign grant breaks the closed-unit invariant, so it is reported and then left alone — + // neither projected nor pruned. + expect(source.workspaceTeamGrants).toEqual([{ teamId: "own-team", workspaceId: "ws-1" }]); + expect(source.invalidWorkspaceTeamGrants).toEqual([{ teamId: "foreign-team", workspaceId: "ws-1" }]); + }); + + test("separates a cross-organization API-key workspace grant instead of projecting it", async () => { + // Keyed off the key's organization, so a grant can name a workspace another organization owns. That + // workspace is outside this unit's observation, so projecting the grant would leave the unit + // reporting a missing record forever — it can never be seen and so never converges. + vi.mocked(prisma.apiKeyWorkspace.findMany).mockResolvedValue([ + { + apiKeyId: "key-1", + permission: "read", + workspace: { organizationId: ORGANIZATION_ID }, + workspaceId: "own-ws", + }, + { apiKeyId: "key-1", workspace: { organizationId: "other-org" }, workspaceId: "foreign-ws" }, + ] as never); + + const source = await readOrganizationSource(ORGANIZATION_ID); + + expect(source.apiKeyWorkspaceGrants).toEqual([{ apiKeyId: "key-1", workspaceId: "own-ws" }]); + expect(source.invalidApiKeyWorkspaceGrants).toEqual([{ apiKeyId: "key-1", workspaceId: "foreign-ws" }]); + }); + + test("enumerates active feedback datasets and all three exact assignment edges", async () => { + vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValue([ + { + id: "directory-active", + isArchived: false, + organizationId: ORGANIZATION_ID, + workspaces: [ + { workspace: { organizationId: ORGANIZATION_ID }, workspaceId: "workspace-1" }, + { workspace: { organizationId: "other-org" }, workspaceId: "workspace-cross-org" }, + ], + }, + { + id: "directory-archived", + isArchived: true, + organizationId: ORGANIZATION_ID, + workspaces: [{ workspace: { organizationId: ORGANIZATION_ID }, workspaceId: "workspace-2" }], + }, + ] as never); + + const source = await readOrganizationSource(ORGANIZATION_ID); + const assignmentId = getFeedbackDirectoryAssignmentObjectId("directory-active", "workspace-1"); + + expect(source.feedbackDirectoryIds).toEqual(["directory-active", "directory-archived"]); + expect(source.feedbackDirectoryAssignments).toEqual([ + { feedbackDirectoryId: "directory-active", workspaceId: "workspace-1" }, + { feedbackDirectoryId: "directory-archived", workspaceId: "workspace-2" }, + ]); + expect(source.invalidFeedbackDirectoryAssignments).toEqual([ + { feedbackDirectoryId: "directory-active", workspaceId: "workspace-cross-org" }, + ]); + expect(source.expectedRelationships).toEqual( + expect.arrayContaining([ + { + relation: "assignment", + resource: { objectId: "directory-active", objectType: "feedback_directory" }, + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + { + relation: "directory", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: "directory-active", objectType: "feedback_directory" }, + }, + { + relation: "workspace", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: "workspace-1", objectType: "workspace" }, + }, + ]) + ); + expect( + source.expectedRelationships.some( + ({ resource }) => + resource.objectType === "feedback_directory_assignment" && + resource.objectId === getFeedbackDirectoryAssignmentObjectId("directory-archived", "workspace-2") + ) + ).toBe(false); + }); + + test("reads a workspace's owning organization so a failure can be attributed to a tenant", async () => { + vi.mocked(prisma.workspace.findUnique).mockResolvedValue({ + organizationId: ORGANIZATION_ID, + } as never); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { + permission: "read", + team: { organizationId: ORGANIZATION_ID }, + teamId: "team-1", + workspaceId: "ws-1", + }, + ] as never); + + await expect(readWorkspaceSource("ws-1")).resolves.toEqual({ + apiKeyWorkspaceGrants: [], + expectedRelationships: expect.arrayContaining([ + expect.objectContaining({ relation: "organization" }), + expect.objectContaining({ relation: "reader_team" }), + ]), + feedbackDirectoryAssignments: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + organizationId: ORGANIZATION_ID, + workspaceExists: true, + workspaceTeamGrants: [{ teamId: "team-1", workspaceId: "ws-1" }], + }); + }); + + test("partitions a workspace grant whose principal belongs to another organization", async () => { + // The join tables have independent foreign keys and no same-organization constraint, so a + // cross-tenant row is representable — and `--workspace-id` must refuse to project it, exactly as + // `--organization-id` does. + vi.mocked(prisma.workspace.findUnique).mockResolvedValue({ + organizationId: ORGANIZATION_ID, + } as never); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { + permission: "read", + team: { organizationId: ORGANIZATION_ID }, + teamId: "own-team", + workspaceId: "ws-1", + }, + { team: { organizationId: "other-org" }, teamId: "foreign-team", workspaceId: "ws-1" }, + ] as never); + vi.mocked(prisma.apiKeyWorkspace.findMany).mockResolvedValue([ + { apiKey: { organizationId: "other-org" }, apiKeyId: "foreign-key", workspaceId: "ws-1" }, + ] as never); + + const source = await readWorkspaceSource("ws-1"); + + expect(source.workspaceTeamGrants).toEqual([{ teamId: "own-team", workspaceId: "ws-1" }]); + expect(source.invalidWorkspaceTeamGrants).toEqual([{ teamId: "foreign-team", workspaceId: "ws-1" }]); + expect(source.apiKeyWorkspaceGrants).toEqual([]); + expect(source.invalidApiKeyWorkspaceGrants).toEqual([{ apiKeyId: "foreign-key", workspaceId: "ws-1" }]); + }); + + test("includes only active same-organization feedback dataset assignments in workspace repair", async () => { + vi.mocked(prisma.workspace.findUnique).mockResolvedValue({ organizationId: ORGANIZATION_ID } as never); + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValue([ + { + feedbackDirectory: { isArchived: false, organizationId: ORGANIZATION_ID }, + feedbackDirectoryId: "directory-active", + workspaceId: "ws-1", + }, + { + feedbackDirectory: { isArchived: true, organizationId: ORGANIZATION_ID }, + feedbackDirectoryId: "directory-archived", + workspaceId: "ws-1", + }, + { + feedbackDirectory: { isArchived: false, organizationId: "other-org" }, + feedbackDirectoryId: "directory-cross-org", + workspaceId: "ws-1", + }, + ] as never); + + const source = await readWorkspaceSource("ws-1"); + + expect(source.feedbackDirectoryAssignments).toEqual([ + { feedbackDirectoryId: "directory-active", workspaceId: "ws-1" }, + { feedbackDirectoryId: "directory-archived", workspaceId: "ws-1" }, + ]); + expect(source.invalidFeedbackDirectoryAssignments).toEqual([ + { feedbackDirectoryId: "directory-cross-org", workspaceId: "ws-1" }, + ]); + expect( + source.expectedRelationships.filter(({ resource }) => resource.objectType.startsWith("feedback")) + ).toHaveLength(3); + }); + + test("reports a workspace with no row as absent rather than failing", async () => { + // The case most worth repairing: the row is gone and its relationships are what should be removed. + // Its grants are still read, because those rows can outlive the workspace. + const source = await readWorkspaceSource("ghost-ws"); + + expect(source.workspaceExists).toBe(false); + expect(source.organizationId).toBeNull(); + }); + + test("returns empty target lists for an organization with no records", async () => { + const source = await readOrganizationSource(ORGANIZATION_ID); + + expect(source).toEqual({ + apiKeyIds: [], + apiKeyWorkspaceGrants: [], + expectedRelationships: [], + feedbackDirectoryAssignments: [], + feedbackDirectoryIds: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + memberships: [], + teamIds: [], + teamMemberships: [], + workspaceIds: [], + workspaceTeamGrants: [], + }); + }); + + test("propagates a failure from any single query", async () => { + vi.mocked(prisma.teamUser.findMany).mockRejectedValue(new Error("statement timeout")); + + await expect(readOrganizationSource(ORGANIZATION_ID)).rejects.toThrow("statement timeout"); + }); +}); + +describe("findMismatchedParentEdges", () => { + test("reports a resource attached to an organization that does not own it", async () => { + // The cross-tenant escalation an existence check cannot see: the workspace exists, so it is not an + // orphan, but the edge names an organization whose owners and managers thereby gain access to it. + vi.mocked(prisma.workspace.findMany).mockResolvedValue([ + { id: "ws-1", organizationId: ORGANIZATION_ID }, + ] as never); + + await expect( + findMismatchedParentEdges([ + { childId: "ws-1", childType: "workspace", organizationId: "other-org", relation: "organization" }, + ]) + ).resolves.toEqual([ + { childId: "ws-1", childType: "workspace", organizationId: "other-org", relation: "organization" }, + ]); + }); + + test("accepts an edge naming the true owner", async () => { + vi.mocked(prisma.team.findMany).mockResolvedValue([ + { id: "team-1", organizationId: ORGANIZATION_ID }, + ] as never); + + await expect( + findMismatchedParentEdges([ + { childId: "team-1", childType: "team", organizationId: ORGANIZATION_ID, relation: "organization" }, + ]) + ).resolves.toEqual([]); + }); + + test("leaves an edge whose resource has no row to the orphan path", async () => { + // That is a different finding with a working repair, so reporting it here too would double-count it. + await expect( + findMismatchedParentEdges([ + { childId: "gone", childType: "api_key", organizationId: ORGANIZATION_ID, relation: "organization" }, + ]) + ).resolves.toEqual([]); + }); + + test("checks all three child types in one batch per type", async () => { + await findMismatchedParentEdges([ + { childId: "team-1", childType: "team", organizationId: ORGANIZATION_ID, relation: "organization" }, + { childId: "ws-1", childType: "workspace", organizationId: ORGANIZATION_ID, relation: "organization" }, + { childId: "key-1", childType: "api_key", organizationId: ORGANIZATION_ID, relation: "organization" }, + ]); + + expect(prisma.team.findMany).toHaveBeenCalledTimes(1); + expect(prisma.workspace.findMany).toHaveBeenCalledTimes(1); + expect(prisma.apiKey.findMany).toHaveBeenCalledTimes(1); + }); + + test("fails closed on a lookup error rather than reporting no mismatch", async () => { + vi.mocked(prisma.workspace.findMany).mockRejectedValue(new Error("connection reset")); + + await expect( + findMismatchedParentEdges([ + { childId: "ws-1", childType: "workspace", organizationId: "other-org", relation: "organization" }, + ]) + ).rejects.toThrow("connection reset"); + }); +}); + +describe("findMissingSourceRefs", () => { + const allKinds: ReadonlyArray = [ + { apiKeyId: "key-1", kind: "apiKey" }, + { apiKeyId: "key-1", kind: "apiKeyWorkspaceGrant", workspaceId: "ws-1" }, + { feedbackDirectoryId: "directory-1", kind: "feedbackDirectory" }, + { + assignmentId: getFeedbackDirectoryAssignmentObjectId("directory-1", "ws-1"), + feedbackDirectoryId: "directory-1", + kind: "feedbackDirectoryAssignment", + }, + { kind: "membership", organizationId: ORGANIZATION_ID, userId: "user-1" }, + { kind: "team", teamId: "team-1" }, + { kind: "teamMembership", teamId: "team-1", userId: "user-1" }, + { kind: "workspace", workspaceId: "ws-1" }, + { kind: "workspaceTeamGrant", teamId: "team-1", workspaceId: "ws-1" }, + ]; + + test("reports nothing missing when every record is present", async () => { + vi.mocked(prisma.apiKey.findMany).mockResolvedValue([{ id: "key-1" }] as never); + vi.mocked(prisma.apiKeyWorkspace.findMany).mockResolvedValue([ + { apiKeyId: "key-1", workspaceId: "ws-1" }, + ] as never); + vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValue([{ id: "directory-1" }] as never); + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValue([ + { feedbackDirectoryId: "directory-1", workspaceId: "ws-1" }, + ] as never); + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { organizationId: ORGANIZATION_ID, userId: "user-1" }, + ] as never); + vi.mocked(prisma.team.findMany).mockResolvedValue([{ id: "team-1" }] as never); + vi.mocked(prisma.teamUser.findMany).mockResolvedValue([{ teamId: "team-1", userId: "user-1" }] as never); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([{ id: "ws-1" }] as never); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { teamId: "team-1", workspaceId: "ws-1" }, + ] as never); + + await expect(findMissingSourceRefs(allKinds)).resolves.toEqual([]); + }); + + test("reports every kind of record that PostgreSQL does not hold", async () => { + // Every query returns nothing, so every current source kind is missing. + await expect(findMissingSourceRefs(allKinds)).resolves.toEqual(allKinds); + }); + + test("verifies hashed assignments from either observable edge without reversing the hash", async () => { + const assignmentId = getFeedbackDirectoryAssignmentObjectId("directory-1", "ws-1"); + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValue([ + { feedbackDirectoryId: "directory-1", workspaceId: "ws-1" }, + ] as never); + + await expect( + findMissingSourceRefs([ + { + assignmentId, + feedbackDirectoryId: "directory-1", + kind: "feedbackDirectoryAssignment", + }, + { assignmentId, kind: "feedbackDirectoryAssignment", workspaceId: "ws-1" }, + ]) + ).resolves.toEqual([]); + + expect(prisma.feedbackDirectoryWorkspace.findMany).toHaveBeenCalledWith({ + where: { + feedbackDirectory: { isArchived: false }, + OR: [{ feedbackDirectoryId: "directory-1" }, { workspaceId: "ws-1" }], + }, + select: { feedbackDirectoryId: true, workspaceId: true }, + }); + }); + + test("distinguishes a present record from an absent one of the same kind", async () => { + vi.mocked(prisma.team.findMany).mockResolvedValue([{ id: "present-team" }] as never); + + await expect( + findMissingSourceRefs([ + { kind: "team", teamId: "present-team" }, + { kind: "team", teamId: "absent-team" }, + ]) + ).resolves.toEqual([{ kind: "team", teamId: "absent-team" }]); + }); + + test("does not confuse composite keys across pair boundaries", async () => { + // A naive concatenated key would match ("ab", "c") against ("a", "bc"). + vi.mocked(prisma.teamUser.findMany).mockResolvedValue([{ teamId: "ab", userId: "c" }] as never); + + await expect( + findMissingSourceRefs([ + { kind: "teamMembership", teamId: "ab", userId: "c" }, + { kind: "teamMembership", teamId: "a", userId: "bc" }, + ]) + ).resolves.toEqual([{ kind: "teamMembership", teamId: "a", userId: "bc" }]); + }); + + test("skips the query for a kind that was not asked about", async () => { + await findMissingSourceRefs([{ kind: "team", teamId: "team-1" }]); + + expect(prisma.team.findMany).toHaveBeenCalledTimes(1); + expect(prisma.membership.findMany).not.toHaveBeenCalled(); + expect(prisma.apiKey.findMany).not.toHaveBeenCalled(); + }); + + test("chunks a large request so the query cannot approach the bind-parameter ceiling", async () => { + // The composite-key kinds contribute two bind parameters per record, so an unchunked list would build + // exactly the unbounded OR that the chunk size exists to prevent. + const refs = Array.from({ length: AUTHZED_TARGET_CHUNK_SIZE + 1 }, (_unused, index) => ({ + kind: "teamMembership" as const, + teamId: "team-1", + userId: `user-${index}`, + })); + + await findMissingSourceRefs(refs); + + expect(prisma.teamUser.findMany).toHaveBeenCalledTimes(2); + }); + + test("issues one batched query per kind rather than one per record", async () => { + await findMissingSourceRefs([ + { kind: "team", teamId: "team-1" }, + { kind: "team", teamId: "team-2" }, + { kind: "team", teamId: "team-3" }, + ]); + + expect(prisma.team.findMany).toHaveBeenCalledTimes(1); + expect(prisma.team.findMany).toHaveBeenCalledWith({ + where: { id: { in: ["team-1", "team-2", "team-3"] } }, + select: { id: true }, + }); + }); + + test("fails closed: a query failure never presents records as absent", async () => { + // This is the single most dangerous mistake available to this tooling. Treating a failed lookup as + // "no source row" would classify live access as orphaned and, under pruning, revoke it at scale. + vi.mocked(prisma.team.findMany).mockRejectedValue(new Error("connection pool exhausted")); + + await expect(findMissingSourceRefs([{ kind: "team", teamId: "team-1" }])).rejects.toThrow( + "connection pool exhausted" + ); + }); + + test("returns nothing for an empty request without querying", async () => { + await expect(findMissingSourceRefs([])).resolves.toEqual([]); + + expect(prisma.team.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/authzed/backfill-source.ts b/apps/web/lib/authzed/backfill-source.ts new file mode 100644 index 000000000000..e94955dbbff5 --- /dev/null +++ b/apps/web/lib/authzed/backfill-source.ts @@ -0,0 +1,803 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import type { TAuthzedParentEdge, TAuthzedSourceRef } from "./backfill-diff"; +import type { TAuthzedRelationship } from "./client"; +import { AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE, AUTHZED_TARGET_CHUNK_SIZE } from "./constants"; +import { getFeedbackDirectoryAssignmentObjectId } from "./feedback-directory-assignment-id"; +import { + ORGANIZATION_ACCESS_RELATIONS, + ORGANIZATION_RELATIONS, + TEAM_RELATIONS, + WORKSPACE_API_KEY_RELATIONS, + WORKSPACE_TEAM_RELATIONS, + normalizeOrganizationAccess, +} from "./relationship-map"; + +/** + * PostgreSQL enumeration for relationship backfill and repair. + * + * **Command-line use only. No route, server action, background job, or request-path module may import + * this file or anything that consumes it.** These reads are deliberately not tenant-scoped — sweeping + * every organization is the entire point — and the tooling built on them performs no authorization + * check of its own, because it runs as an operator with the AuthZed system credential. Reachable from + * an HTTP surface it would let a caller rewrite any tenant's permission graph by ID. + * + * This is the only file in the backfill that touches `prisma`, which keeps "what does the tooling read + * from the database?" a single-file audit. Every query names its columns explicitly and reads only + * identifiers, roles, permissions, and API-key organization access — never plaintext keys, key hashes, + * lookup hashes, creator metadata, or usage timestamps. + * + * **Errors are never caught here.** Absence is what marks a relationship stale, so a failed query must + * not be mistaken for "no source row": that would classify live access as orphaned and, under pruning, + * revoke it at scale. A failure propagates, the unit is reported failed, and the run continues. + */ + +export type TAuthzedMembershipTarget = Readonly<{ organizationId: string; userId: string }>; +export type TAuthzedTeamMembershipTarget = Readonly<{ teamId: string; userId: string }>; +export type TAuthzedWorkspaceTeamTarget = Readonly<{ teamId: string; workspaceId: string }>; +export type TAuthzedApiKeyWorkspaceTarget = Readonly<{ apiKeyId: string; workspaceId: string }>; +export type TAuthzedFeedbackDirectoryAssignmentTarget = Readonly<{ + feedbackDirectoryId: string; + workspaceId: string; +}>; + +/** + * Every authorization-relevant record owned by one organization. + * + * The organization is a closed unit: each of these models reaches `Organization` in one hop or two, so + * a complete set of targets for one organization can be enumerated without consulting any other. + */ +export type TAuthzedOrganizationSource = Readonly<{ + apiKeyIds: ReadonlyArray; + apiKeyWorkspaceGrants: ReadonlyArray; + /** Exact managed relationship set derived from the same maps as the projectors. */ + expectedRelationships: ReadonlyArray; + /** All valid pairs to observe/reconcile; archived pairs are targets but contribute no expected edges. */ + feedbackDirectoryAssignments: ReadonlyArray; + feedbackDirectoryIds: ReadonlyArray; + /** + * API-key workspace grants whose key and workspace belong to different organizations. + * + * Same treatment as `invalidWorkspaceTeamGrants`: reported, never projected, never pruned. + */ + invalidApiKeyWorkspaceGrants: ReadonlyArray; + invalidFeedbackDirectoryAssignments: ReadonlyArray; + /** + * Workspace-team grants whose team and workspace belong to different organizations. + * + * Formbricks never creates one, and it would break the closed-unit invariant, so these are reported + * and then left strictly alone — neither projected nor pruned. + */ + invalidWorkspaceTeamGrants: ReadonlyArray; + memberships: ReadonlyArray; + teamIds: ReadonlyArray; + teamMemberships: ReadonlyArray; + workspaceIds: ReadonlyArray; + workspaceTeamGrants: ReadonlyArray; +}>; + +/** The grants attached to one workspace, for the narrower workspace repair scope. */ +export type TAuthzedWorkspaceSource = Readonly<{ + apiKeyWorkspaceGrants: ReadonlyArray; + /** Exact managed relationship set for this workspace and its valid grants. */ + expectedRelationships: ReadonlyArray; + /** All valid pairs to observe/reconcile; archived pairs are targets but contribute no expected edges. */ + feedbackDirectoryAssignments: ReadonlyArray; + /** + * Grants whose principal belongs to a different organization than the workspace. + * + * The join tables carry independent foreign keys and no same-organization constraint, so a + * cross-tenant row is representable. The organization scope already partitions these out; this scope + * has to as well, or `--workspace-id` would *write* a cross-tenant grant that `--organization-id` + * refuses to write. Reported, never projected, never pruned. + */ + invalidApiKeyWorkspaceGrants: ReadonlyArray; + invalidFeedbackDirectoryAssignments: ReadonlyArray; + invalidWorkspaceTeamGrants: ReadonlyArray; + /** + * The owning organization, or `null` when the workspace has no row. + * + * Read so a failure in this scope can be attributed to a tenant. Without it every workspace-scoped + * failure reports the empty string, which is also the sweep's marker for a genuinely unattributable + * orphan — leaving the two indistinguishable in the output. + */ + organizationId: string | null; + /** Reported rather than enforced: a missing workspace is a valid repair target, not an error. */ + workspaceExists: boolean; + workspaceTeamGrants: ReadonlyArray; +}>; + +type TOrganizationWorkspaceTeamGrant = Readonly<{ + permission: keyof typeof WORKSPACE_TEAM_RELATIONS; + team: Readonly<{ organizationId: string }>; + teamId: string; + workspaceId: string; +}>; + +type TOrganizationApiKeyWorkspaceGrant = Readonly<{ + apiKeyId: string; + permission: keyof typeof WORKSPACE_API_KEY_RELATIONS; + workspace: Readonly<{ organizationId: string }>; + workspaceId: string; +}>; + +type TOrganizationFeedbackDirectory = Readonly<{ + id: string; + isArchived: boolean; + organizationId: string; + workspaces: ReadonlyArray< + Readonly<{ workspace: Readonly<{ organizationId: string }>; workspaceId: string }> + >; +}>; +const partitionByOrganization = ( + grants: ReadonlyArray, + organizationId: string | null, + getOrganizationId: (grant: TGrant) => string +): Readonly<{ invalid: TGrant[]; valid: TGrant[] }> => { + if (organizationId === null) { + return { invalid: [], valid: [...grants] }; + } + + const valid: TGrant[] = []; + const invalid: TGrant[] = []; + for (const grant of grants) { + (getOrganizationId(grant) === organizationId ? valid : invalid).push(grant); + } + + return { invalid, valid }; +}; + +const toWorkspaceTeamTarget = ({ + teamId, + workspaceId, +}: Pick): TAuthzedWorkspaceTeamTarget => ({ + teamId, + workspaceId, +}); + +const toApiKeyWorkspaceTarget = ({ + apiKeyId, + workspaceId, +}: Pick): TAuthzedApiKeyWorkspaceTarget => ({ + apiKeyId, + workspaceId, +}); + +const getApiKeyRelationships = ( + apiKey: Readonly<{ id: string; organizationAccess: unknown; organizationId: string }> +): ReadonlyArray => { + const relationships: TAuthzedRelationship[] = [ + { + relation: "organization", + resource: { objectId: apiKey.id, objectType: "api_key" }, + subject: { objectId: apiKey.organizationId, objectType: "organization" }, + }, + ]; + const access = normalizeOrganizationAccess(apiKey.organizationAccess); + for (const permission of Object.keys(ORGANIZATION_ACCESS_RELATIONS) as ReadonlyArray< + keyof typeof ORGANIZATION_ACCESS_RELATIONS + >) { + if (access[permission]) { + relationships.push({ + relation: ORGANIZATION_ACCESS_RELATIONS[permission], + resource: { objectId: apiKey.organizationId, objectType: "organization" }, + subject: { objectId: apiKey.id, objectType: "api_key" }, + }); + } + } + + return relationships; +}; + +const getFeedbackDirectorySource = ( + directories: ReadonlyArray, + organizationId: string +): Readonly<{ + assignments: TAuthzedFeedbackDirectoryAssignmentTarget[]; + invalidAssignments: TAuthzedFeedbackDirectoryAssignmentTarget[]; + relationships: TAuthzedRelationship[]; +}> => { + const assignments: TAuthzedFeedbackDirectoryAssignmentTarget[] = []; + const invalidAssignments: TAuthzedFeedbackDirectoryAssignmentTarget[] = []; + const relationships: TAuthzedRelationship[] = []; + + for (const directory of directories) { + relationships.push({ + relation: "organization", + resource: { objectId: directory.id, objectType: "feedback_directory" }, + subject: { objectId: directory.organizationId, objectType: "organization" }, + }); + for (const workspace of directory.workspaces) { + const target = { feedbackDirectoryId: directory.id, workspaceId: workspace.workspaceId }; + if (workspace.workspace.organizationId !== organizationId) { + invalidAssignments.push(target); + continue; + } + + assignments.push(target); + if (directory.isArchived) { + continue; + } + + const assignmentId = getFeedbackDirectoryAssignmentObjectId(directory.id, workspace.workspaceId); + relationships.push( + { + relation: "assignment", + resource: { objectId: directory.id, objectType: "feedback_directory" }, + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + { + relation: "directory", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: directory.id, objectType: "feedback_directory" }, + }, + { + relation: "workspace", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: workspace.workspaceId, objectType: "workspace" }, + } + ); + } + } + + return { assignments, invalidAssignments, relationships }; +}; +/** + * One keyset page of organization IDs. + * + * Keyset rather than offset so the sweep is stable while organizations are created and deleted, and so + * an interrupted run resumes from the last ID it reported instead of restarting. + */ +export const readOrganizationIdPage = async ( + page: Readonly<{ afterOrganizationId?: string; limit?: number }> = {} +): Promise> => { + const organizations = await prisma.organization.findMany({ + where: page.afterOrganizationId ? { id: { gt: page.afterOrganizationId } } : undefined, + select: { id: true }, + orderBy: { id: "asc" }, + take: page.limit ?? AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE, + }); + + return organizations.map(({ id }) => id); +}; + +export const organizationExists = async (organizationId: string): Promise => + (await prisma.organization.count({ where: { id: organizationId } })) > 0; + +/** + * Enumerate the authorization-relevant records attached to one workspace. + * + * A narrower unit than the organization, for repairing a single workspace's grants without touching the + * rest of the tenant. + * + * Deliberately does **not** require the workspace to exist. A workspace whose row is already gone is + * the case most worth repairing — its relationships are exactly what should be removed — and an + * organization ID is not needed to reach them, because the caller supplies the workspace ID directly. + */ +export const readWorkspaceSource = async (workspaceId: string): Promise => { + const [workspace, workspaceTeams, apiKeyWorkspaces, directoryAssignments] = await Promise.all([ + prisma.workspace.findUnique({ where: { id: workspaceId }, select: { organizationId: true } }), + prisma.workspaceTeam.findMany({ + where: { workspaceId }, + // The principal's organization is read so a cross-organization grant can be partitioned out + // rather than projected, matching what the organization scope already does. + select: { + permission: true, + team: { select: { organizationId: true } }, + teamId: true, + workspaceId: true, + }, + orderBy: { teamId: "asc" }, + }), + prisma.apiKeyWorkspace.findMany({ + where: { workspaceId }, + select: { + apiKey: { select: { organizationId: true } }, + apiKeyId: true, + permission: true, + workspaceId: true, + }, + orderBy: { apiKeyId: "asc" }, + }), + prisma.feedbackDirectoryWorkspace.findMany({ + where: { workspaceId }, + select: { + feedbackDirectory: { select: { isArchived: true, organizationId: true } }, + feedbackDirectoryId: true, + workspaceId: true, + }, + orderBy: { feedbackDirectoryId: "asc" }, + }), + ]); + + const organizationId = workspace?.organizationId ?? null; + + // Only decidable when the workspace still has a row. With no row there is no organization to compare + // against, and its grants are stale by construction — the prune path is what deals with them. + const teamGrants = partitionByOrganization( + workspaceTeams, + organizationId, + (grant) => grant.team.organizationId + ); + const keyGrants = partitionByOrganization( + apiKeyWorkspaces, + organizationId, + (grant) => grant.apiKey.organizationId + ); + const directoryGrants = partitionByOrganization( + directoryAssignments, + organizationId, + (grant) => grant.feedbackDirectory.organizationId + ); + + const expectedRelationships: TAuthzedRelationship[] = []; + if (organizationId !== null) { + expectedRelationships.push({ + relation: "organization", + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: organizationId, objectType: "organization" }, + }); + } + for (const grant of teamGrants.valid) { + expectedRelationships.push({ + relation: WORKSPACE_TEAM_RELATIONS[grant.permission], + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: grant.teamId, objectType: "team", relation: "member" }, + }); + } + for (const grant of keyGrants.valid) { + expectedRelationships.push({ + relation: WORKSPACE_API_KEY_RELATIONS[grant.permission], + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: grant.apiKeyId, objectType: "api_key" }, + }); + } + const feedbackDirectoryAssignments = directoryGrants.valid.map(({ feedbackDirectoryId }) => ({ + feedbackDirectoryId, + workspaceId, + })); + for (const grant of directoryGrants.valid) { + if (grant.feedbackDirectory.isArchived) { + continue; + } + const assignment = { feedbackDirectoryId: grant.feedbackDirectoryId, workspaceId }; + const assignmentId = getFeedbackDirectoryAssignmentObjectId( + assignment.feedbackDirectoryId, + assignment.workspaceId + ); + expectedRelationships.push( + { + relation: "assignment", + resource: { objectId: assignment.feedbackDirectoryId, objectType: "feedback_directory" }, + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + { + relation: "directory", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: assignment.feedbackDirectoryId, objectType: "feedback_directory" }, + }, + { + relation: "workspace", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: assignment.workspaceId, objectType: "workspace" }, + } + ); + } + + return { + apiKeyWorkspaceGrants: keyGrants.valid.map(toApiKeyWorkspaceTarget), + expectedRelationships, + feedbackDirectoryAssignments, + invalidApiKeyWorkspaceGrants: keyGrants.invalid.map(toApiKeyWorkspaceTarget), + invalidFeedbackDirectoryAssignments: directoryGrants.invalid.map(({ feedbackDirectoryId }) => ({ + feedbackDirectoryId, + workspaceId, + })), + invalidWorkspaceTeamGrants: teamGrants.invalid.map(toWorkspaceTeamTarget), + organizationId, + // Truthiness rather than `!== null`, so a row is required to claim existence rather than merely the + // absence of one particular falsy value. + workspaceExists: Boolean(workspace), + workspaceTeamGrants: teamGrants.valid.map(toWorkspaceTeamTarget), + }; +}; + +/** Enumerate every authorization-relevant record owned by one organization. */ +export const readOrganizationSource = async (organizationId: string): Promise => { + const [ + memberships, + teams, + workspaces, + apiKeys, + teamMemberships, + workspaceTeams, + apiKeyWorkspaces, + feedbackDirectories, + ] = await Promise.all([ + prisma.membership.findMany({ + where: { organizationId }, + select: { role: true, userId: true }, + orderBy: { userId: "asc" }, + }), + prisma.team.findMany({ + where: { organizationId }, + select: { id: true, organizationId: true }, + orderBy: { id: "asc" }, + }), + prisma.workspace.findMany({ + where: { organizationId }, + select: { id: true, organizationId: true }, + orderBy: { id: "asc" }, + }), + prisma.apiKey.findMany({ + where: { organizationId }, + select: { id: true, organizationAccess: true, organizationId: true }, + orderBy: { id: "asc" }, + }), + prisma.teamUser.findMany({ + where: { team: { organizationId } }, + select: { role: true, teamId: true, userId: true }, + orderBy: [{ teamId: "asc" }, { userId: "asc" }], + }), + prisma.workspaceTeam.findMany({ + where: { workspace: { organizationId } }, + // The team's organization is read so a cross-organization grant can be detected rather than + // silently projected as if the unit were closed. + select: { + permission: true, + team: { select: { organizationId: true } }, + teamId: true, + workspaceId: true, + }, + orderBy: [{ workspaceId: "asc" }, { teamId: "asc" }], + }), + prisma.apiKeyWorkspace.findMany({ + where: { apiKey: { organizationId } }, + // Keyed off the *key's* organization, so the workspace's is read to detect a grant that crosses + // organizations — the same check `workspaceTeam` above performs, and for the same reason. + select: { + apiKeyId: true, + permission: true, + workspace: { select: { organizationId: true } }, + workspaceId: true, + }, + orderBy: [{ apiKeyId: "asc" }, { workspaceId: "asc" }], + }), + prisma.feedbackDirectory.findMany({ + where: { organizationId }, + select: { + id: true, + isArchived: true, + organizationId: true, + workspaces: { + select: { workspace: { select: { organizationId: true } }, workspaceId: true }, + orderBy: { workspaceId: "asc" }, + }, + }, + orderBy: { id: "asc" }, + }), + ]); + + const teamGrants = partitionByOrganization( + workspaceTeams, + organizationId, + (grant) => grant.team.organizationId + ); + // A grant whose workspace belongs to another organization is unreachable from this one: the + // observation only reads workspaces this organization owns, so an expected relationship naming a + // foreign workspace could never be seen and the unit would report drift that no run can converge. + // Excluded from the targets for the same reason as the workspace-team case — never projected, never + // pruned, only reported. + const keyGrants = partitionByOrganization( + apiKeyWorkspaces, + organizationId, + (grant) => grant.workspace.organizationId + ); + const directorySource = getFeedbackDirectorySource(feedbackDirectories, organizationId); + + const expectedRelationships: TAuthzedRelationship[] = [ + ...memberships.map(({ role, userId }) => ({ + relation: ORGANIZATION_RELATIONS[role], + resource: { objectId: organizationId, objectType: "organization" }, + subject: { objectId: userId, objectType: "user" }, + })), + ...teams.map(({ id, organizationId: teamOrganizationId }) => ({ + relation: "organization", + resource: { objectId: id, objectType: "team" }, + subject: { objectId: teamOrganizationId, objectType: "organization" }, + })), + ...teamMemberships.map(({ role, teamId, userId }) => ({ + relation: TEAM_RELATIONS[role], + resource: { objectId: teamId, objectType: "team" }, + subject: { objectId: userId, objectType: "user" }, + })), + ...workspaces.map(({ id, organizationId: workspaceOrganizationId }) => ({ + relation: "organization", + resource: { objectId: id, objectType: "workspace" }, + subject: { objectId: workspaceOrganizationId, objectType: "organization" }, + })), + ...teamGrants.valid.map(({ permission, teamId, workspaceId }) => ({ + relation: WORKSPACE_TEAM_RELATIONS[permission], + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: teamId, objectType: "team", relation: "member" }, + })), + ...apiKeys.flatMap(getApiKeyRelationships), + ...keyGrants.valid.map(({ apiKeyId, permission, workspaceId }) => ({ + relation: WORKSPACE_API_KEY_RELATIONS[permission], + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: apiKeyId, objectType: "api_key" }, + })), + ...directorySource.relationships, + ]; + + return { + apiKeyIds: apiKeys.map(({ id }) => id), + apiKeyWorkspaceGrants: keyGrants.valid.map(toApiKeyWorkspaceTarget), + expectedRelationships, + feedbackDirectoryAssignments: directorySource.assignments, + feedbackDirectoryIds: feedbackDirectories.map(({ id }) => id), + invalidApiKeyWorkspaceGrants: keyGrants.invalid.map(toApiKeyWorkspaceTarget), + invalidFeedbackDirectoryAssignments: directorySource.invalidAssignments, + invalidWorkspaceTeamGrants: teamGrants.invalid.map(toWorkspaceTeamTarget), + memberships: memberships.map(({ userId }) => ({ organizationId, userId })), + teamIds: teams.map(({ id }) => id), + teamMemberships: teamMemberships.map(({ teamId, userId }) => ({ teamId, userId })), + workspaceIds: workspaces.map(({ id }) => id), + workspaceTeamGrants: teamGrants.valid.map(toWorkspaceTeamTarget), + }; +}; + +/** + * Run a reader over one chunk at a time, concatenating the results. + * + * Both readers below build a `where: { OR: [...] }` from their input, which is unbounded by + * construction — so both need the same chunking, and it lives here once rather than being re-derived + * per reader. Sequential rather than parallel: these run inside a sweep that already bounds its own + * concurrency, and a fan-out here would multiply it. + */ +const inChunks = async ( + items: ReadonlyArray, + read: (chunk: ReadonlyArray) => Promise> +): Promise> => { + const collected: TItem[] = []; + for (let start = 0; start < items.length; start += AUTHZED_TARGET_CHUNK_SIZE) { + collected.push(...(await read(items.slice(start, start + AUTHZED_TARGET_CHUNK_SIZE)))); + } + + return collected; +}; + +/** + * Of the observed parent edges, report those PostgreSQL contradicts. + * + * An edge is only correct when the resource exists *and* belongs to the organization the edge names. An + * existence check alone cannot tell the difference, which is why this is separate — and it is the check + * that catches a cross-tenant parent edge, where a resource is additionally attached to an organization + * that does not own it and every owner and manager of that organization silently gains access. + * + * Errors propagate, for the same reason as everywhere else in this module: a failed lookup must never be + * read as "PostgreSQL disagrees". + */ +export const findMismatchedParentEdges = async ( + edges: ReadonlyArray +): Promise> => { + if (edges.length > AUTHZED_TARGET_CHUNK_SIZE) { + return inChunks(edges, findMismatchedParentEdges); + } + + const idsFor = (childType: TAuthzedParentEdge["childType"]): ReadonlyArray => [ + ...new Set(edges.filter((edge) => edge.childType === childType).map((edge) => edge.childId)), + ]; + const teamIds = idsFor("team"); + const workspaceIds = idsFor("workspace"); + const apiKeyIds = idsFor("api_key"); + const feedbackDirectoryIds = idsFor("feedback_directory"); + + const [teams, workspaces, apiKeys, feedbackDirectories] = await Promise.all([ + teamIds.length === 0 + ? [] + : prisma.team.findMany({ + where: { id: { in: [...teamIds] } }, + select: { id: true, organizationId: true }, + }), + workspaceIds.length === 0 + ? [] + : prisma.workspace.findMany({ + where: { id: { in: [...workspaceIds] } }, + select: { id: true, organizationId: true }, + }), + apiKeyIds.length === 0 + ? [] + : prisma.apiKey.findMany({ + where: { id: { in: [...apiKeyIds] } }, + select: { id: true, organizationId: true }, + }), + feedbackDirectoryIds.length === 0 + ? [] + : prisma.feedbackDirectory.findMany({ + where: { id: { in: [...feedbackDirectoryIds] } }, + select: { id: true, organizationId: true }, + }), + ]); + + const trueParents = new Map([ + ...teams.map(({ id, organizationId }): [string, string] => [`team:${id}`, organizationId]), + ...workspaces.map(({ id, organizationId }): [string, string] => [`workspace:${id}`, organizationId]), + ...apiKeys.map(({ id, organizationId }): [string, string] => [`api_key:${id}`, organizationId]), + ...feedbackDirectories.map(({ id, organizationId }): [string, string] => [ + `feedback_directory:${id}`, + organizationId, + ]), + ]); + + // A resource with no row at all is not reported here — that is an orphan, handled by the existence + // check, and it has a working repair path. This is only about a resource that exists under a different + // organization than the edge claims. + return edges.filter((edge) => { + const trueParent = trueParents.get(`${edge.childType}:${edge.childId}`); + return trueParent !== undefined && trueParent !== edge.organizationId; + }); +}; + +const byKind = ( + refs: ReadonlyArray, + kind: TKind +): ReadonlyArray> => + refs.filter((ref): ref is Extract => ref.kind === kind); + +const pairKey = (first: string, second: string): string => `${first.length}:${first}${second}`; + +/** + * Of the supplied source records, report those PostgreSQL does not hold. + * + * One batched query per record kind, so the cost is bounded by the number of kinds rather than by the + * number of records. A query failure propagates untouched — see the note on this module: treating a + * failure as absence is the one mistake that turns this tooling destructive. + */ +export const findMissingSourceRefs = async ( + refs: ReadonlyArray +): Promise> => { + // Chunked for the same reason reconciler targets are: the composite-key kinds contribute two bind + // parameters per record, so an unchunked list would approach PostgreSQL's parameter ceiling and give + // the planner an `OR` list it cannot use an index for. One query per kind *per chunk* still keeps the + // cost proportional to the number of kinds rather than the number of records. + if (refs.length > AUTHZED_TARGET_CHUNK_SIZE) { + return inChunks(refs, findMissingSourceRefs); + } + + const apiKeyRefs = byKind(refs, "apiKey"); + const membershipRefs = byKind(refs, "membership"); + const teamRefs = byKind(refs, "team"); + const teamMembershipRefs = byKind(refs, "teamMembership"); + const workspaceRefs = byKind(refs, "workspace"); + const workspaceTeamGrantRefs = byKind(refs, "workspaceTeamGrant"); + const apiKeyWorkspaceGrantRefs = byKind(refs, "apiKeyWorkspaceGrant"); + const feedbackDirectoryRefs = byKind(refs, "feedbackDirectory"); + const feedbackDirectoryAssignmentRefs = byKind(refs, "feedbackDirectoryAssignment"); + + const [ + apiKeys, + memberships, + teams, + teamMemberships, + workspaces, + workspaceTeams, + apiKeyWorkspaces, + feedbackDirectories, + feedbackDirectoryAssignments, + ] = await Promise.all([ + apiKeyRefs.length === 0 + ? [] + : prisma.apiKey.findMany({ + where: { id: { in: apiKeyRefs.map(({ apiKeyId }) => apiKeyId) } }, + select: { id: true }, + }), + membershipRefs.length === 0 + ? [] + : prisma.membership.findMany({ + where: { + OR: membershipRefs.map(({ organizationId, userId }) => ({ organizationId, userId })), + }, + select: { organizationId: true, userId: true }, + }), + teamRefs.length === 0 + ? [] + : prisma.team.findMany({ + where: { id: { in: teamRefs.map(({ teamId }) => teamId) } }, + select: { id: true }, + }), + teamMembershipRefs.length === 0 + ? [] + : prisma.teamUser.findMany({ + where: { OR: teamMembershipRefs.map(({ teamId, userId }) => ({ teamId, userId })) }, + select: { teamId: true, userId: true }, + }), + workspaceRefs.length === 0 + ? [] + : prisma.workspace.findMany({ + where: { id: { in: workspaceRefs.map(({ workspaceId }) => workspaceId) } }, + select: { id: true }, + }), + workspaceTeamGrantRefs.length === 0 + ? [] + : prisma.workspaceTeam.findMany({ + where: { + OR: workspaceTeamGrantRefs.map(({ teamId, workspaceId }) => ({ teamId, workspaceId })), + }, + select: { teamId: true, workspaceId: true }, + }), + apiKeyWorkspaceGrantRefs.length === 0 + ? [] + : prisma.apiKeyWorkspace.findMany({ + where: { + OR: apiKeyWorkspaceGrantRefs.map(({ apiKeyId, workspaceId }) => ({ apiKeyId, workspaceId })), + }, + select: { apiKeyId: true, workspaceId: true }, + }), + feedbackDirectoryRefs.length === 0 + ? [] + : prisma.feedbackDirectory.findMany({ + where: { id: { in: feedbackDirectoryRefs.map(({ feedbackDirectoryId }) => feedbackDirectoryId) } }, + select: { id: true }, + }), + feedbackDirectoryAssignmentRefs.length === 0 + ? [] + : prisma.feedbackDirectoryWorkspace.findMany({ + where: { + feedbackDirectory: { isArchived: false }, + OR: feedbackDirectoryAssignmentRefs.flatMap(({ feedbackDirectoryId, workspaceId }) => [ + ...(feedbackDirectoryId === undefined ? [] : [{ feedbackDirectoryId }]), + ...(workspaceId === undefined ? [] : [{ workspaceId }]), + ]), + }, + select: { feedbackDirectoryId: true, workspaceId: true }, + }), + ]); + + const existingApiKeyIds = new Set(apiKeys.map(({ id }) => id)); + const existingTeamIds = new Set(teams.map(({ id }) => id)); + const existingWorkspaceIds = new Set(workspaces.map(({ id }) => id)); + const existingMemberships = new Set( + memberships.map(({ organizationId, userId }) => pairKey(organizationId, userId)) + ); + const existingTeamMemberships = new Set( + teamMemberships.map(({ teamId, userId }) => pairKey(teamId, userId)) + ); + const existingWorkspaceTeams = new Set( + workspaceTeams.map(({ teamId, workspaceId }) => pairKey(workspaceId, teamId)) + ); + const existingApiKeyWorkspaces = new Set( + apiKeyWorkspaces.map(({ apiKeyId, workspaceId }) => pairKey(apiKeyId, workspaceId)) + ); + const existingFeedbackDirectoryIds = new Set(feedbackDirectories.map(({ id }) => id)); + const existingFeedbackDirectoryAssignments = new Set( + feedbackDirectoryAssignments.map(({ feedbackDirectoryId, workspaceId }) => + getFeedbackDirectoryAssignmentObjectId(feedbackDirectoryId, workspaceId) + ) + ); + + const isPresent = (ref: TAuthzedSourceRef): boolean => { + switch (ref.kind) { + case "apiKey": + return existingApiKeyIds.has(ref.apiKeyId); + case "apiKeyWorkspaceGrant": + return existingApiKeyWorkspaces.has(pairKey(ref.apiKeyId, ref.workspaceId)); + case "feedbackDirectory": + return existingFeedbackDirectoryIds.has(ref.feedbackDirectoryId); + case "feedbackDirectoryAssignment": + return existingFeedbackDirectoryAssignments.has(ref.assignmentId); + case "membership": + return existingMemberships.has(pairKey(ref.organizationId, ref.userId)); + case "team": + return existingTeamIds.has(ref.teamId); + case "teamMembership": + return existingTeamMemberships.has(pairKey(ref.teamId, ref.userId)); + case "workspace": + return existingWorkspaceIds.has(ref.workspaceId); + case "workspaceTeamGrant": + return existingWorkspaceTeams.has(pairKey(ref.workspaceId, ref.teamId)); + } + }; + + return refs.filter((ref) => !isPresent(ref)); +}; diff --git a/apps/web/lib/authzed/backfill.test.ts b/apps/web/lib/authzed/backfill.test.ts new file mode 100644 index 000000000000..8fdadf5b4c6a --- /dev/null +++ b/apps/web/lib/authzed/backfill.test.ts @@ -0,0 +1,1178 @@ +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { type TAuthzedBackfillRequest, runAuthzedBackfill } from "./backfill"; +import * as source from "./backfill-source"; +import type { TAuthzedOrganizationSource } from "./backfill-source"; +import { + AUTHZED_MAX_RELATIONSHIP_READS, + AUTHZED_MAX_TRACKED_ORPHAN_REFS, + AUTHZED_TARGET_CHUNK_SIZE, +} from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; + +vi.mock("./backfill-source", () => ({ + findMismatchedParentEdges: vi.fn(), + findMissingSourceRefs: vi.fn(), + organizationExists: vi.fn(), + readOrganizationIdPage: vi.fn(), + readOrganizationSource: vi.fn(), + readWorkspaceSource: vi.fn(), +})); + +const apply = { + deleteFeedbackDirectoryAssignmentResources: vi.fn(), + reconcileApiKeys: vi.fn(), + reconcileFeedbackDirectories: vi.fn(), + reconcileMemberships: vi.fn(), + reconcileTeamWorkspace: vi.fn(), +}; +const readRelationships = vi.fn(); +const dependencies = { apply, client: { readRelationships } }; + +const PROJECTED = { passes: 1, status: "projected" } as const; + +// Annotated, so adding a field to the source type is a compile error here rather than a pile of +// `undefined.length` failures at runtime. +const emptySource: TAuthzedOrganizationSource = { + apiKeyIds: [], + apiKeyWorkspaceGrants: [], + expectedRelationships: [], + feedbackDirectoryAssignments: [], + feedbackDirectoryIds: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + memberships: [], + teamIds: [], + teamMemberships: [], + workspaceIds: [], + workspaceTeamGrants: [], +}; + +const request = (overrides: Partial = {}): TAuthzedBackfillRequest => ({ + maxPrune: 500, + mode: "apply", + prune: false, + scope: { kind: "organization", organizationId: "org-1" }, + ...overrides, +}); + +const emptyPage = { cursor: null, relationships: [], snapshot: null }; + +beforeEach(() => { + vi.clearAllMocks(); + apply.reconcileApiKeys.mockResolvedValue(PROJECTED); + apply.deleteFeedbackDirectoryAssignmentResources.mockResolvedValue(PROJECTED); + apply.reconcileFeedbackDirectories.mockResolvedValue(PROJECTED); + apply.reconcileMemberships.mockResolvedValue(PROJECTED); + apply.reconcileTeamWorkspace.mockResolvedValue(PROJECTED); + readRelationships.mockResolvedValue(emptyPage); + vi.mocked(source.organizationExists).mockResolvedValue(true); + vi.mocked(source.readOrganizationSource).mockResolvedValue(emptySource); + vi.mocked(source.findMissingSourceRefs).mockResolvedValue([]); + vi.mocked(source.findMismatchedParentEdges).mockResolvedValue([]); + vi.mocked(source.readWorkspaceSource).mockResolvedValue({ + apiKeyWorkspaceGrants: [], + expectedRelationships: [], + feedbackDirectoryAssignments: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + organizationId: "org-1", + workspaceExists: true, + workspaceTeamGrants: [], + }); + vi.mocked(source.readOrganizationIdPage).mockResolvedValue([]); +}); + +describe("dry-run inertness", () => { + test("performs no reconciliation in dry-run mode", async () => { + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + memberships: [{ organizationId: "org-1", userId: "user-1" }], + teamIds: ["team-1"], + }); + + const result = await runAuthzedBackfill(request({ mode: "dry_run" }), dependencies); + + expect(apply.reconcileMemberships).not.toHaveBeenCalled(); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalled(); + expect(apply.reconcileApiKeys).not.toHaveBeenCalled(); + expect(result.mode).toBe("dry_run"); + expect(result.counters.reconciled).toBe(0); + expect(result.counters.scanned).toBe(1); + }); + + test("never prunes in dry-run mode even when pruning is requested", async () => { + vi.mocked(source.findMissingSourceRefs).mockResolvedValue([{ kind: "team", teamId: "ghost-team" }]); + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ + { + relation: "organization", + resource: { objectId: "ghost-team", objectType: "team" }, + subject: { objectId: "org-1", objectType: "organization" }, + }, + ], + snapshot: { token: "revision-1" }, + }); + + const result = await runAuthzedBackfill(request({ mode: "dry_run", prune: true }), dependencies); + + expect(result.counters.orphaned).toBe(1); + expect(result.counters.pruned).toBe(0); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalled(); + }); + + test("cannot reach a mutation except through the injected capability", () => { + // The structural guarantee behind dry-run inertness: the orchestrator has no import path to a + // write. If this regresses, a dry run could mutate regardless of the mode flag — so the guarantee + // is asserted against the source rather than inferred from a mock never being called. + const moduleSource = readFileSync(new URL("./backfill.ts", import.meta.url), "utf8"); + + // Type-only imports are erased at compile time and carry no capability, so only value imports of + // the client facade and the reconcilers matter. An import clause contains no semicolon, so + // `[^;]` safely spans a multi-line clause. + const importsSomethingAtRuntime = (clause: string): boolean => { + const trimmed = clause.trim(); + if (trimmed.startsWith("type ")) { + return false; + } + const specifiers = /^\{([\s\S]*)\}$/.exec(trimmed); + if (!specifiers) { + return true; // default or namespace import + } + return specifiers[1] + .split(",") + .map((specifier) => specifier.trim()) + .filter(Boolean) + .some((specifier) => !specifier.startsWith("type ")); + }; + + const valueImports = [...moduleSource.matchAll(/^import\b([^;]*?)from "([^"]+)";/gm)] + .filter(([, clause]) => importsSomethingAtRuntime(clause)) + .map(([, , path]) => path); + + // `getAuthzedClient` and every reconciler are reachable only through these modules, so verifying + // none is imported at runtime is the whole guarantee. + for (const mutationModule of ["./client", "./organization-membership", "./team-workspace", "./api-key"]) { + expect(valueImports).not.toContain(mutationModule); + } + }); +}); + +describe("per-unit failure isolation", () => { + test("continues the sweep when one organization fails and reports it", async () => { + vi.mocked(source.readOrganizationIdPage) + .mockResolvedValueOnce(["org-1", "org-2", "org-3"]) + .mockResolvedValueOnce([]); + vi.mocked(source.readOrganizationSource) + .mockResolvedValueOnce({ ...emptySource, teamIds: ["team-1"] }) + .mockRejectedValueOnce(new Error("statement timeout")) + .mockResolvedValueOnce({ ...emptySource, teamIds: ["team-3"] }); + + const result = await runAuthzedBackfill(request({ scope: { kind: "all" } }), dependencies); + + expect(result.counters.scanned).toBe(3); + expect(result.counters.reconciled).toBe(2); + expect(result.counters.failed).toBe(1); + expect(result.failures).toEqual([ + { attempts: 1, code: "authzed_internal", organizationId: "org-2", retryable: false }, + ]); + expect(result.status).toBe("failed"); + }); + + test("reports a reconciler failure against its organization without aborting", async () => { + vi.mocked(source.readOrganizationIdPage) + .mockResolvedValueOnce(["org-1", "org-2"]) + .mockResolvedValueOnce([]); + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + teamIds: ["team-1"], + }); + apply.reconcileTeamWorkspace + .mockResolvedValueOnce({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }) + .mockResolvedValue(PROJECTED); + + const result = await runAuthzedBackfill(request({ scope: { kind: "all" } }), dependencies); + + expect(result.counters.failed).toBe(1); + expect(result.counters.reconciled).toBe(1); + expect(result.failures[0]).toEqual({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + organizationId: "org-1", + retryable: true, + }); + }); + + test("counts a disabled projection as a failure rather than a success", async () => { + // Otherwise a run against an instance with AuthZed switched off would report every organization as + // reconciled and exit clean. + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + teamIds: ["team-1"], + }); + apply.reconcileTeamWorkspace.mockResolvedValue({ status: "disabled" }); + + const result = await runAuthzedBackfill(request(), dependencies); + + expect(result.counters.failed).toBe(1); + expect(result.counters.reconciled).toBe(0); + expect(result.failures[0]).toMatchObject({ code: AUTHZED_ERROR_CODES.DISABLED }); + expect(result.status).toBe("failed"); + }); + + test("resumes from the last organization it reached", async () => { + vi.mocked(source.readOrganizationIdPage) + .mockResolvedValueOnce(["org-1", "org-2"]) + .mockResolvedValueOnce([]); + + const result = await runAuthzedBackfill(request({ scope: { kind: "all" } }), dependencies); + + expect(result.lastOrganizationId).toBe("org-2"); + expect(source.readOrganizationIdPage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ afterOrganizationId: "org-2" }) + ); + }); + + test("rejects a scope naming an organization that does not exist", async () => { + vi.mocked(source.organizationExists).mockResolvedValue(false); + + await expect(runAuthzedBackfill(request(), dependencies)).rejects.toThrow(AUTHZED_ERROR_CODES.NOT_FOUND); + expect(apply.reconcileMemberships).not.toHaveBeenCalled(); + }); +}); + +describe("idempotency", () => { + test("a second run over unchanged state reports no drift and identical counters", async () => { + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + memberships: [{ organizationId: "org-1", userId: "user-1" }], + }); + // Converged state: SpiceDB holds the relationship the membership implies. Without this the run + // rightly reports the record as unprojected — see the dry-run detection tests. + const convergedPage = { + cursor: null, + relationships: [ + { + relation: "owner", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, + }, + ], + snapshot: { token: "revision-1" }, + }; + readRelationships.mockResolvedValue(convergedPage); + + const first = await runAuthzedBackfill(request(), dependencies); + const second = await runAuthzedBackfill(request(), dependencies); + + expect(second).toEqual(first); + expect(second.counters.orphaned).toBe(0); + expect(second.status).toBe("reconciled"); + }); +}); + +describe("detecting records SpiceDB is missing", () => { + test("a dry run over an empty SpiceDB reports drift rather than a clean bill of health", async () => { + // The whole reason this tool exists. Reporting "reconciled" here would let an operator satisfy the + // documented pre-enforcement gate with a run that proved nothing. + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + apiKeyIds: ["key-1"], + memberships: [{ organizationId: "org-1", userId: "user-1" }], + teamIds: ["team-1"], + }); + + const result = await runAuthzedBackfill(request({ mode: "dry_run" }), dependencies); + + expect(result.counters.missing).toBe(3); + expect(result.status).toBe("drifted"); + }); + + test("reports nothing missing once every record is projected", async () => { + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + teamIds: ["team-1"], + }); + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ + { + relation: "organization", + resource: { objectId: "team-1", objectType: "team" }, + subject: { objectId: "org-1", objectType: "organization" }, + }, + ], + snapshot: { token: "revision-1" }, + }); + + const result = await runAuthzedBackfill(request({ mode: "dry_run" }), dependencies); + + expect(result.counters.missing).toBe(0); + expect(result.status).toBe("reconciled"); + }); + + test("checks the missing direction on a full sweep only when nothing will be written", async () => { + vi.mocked(source.readOrganizationIdPage).mockResolvedValueOnce(["org-1"]).mockResolvedValue([]); + vi.mocked(source.readOrganizationSource).mockResolvedValue({ ...emptySource, teamIds: ["team-1"] }); + + const dryRun = await runAuthzedBackfill( + request({ mode: "dry_run", scope: { kind: "all" } }), + dependencies + ); + // An applying sweep converges this direction by writing, so it skips the per-organization read. + const applied = await runAuthzedBackfill(request({ scope: { kind: "all" } }), dependencies); + + expect(dryRun.counters.missing).toBe(1); + expect(applied.counters.missing).toBe(0); + }); +}); + +describe("counting each finding once", () => { + const ghostTeam = { + relation: "organization", + resource: { objectId: "ghost-team", objectType: "team" }, + subject: { objectId: "org-1", objectType: "organization" }, + }; + + beforeEach(() => { + vi.mocked(source.readOrganizationIdPage).mockResolvedValueOnce(["org-1"]).mockResolvedValue([]); + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + teamIds: ["ghost-team"], + }); + // The orphan is reachable two ways: from org-1's own team list, and from the `team` type sweep. + readRelationships.mockImplementation(({ filter }) => + Promise.resolve( + filter.resourceType === "team" + ? { cursor: null, relationships: [ghostTeam], snapshot: { token: "revision-1" } } + : emptyPage + ) + ); + vi.mocked(source.findMissingSourceRefs).mockImplementation((refs) => + Promise.resolve(refs.filter((ref) => ref.kind === "team" && ref.teamId === "ghost-team")) + ); + }); + + test.each([ + ["the default run, which is a dry run over every organization", { kind: "all" } as const], + ["a single organization", { kind: "organization", organizationId: "org-1" } as const], + ])("counts one orphaned relationship once in %s", async (_label, scope) => { + // A full scope observes per organization *and* sweeps globally. Only one of them may own the orphan + // accounting, or every stale relationship is reported twice — and inflated counts both mislead an + // operator and eat the prune budget twice over. + const result = await runAuthzedBackfill(request({ mode: "dry_run", scope }), dependencies); + + expect(result.counters.orphaned).toBe(1); + }); + + test("still reports the missing direction on a full-scope dry run", async () => { + // The whole reason the per-organization observation runs at all under a full scope. + const result = await runAuthzedBackfill( + request({ mode: "dry_run", scope: { kind: "all" } }), + dependencies + ); + + expect(result.counters.missing).toBe(0); + expect(result.status).toBe("drifted"); + }); +}); + +describe("drift status covers unrepaired state", () => { + test.each([ + [ + "a cross-organization source grant", + () => + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + invalidWorkspaceTeamGrants: [{ teamId: "foreign-team", workspaceId: "ws-1" }], + }), + ], + [ + "an unrecognized relationship", + () => + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ + { + relation: "not_a_formbricks_relation", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "someone", objectType: "user" }, + }, + ], + snapshot: { token: "revision-1" }, + }), + ], + ])("does not report reconciled while %s remains", async (_label, arrange) => { + // Both are deliberately left unrepaired — which is exactly why a clean exit must not be reachable + // while they exist. This result is the gate for shadow evaluation and enforcement. + arrange(); + + const result = await runAuthzedBackfill(request(), dependencies); + + expect(result.status).toBe("drifted"); + }); +}); + +describe("detecting a cross-tenant parent edge", () => { + const foreignParent = { + relation: "organization", + resource: { objectId: "ws-1", objectType: "workspace" }, + subject: { objectId: "other-org", objectType: "organization" }, + }; + + test("reports a parent edge PostgreSQL contradicts and never prunes it", async () => { + // `organization` is a relation, so an extra parent edge is additive: every owner and manager of the + // named organization gains access through `organization->manage`. Nothing in PostgreSQL shows it, and + // an existence check cannot see it, because the workspace really does exist. + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [foreignParent], + snapshot: { token: "revision-1" }, + }); + vi.mocked(source.findMismatchedParentEdges).mockResolvedValue([ + { childId: "ws-1", childType: "workspace", organizationId: "other-org", relation: "organization" }, + ]); + + const result = await runAuthzedBackfill(request({ prune: true }), dependencies); + + expect(result.counters.mismatchedParents).toBe(1); + expect(result.mismatchedParents).toEqual([ + { childId: "ws-1", childType: "workspace", organizationId: "other-org", relation: "organization" }, + ]); + // Removing it would mean deleting a relation the workspace legitimately needs one of, so it is left + // for a human — but it must force a non-clean status. + expect(result.status).toBe("drifted"); + expect(result.counters.pruned).toBe(0); + }); +}); + +describe("detecting mismatched permission relations", () => { + test("reports a stale higher workspace grant even though the source pair is present", async () => { + const expectedRelationship = { + relation: "reader_team", + resource: { objectId: "ws-1", objectType: "workspace" }, + subject: { objectId: "team-1", objectType: "team", relation: "member" }, + }; + const observedRelationship = { ...expectedRelationship, relation: "manager_team" }; + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + expectedRelationships: [expectedRelationship], + workspaceIds: ["ws-1"], + workspaceTeamGrants: [{ teamId: "team-1", workspaceId: "ws-1" }], + }); + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [observedRelationship], + snapshot: { token: "revision-1" }, + }); + + const result = await runAuthzedBackfill(request({ mode: "dry_run" }), dependencies); + + expect(result.counters.mismatchedPermissions).toBe(1); + expect(result.mismatchedPermissions).toEqual([ + { + expectedRelations: ["reader_team"], + observedRelations: ["manager_team"], + source: { kind: "workspaceTeamGrant", teamId: "team-1", workspaceId: "ws-1" }, + }, + ]); + expect(result.status).toBe("drifted"); + }); +}); + +describe("pruning", () => { + const ghostTeamRelationship = { + relation: "organization", + resource: { objectId: "ghost-team", objectType: "team" }, + subject: { objectId: "org-1", objectType: "organization" }, + }; + + beforeEach(() => { + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ghostTeamRelationship], + snapshot: { token: "revision-1" }, + }); + vi.mocked(source.findMissingSourceRefs).mockResolvedValue([{ kind: "team", teamId: "ghost-team" }]); + }); + + test("reports drift without pruning by default", async () => { + const result = await runAuthzedBackfill(request(), dependencies); + + expect(result.counters.orphaned).toBe(1); + expect(result.counters.pruned).toBe(0); + expect(result.status).toBe("drifted"); + expect(result.orphans).toEqual([{ kind: "team", teamId: "ghost-team" }]); + // The write half still runs, but the orphan is not handed to it. + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ teamIds: expect.arrayContaining(["ghost-team"]) }) + ); + }); + + test("feeds the orphan to a reconciler as a target when pruning", async () => { + const result = await runAuthzedBackfill(request({ prune: true }), dependencies); + + expect(result.counters.pruned).toBe(1); + expect(result.status).toBe("reconciled"); + // Handed over as a target, never as a delete instruction: the reconciler re-reads PostgreSQL and + // decides, so a team recreated in the meantime is written rather than deleted. + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ teamIds: ["ghost-team"] }) + ); + }); + + test("prunes nothing for a unit whose orphan count exceeds the cap", async () => { + vi.mocked(source.findMissingSourceRefs).mockResolvedValue([ + { kind: "team", teamId: "ghost-1" }, + { kind: "team", teamId: "ghost-2" }, + ]); + + const result = await runAuthzedBackfill(request({ maxPrune: 1, prune: true }), dependencies); + + // A large orphan count is a symptom, not a big cleanup job. Aborting before the first delete keeps + // a misdirected run a loud report instead of a partly-destroyed graph. + expect(result.counters.pruned).toBe(0); + expect(result.counters.skipped).toBe(1); + expect(result.status).toBe("drifted"); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ teamIds: expect.arrayContaining(["ghost-1"]) }) + ); + }); + + test("marks the report incomplete when an observation is abandoned", async () => { + readRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, + operation: "read_relationships", + retryable: false, + }) + ); + + const result = await runAuthzedBackfill(request({ prune: true }), dependencies); + + // Fewer relationships observed means fewer orphans found, so the result must not read as complete. + expect(result.truncated).toBe(true); + expect(result.counters.pruned).toBe(0); + expect(result.counters.failed).toBe(1); + expect(result.failures[0]).toMatchObject({ code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION }); + }); + + test("propagates a source-read failure instead of treating records as absent", async () => { + vi.mocked(source.findMissingSourceRefs).mockRejectedValue(new Error("connection pool exhausted")); + + const result = await runAuthzedBackfill(request({ prune: true }), dependencies); + + expect(result.counters.pruned).toBe(0); + expect(result.counters.orphaned).toBe(0); + expect(result.counters.failed).toBe(1); + expect(result.truncated).toBe(true); + }); +}); + +describe("scope and observation completeness", () => { + test("single-organization scope observes only resources PostgreSQL still knows about", async () => { + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + apiKeyIds: ["key-1"], + teamIds: ["team-1"], + workspaceIds: ["ws-1"], + }); + + const result = await runAuthzedBackfill(request(), dependencies); + + // A resource whose row is already gone is unreachable from its organization, so this scope must not + // claim completeness. + expect(result.orphanScope).toBe("known_resources"); + // The trailing organization read is the closing freshness capture, not an observation. + expect(readRelationships.mock.calls.map(([query]) => query.filter)).toEqual([ + { resourceId: "org-1", resourceType: "organization" }, + { resourceId: "team-1", resourceType: "team" }, + { resourceId: "ws-1", resourceType: "workspace" }, + { resourceId: "key-1", resourceType: "api_key" }, + { resourceType: "organization" }, + ]); + }); + + test("full scope sweeps every managed resource type and claims completeness", async () => { + vi.mocked(source.readOrganizationIdPage).mockResolvedValueOnce(["org-1"]).mockResolvedValueOnce([]); + + const result = await runAuthzedBackfill(request({ scope: { kind: "all" } }), dependencies); + + expect(result.orphanScope).toBe("all"); + expect(readRelationships.mock.calls.map(([query]) => query.filter)).toEqual([ + { resourceType: "api_key" }, + { resourceType: "feedback_directory" }, + { resourceType: "feedback_directory_assignment" }, + { resourceType: "organization" }, + { resourceType: "team" }, + { resourceType: "workspace" }, + // The closing freshness capture, which wants one relationship rather than a page. + { resourceType: "organization" }, + ]); + expect( + readRelationships.mock.calls + .slice(0, -1) + .every(([query]) => query.limit === AUTHZED_MAX_RELATIONSHIP_READS) + ).toBe(true); + }); + + test("reports a revision captured after the run's own writes", async () => { + // The point of the field: shadow evaluation uses it as an `at_least_as_fresh` floor, so a revision + // read *before* the writes would be the exact opposite of a floor. Ordering is what is asserted here. + vi.mocked(source.readOrganizationSource).mockResolvedValue({ ...emptySource, teamIds: ["team-1"] }); + let written = false; + apply.reconcileTeamWorkspace.mockImplementation(async () => { + written = true; + return PROJECTED; + }); + readRelationships.mockImplementation(() => + Promise.resolve({ + cursor: null, + relationships: [], + snapshot: { token: written ? "after-writes" : "before-writes" }, + }) + ); + + const result = await runAuthzedBackfill(request(), dependencies); + + expect(result.completedAtSnapshot).toBe("after-writes"); + }); + + test("reports no revision for a dry run, which wrote nothing to be fresh relative to", async () => { + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [], + snapshot: { token: "revision-42" }, + }); + + const result = await runAuthzedBackfill(request({ mode: "dry_run" }), dependencies); + + expect(result.completedAtSnapshot).toBeNull(); + }); + + test("reports no revision rather than a stale one when the closing read fails", async () => { + // No teams or workspaces, so the observation is a single organization read and the second call is the + // closing capture — which is the one that has to fail for this to test what it claims. + readRelationships + .mockResolvedValueOnce({ cursor: null, relationships: [], snapshot: { token: "observed" } }) + .mockRejectedValue(new Error("connection reset")); + + const result = await runAuthzedBackfill(request(), dependencies); + + // A floor that might pre-date the writes is worse than no floor at all. + expect(result.completedAtSnapshot).toBeNull(); + expect(result.counters.reconciled).toBe(1); + }); + + test("counts deliberately unprojected relationships as ignored, never orphaned", async () => { + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ + { + relation: "workspace", + resource: { objectId: "survey-1", objectType: "survey" }, + subject: { objectId: "ws-1", objectType: "workspace" }, + }, + ], + snapshot: { token: "revision-1" }, + }); + + const result = await runAuthzedBackfill(request({ prune: true }), dependencies); + + expect(result.counters.ignored).toBe(1); + expect(result.counters.orphaned).toBe(0); + expect(result.counters.pruned).toBe(0); + }); + + test("reports unrecognized relationships without reconciling them", async () => { + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ + { + relation: "superuser", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, + }, + ], + snapshot: { token: "revision-1" }, + }); + + const result = await runAuthzedBackfill(request({ prune: true }), dependencies); + + expect(result.unmanaged).toEqual([ + { objectId: "org-1", objectType: "organization", relation: "superuser" }, + ]); + expect(result.counters.pruned).toBe(0); + }); + + test("reports a cross-organization workspace-team grant without acting on it", async () => { + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + invalidWorkspaceTeamGrants: [{ teamId: "foreign-team", workspaceId: "ws-1" }], + }); + + const result = await runAuthzedBackfill(request(), dependencies); + + expect(result.counters.invalid).toBe(1); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ workspaceTeamGrants: expect.arrayContaining([expect.anything()]) }) + ); + }); +}); + +describe("workspace scope", () => { + const missingWorkspace = (): void => { + vi.mocked(source.readWorkspaceSource).mockResolvedValue({ + apiKeyWorkspaceGrants: [], + expectedRelationships: [], + feedbackDirectoryAssignments: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + organizationId: null, + workspaceExists: false, + workspaceTeamGrants: [], + }); + }; + + test("does not remove a missing workspace's relationships without permission to prune", async () => { + // Naming a workspace with no row is what removes *every* relationship on it — team grants and + // API-key grants included. That is a prune, so it needs the prune flags rather than happening as a + // side effect of `--apply` on a stale ID. + missingWorkspace(); + + const result = await runAuthzedBackfill( + request({ scope: { kind: "workspace", workspaceId: "ghost-ws" } }), + dependencies + ); + + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ workspaceIds: ["ghost-ws"] }) + ); + expect(result.counters.pruned).toBe(0); + }); + + test("withholds the whole-workspace deletion when the orphan count exceeds the budget", async () => { + // Naming a workspace with no row removes every relationship on it. An over-cap unit reported + // `skipped: 1, pruned: 0` and still performed exactly that deletion, which inverts the cap. + missingWorkspace(); + vi.mocked(source.findMissingSourceRefs).mockResolvedValue([ + { kind: "workspaceTeamGrant", teamId: "team-1", workspaceId: "ghost-ws" }, + { kind: "workspaceTeamGrant", teamId: "team-2", workspaceId: "ghost-ws" }, + ]); + + const result = await runAuthzedBackfill( + request({ maxPrune: 1, prune: true, scope: { kind: "workspace", workspaceId: "ghost-ws" } }), + dependencies + ); + + expect(result.counters.skipped).toBe(1); + expect(result.counters.pruned).toBe(0); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ workspaceIds: ["ghost-ws"] }) + ); + }); + + test("removes a missing workspace's relationships when pruning is permitted", async () => { + missingWorkspace(); + + await runAuthzedBackfill( + request({ prune: true, scope: { kind: "workspace", workspaceId: "ghost-ws" } }), + dependencies + ); + + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceIds: ["ghost-ws"] }) + ); + }); + + test("withholds a grant whose principal is gone, since deleting it would reach other tenants", async () => { + // A grant ref implies its team, and a team with no PostgreSQL row makes the reconciler delete that + // team's grants on *every* workspace — outside this scope and outside its budget. + vi.mocked(source.findMissingSourceRefs).mockImplementation((refs) => + Promise.resolve(refs.filter((ref) => ref.kind === "workspaceTeamGrant" || ref.kind === "team")) + ); + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [ + { + relation: "reader_team", + resource: { objectId: "ws-1", objectType: "workspace" }, + subject: { objectId: "ghost-team", objectType: "team", relation: "member" }, + }, + ], + snapshot: { token: "revision-1" }, + }); + + const result = await runAuthzedBackfill( + request({ prune: true, scope: { kind: "workspace", workspaceId: "ws-1" } }), + dependencies + ); + + // Counted, so the run stays drifted and tells the operator a wider scope is needed… + expect(result.counters.orphaned).toBe(1); + // …but never handed over, because the delete would not stay inside this workspace. + expect(result.counters.pruned).toBe(0); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ + workspaceTeamGrants: [{ teamId: "ghost-team", workspaceId: "ws-1" }], + }) + ); + expect(result.status).toBe("drifted"); + }); + + test("still projects the parent edge of a workspace that exists", async () => { + // The same target list drives the write when the row is present, so gating it on pruning must not + // stop an existing workspace's own parent edge from being projected. + await runAuthzedBackfill(request({ scope: { kind: "workspace", workspaceId: "ws-1" } }), dependencies); + + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceIds: ["ws-1"] }) + ); + }); + + test("attributes a failure to the owning organization rather than to no tenant", async () => { + apply.reconcileTeamWorkspace.mockResolvedValue({ + attempts: 1, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }); + + const result = await runAuthzedBackfill( + request({ scope: { kind: "workspace", workspaceId: "ws-1" } }), + dependencies + ); + + // The empty string is the sweep's marker for an orphan with no organization left, so a workspace that + // still has a row must not report it. + expect(result.failures).toEqual([ + { attempts: 1, code: AUTHZED_ERROR_CODES.UNAVAILABLE, organizationId: "org-1", retryable: true }, + ]); + }); +}); + +describe("full-scope orphan sweep", () => { + const ghostWorkspace = { + relation: "organization", + resource: { objectId: "ghost-ws", objectType: "workspace" }, + subject: { objectId: "gone-org", objectType: "organization" }, + }; + + beforeEach(() => { + vi.mocked(source.readOrganizationIdPage).mockResolvedValueOnce([]).mockResolvedValue([]); + // The sweep reads one resource type at a time, so the fixture has to answer per type — the ghost + // workspace exists on `workspace` and nowhere else. + readRelationships.mockImplementation(({ filter }) => + Promise.resolve( + filter.resourceType === "workspace" + ? { cursor: null, relationships: [ghostWorkspace], snapshot: { token: "revision-1" } } + : emptyPage + ) + ); + vi.mocked(source.findMissingSourceRefs).mockImplementation((refs) => + Promise.resolve(refs.length > 0 ? [{ kind: "workspace", workspaceId: "ghost-ws" }] : []) + ); + }); + + test("finds a resource unreachable from any organization and reports it", async () => { + // A workspace whose organization is also gone cannot be reached by enumerating organizations, which + // is the whole reason the full sweep filters by resource type instead. + const result = await runAuthzedBackfill(request({ scope: { kind: "all" } }), dependencies); + + expect(result.counters.orphaned).toBe(1); + expect(result.counters.pruned).toBe(0); + expect(result.orphanScope).toBe("all"); + expect(result.status).toBe("drifted"); + }); + + test("hands the orphan to a reconciler as a target when pruning", async () => { + const result = await runAuthzedBackfill(request({ prune: true, scope: { kind: "all" } }), dependencies); + + expect(result.counters.pruned).toBe(1); + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceIds: ["ghost-ws"] }) + ); + expect(result.status).toBe("reconciled"); + }); + + test("prunes every orphan inside the budget, even past the 100-entry reporting cap", async () => { + // The reported lists cap at 100 so a broken instance cannot emit a huge line; the prune budget is + // 500. Conflating the two would silently prune only the first 100 of a run well inside its budget + // and report that as a success. + const ghosts = Array.from({ length: 150 }, (_unused, index) => ({ + kind: "workspace" as const, + workspaceId: `ghost-${index}`, + })); + vi.mocked(source.findMissingSourceRefs).mockImplementation((refs) => + Promise.resolve(refs.length > 0 ? ghosts : []) + ); + + const result = await runAuthzedBackfill( + request({ maxPrune: 500, prune: true, scope: { kind: "all" } }), + dependencies + ); + + expect(result.counters.orphaned).toBe(150); + expect(result.counters.pruned).toBe(150); + expect(result.counters.skipped).toBe(0); + // Reported lists stay capped while the counters carry the true totals. + expect(result.orphans).toHaveLength(100); + expect(result.status).toBe("reconciled"); + }); + + test("prunes nothing at all when a later page pushes the total past the cap", async () => { + // The cap is an abort-before-delete guard, so it has to be decided against the *whole* sweep. Enforced + // per page it would delete every page that fit and stop at the one that did not — so a run aimed at + // the wrong database would revoke a cap's worth of live access instead of revoking none. + const secondGhost = { + relation: "organization", + resource: { objectId: "ghost-ws-2", objectType: "workspace" }, + subject: { objectId: "gone-org", objectType: "organization" }, + }; + readRelationships.mockImplementation(({ cursor, filter }) => + Promise.resolve( + filter.resourceType !== "workspace" + ? emptyPage + : cursor === undefined + ? { + cursor: { token: "page-2" }, + relationships: [ghostWorkspace], + snapshot: { token: "revision-1" }, + } + : { cursor: null, relationships: [secondGhost], snapshot: { token: "revision-1" } } + ) + ); + vi.mocked(source.findMissingSourceRefs).mockImplementation((refs) => Promise.resolve(refs)); + + const result = await runAuthzedBackfill( + request({ maxPrune: 1, prune: true, scope: { kind: "all" } }), + dependencies + ); + + // Both counted — the total is the diagnostic, and it is what says how far off the run is. + expect(result.counters.orphaned).toBe(2); + // The first page fit the budget of one. It is still not deleted. + expect(result.counters.pruned).toBe(0); + expect(result.counters.skipped).toBe(1); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalledWith( + expect.objectContaining({ workspaceIds: expect.arrayContaining(["ghost-ws"]) }) + ); + expect(result.status).toBe("drifted"); + }); + + test("reports truncated rather than growing the dedupe set without bound", async () => { + // The dedupe set is the one structure in a streaming sweep that grows with the store. Past its + // bound the sweep keeps counting and says so, instead of holding a key for every record on the + // heap — and a run this far past the prune cap deletes nothing on the strength of it anyway. + const ghosts = Array.from({ length: AUTHZED_MAX_TRACKED_ORPHAN_REFS + 1 }, (_unused, index) => ({ + kind: "workspace" as const, + workspaceId: `ghost-${index}`, + })); + vi.mocked(source.findMissingSourceRefs).mockImplementation((refs) => + Promise.resolve(refs.length > 0 ? ghosts : []) + ); + + const result = await runAuthzedBackfill(request({ prune: true, scope: { kind: "all" } }), dependencies); + + expect(result.counters.orphaned).toBe(AUTHZED_MAX_TRACKED_ORPHAN_REFS + 1); + expect(result.truncated).toBe(true); + // Far past the cap, so nothing is deleted and the run degrades into a report. + expect(result.counters.pruned).toBe(0); + expect(result.counters.skipped).toBe(1); + expect(result.status).toBe("drifted"); + }); + + test("counts a record implied by two pages once", async () => { + // SpiceDB returns alternate relations on one resource grouped by relation rather than adjacently, so + // the tuples implying a single record straddle a page boundary in any tenant larger than a page. + // Counting it twice would inflate the total that the cap is compared against. + readRelationships.mockImplementation(({ cursor, filter }) => + Promise.resolve( + filter.resourceType !== "workspace" + ? emptyPage + : cursor === undefined + ? { + cursor: { token: "page-2" }, + relationships: [ghostWorkspace], + snapshot: { token: "revision-1" }, + } + : { cursor: null, relationships: [ghostWorkspace], snapshot: { token: "revision-1" } } + ) + ); + + const result = await runAuthzedBackfill( + request({ maxPrune: 1, prune: true, scope: { kind: "all" } }), + dependencies + ); + + expect(result.counters.orphaned).toBe(1); + expect(result.orphans).toEqual([{ kind: "workspace", workspaceId: "ghost-ws" }]); + // Deduplicated to one, so it fits the budget of one and is pruned rather than skipped. + expect(result.counters.pruned).toBe(1); + expect(result.counters.skipped).toBe(0); + }); + + test("prunes nothing when the sweep's orphan count exceeds the cap", async () => { + vi.mocked(source.findMissingSourceRefs).mockResolvedValue([ + { kind: "workspace", workspaceId: "ghost-1" }, + { kind: "workspace", workspaceId: "ghost-2" }, + ]); + + const result = await runAuthzedBackfill( + request({ maxPrune: 1, prune: true, scope: { kind: "all" } }), + dependencies + ); + + expect(result.counters.pruned).toBe(0); + expect(result.counters.skipped).toBe(1); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalled(); + }); + + test("records a prune failure against no organization, since the resource has none", async () => { + apply.reconcileTeamWorkspace.mockResolvedValue({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }); + + const result = await runAuthzedBackfill(request({ prune: true, scope: { kind: "all" } }), dependencies); + + expect(result.counters.pruned).toBe(0); + expect(result.failures[0]).toEqual({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + organizationId: "", + retryable: true, + }); + expect(result.status).toBe("failed"); + }); + + test("marks the report truncated when the sweep itself fails", async () => { + readRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.LIMIT_EXCEEDED, + operation: "read_all_relationships", + retryable: false, + }) + ); + + const result = await runAuthzedBackfill(request({ prune: true, scope: { kind: "all" } }), dependencies); + + expect(result.truncated).toBe(true); + expect(result.counters.pruned).toBe(0); + expect(result.failures[0]).toMatchObject({ code: AUTHZED_ERROR_CODES.LIMIT_EXCEEDED }); + expect(result.status).toBe("failed"); + }); +}); + +describe("chunking", () => { + test("splits a large target list so no reconciler receives an unbounded query", async () => { + const memberships = Array.from({ length: AUTHZED_TARGET_CHUNK_SIZE + 1 }, (_unused, index) => ({ + organizationId: "org-1", + userId: `user-${index}`, + })); + vi.mocked(source.readOrganizationSource).mockResolvedValue({ ...emptySource, memberships }); + + await runAuthzedBackfill(request(), dependencies); + + expect(apply.reconcileMemberships).toHaveBeenCalledTimes(2); + expect(apply.reconcileMemberships.mock.calls[0][0].memberships).toHaveLength(AUTHZED_TARGET_CHUNK_SIZE); + expect(apply.reconcileMemberships.mock.calls[1][0].memberships).toHaveLength(1); + }); + + test("never hands an empty target list to a reconciler", async () => { + // The write facade rejects an empty batch as an invalid request. + await runAuthzedBackfill(request(), dependencies); + + expect(apply.reconcileMemberships).not.toHaveBeenCalled(); + expect(apply.reconcileTeamWorkspace).not.toHaveBeenCalled(); + expect(apply.reconcileApiKeys).not.toHaveBeenCalled(); + }); + + test("passes every list a reconciler understands in one call", async () => { + // A reconciler reads one PostgreSQL snapshot covering all the lists it was given, so splitting them + // would multiply the snapshot reads and verification passes for no benefit. + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + apiKeyIds: ["key-1"], + apiKeyWorkspaceGrants: [{ apiKeyId: "key-1", workspaceId: "ws-1" }], + teamIds: ["team-1"], + teamMemberships: [{ teamId: "team-1", userId: "user-1" }], + workspaceIds: ["ws-1"], + workspaceTeamGrants: [{ teamId: "team-1", workspaceId: "ws-1" }], + }); + + await runAuthzedBackfill(request(), dependencies); + + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledTimes(1); + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledWith({ + teamIds: ["team-1"], + teamMemberships: [{ teamId: "team-1", userId: "user-1" }], + workspaceIds: ["ws-1"], + workspaceTeamGrants: [{ teamId: "team-1", workspaceId: "ws-1" }], + }); + expect(apply.reconcileApiKeys).toHaveBeenCalledTimes(1); + expect(apply.reconcileApiKeys).toHaveBeenCalledWith({ + apiKeyIds: ["key-1"], + apiKeyWorkspaceGrants: [{ apiKeyId: "key-1", workspaceId: "ws-1" }], + }); + }); + + test("passes an empty list through untouched rather than asserting a narrowed object type", async () => { + // Chunking builds each call by narrowing a full target object, so lists with nothing in them arrive + // as `[]`. Every reconciler treats that as a no-op, and it is what lets the chunker avoid a type + // assertion that would silently keep compiling if a target field ever became required. + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + teamIds: ["team-1"], + }); + + await runAuthzedBackfill(request(), dependencies); + + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledWith({ + teamIds: ["team-1"], + teamMemberships: [], + workspaceIds: [], + workspaceTeamGrants: [], + }); + }); + + test("bounds each list independently, so call count follows the longest list", async () => { + const teamMemberships = Array.from({ length: AUTHZED_TARGET_CHUNK_SIZE + 1 }, (_unused, index) => ({ + teamId: "team-1", + userId: `user-${index}`, + })); + vi.mocked(source.readOrganizationSource).mockResolvedValue({ + ...emptySource, + teamIds: ["team-1"], + teamMemberships, + }); + + await runAuthzedBackfill(request(), dependencies); + + expect(apply.reconcileTeamWorkspace).toHaveBeenCalledTimes(2); + // The short list is exhausted by the first call and must not be resent. + expect(apply.reconcileTeamWorkspace.mock.calls[0][0]).toMatchObject({ + teamIds: ["team-1"], + teamMemberships: teamMemberships.slice(0, AUTHZED_TARGET_CHUNK_SIZE), + }); + // The short list is exhausted by the first call, so the second must not resend it. + expect(apply.reconcileTeamWorkspace.mock.calls[1][0]).toMatchObject({ + teamIds: [], + teamMemberships: teamMemberships.slice(AUTHZED_TARGET_CHUNK_SIZE), + }); + }); +}); diff --git a/apps/web/lib/authzed/backfill.ts b/apps/web/lib/authzed/backfill.ts new file mode 100644 index 000000000000..68d40b4fa97f --- /dev/null +++ b/apps/web/lib/authzed/backfill.ts @@ -0,0 +1,1276 @@ +import "server-only"; +import type { TApiKeyProjectionTargets } from "./api-key"; +import { + type TAuthzedObservationSummary, + type TAuthzedParentEdge, + type TAuthzedPermissionMismatch, + type TAuthzedSourceRef, + findMismatchedPermissionRelations, + findUnprojectedSourceRefs, + getManagedResourceTypes, + sourceRefKey, + summarizeObservation, +} from "./backfill-diff"; +import { + type TAuthzedApiKeyWorkspaceTarget, + type TAuthzedFeedbackDirectoryAssignmentTarget, + type TAuthzedMembershipTarget, + type TAuthzedOrganizationSource, + type TAuthzedTeamMembershipTarget, + type TAuthzedWorkspaceSource, + type TAuthzedWorkspaceTeamTarget, + findMismatchedParentEdges, + findMissingSourceRefs, + organizationExists, + readOrganizationIdPage, + readOrganizationSource, + readWorkspaceSource, +} from "./backfill-source"; +import type { TAuthzedClient, TAuthzedRelationship } from "./client"; +import { + AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE, + AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES, + AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN, + AUTHZED_MAX_TRACKED_ORPHAN_REFS, +} from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import type { TFeedbackDirectoryProjectionTargets } from "./feedback-directory"; +import { getFeedbackDirectoryAssignmentObjectId } from "./feedback-directory-assignment-id"; +import type { TOrganizationMembershipProjectionTargets } from "./organization-membership"; +import type { TAuthzedProjectionResult } from "./projection"; +import { runChunked } from "./projection-chunks"; +import { forEachRelationshipPage, readAllRelationships } from "./relationship-reads"; +import type { TTeamWorkspaceProjectionTargets } from "./team-workspace"; + +/** + * Relationship backfill and repair, organized around one organization at a time. + * + * **Command-line use only** — see the note on `./backfill-source`. This module performs no + * authorization check, because it runs as an operator against the whole database. + * + * The organization is the unit of work because every authorization-relevant model reaches + * `Organization` in one hop or two, so an organization's target set is closed and can be reconciled + * without consulting any other. It also means backfill and single-organization repair are the same + * code path with a different unit list, and that a partial run leaves complete graphs for the + * organizations it finished rather than a fragment of every tenant's graph. + * + * This module cannot write. It reaches mutations only through an injected capability object, and it + * imports neither `getAuthzedClient` nor any reconciler — so a dry run is inert by construction rather + * than by remembering to check a flag. + */ + +/** Mutation capability. Supplied by the CLI; a dry run supplies no-ops. */ +export type TAuthzedBackfillApply = Readonly<{ + deleteFeedbackDirectoryAssignmentResources: ( + assignmentIds: ReadonlyArray + ) => Promise; + reconcileApiKeys: (targets: TApiKeyProjectionTargets) => Promise; + reconcileMemberships: ( + targets: TOrganizationMembershipProjectionTargets + ) => Promise; + reconcileFeedbackDirectories: ( + targets: TFeedbackDirectoryProjectionTargets + ) => Promise; + reconcileTeamWorkspace: (targets: TTeamWorkspaceProjectionTargets) => Promise; +}>; + +export type TAuthzedBackfillScope = + | Readonly<{ afterOrganizationId?: string; kind: "all" }> + | Readonly<{ kind: "organization"; organizationId: string }> + | Readonly<{ kind: "workspace"; workspaceId: string }>; + +export type TAuthzedBackfillRequest = Readonly<{ + maxPrune: number; + mode: "apply" | "dry_run"; + /** + * Whether relationships with no source record may be removed. + * + * Note that "no prune" does not mean "no deletes": converging a membership inherently deletes the + * roles it does not hold. What pruning adds is permission to reconcile records observed *only* in + * SpiceDB — the ones PostgreSQL has no row for at all. + */ + prune: boolean; + scope: TAuthzedBackfillScope; +}>; + +/** + * PostgreSQL reads the orchestrator needs. + * + * Injected rather than imported so the orchestrator can be driven against a real SpiceDB without a + * database — which is how the compose smoke test exercises the observation, classification, and prune + * paths end to end. Note these are reads only; the mutation capability stays separate, so injecting + * them does not weaken the dry-run guarantee. + */ +export type TAuthzedBackfillSource = Readonly<{ + findMismatchedParentEdges: ( + edges: ReadonlyArray + ) => Promise>; + findMissingSourceRefs: ( + refs: ReadonlyArray + ) => Promise>; + organizationExists: (organizationId: string) => Promise; + readOrganizationIdPage: ( + page: Readonly<{ afterOrganizationId?: string; limit?: number }> + ) => Promise>; + readOrganizationSource: (organizationId: string) => Promise; + readWorkspaceSource: (workspaceId: string) => Promise; +}>; + +export const defaultBackfillSource: TAuthzedBackfillSource = { + findMismatchedParentEdges, + findMissingSourceRefs, + organizationExists, + readOrganizationIdPage, + readOrganizationSource, + readWorkspaceSource, +}; + +export type TAuthzedBackfillDependencies = Readonly<{ + apply: TAuthzedBackfillApply; + /** Read-only slice of the facade. Deliberately not the whole client. */ + client: Pick; + source?: TAuthzedBackfillSource; +}>; + +export type TAuthzedBackfillCounters = Readonly<{ + failed: number; + /** + * Relationships on a deliberately unprojected resource type — `survey`, `dashboard`, `response`. + * + * Expected to be 0, and structurally so: every read this tool issues filters to a managed type, so + * there is no path by which an unprojected type is observed. The classification behind it is kept + * anyway, because it is what guarantees that widening a filter later cannot make a survey relationship + * look like an orphan and prune it. A non-zero value means a filter was widened without revisiting + * that, which is worth seeing rather than silently absorbing. + */ + ignored: number; + invalid: number; + /** Resources attached to an organization PostgreSQL says does not own them. Never pruned. */ + mismatchedParents: number; + /** Existing source records whose exact projected role or grant relation differs from PostgreSQL. */ + mismatchedPermissions: number; + /** Source records PostgreSQL holds that SpiceDB has no relationship for. */ + missing: number; + orphaned: number; + pruned: number; + reconciled: number; + scanned: number; + skipped: number; + /** + * Relationships outside the vocabulary, counted rather than merely listed. + * + * The `unmanaged` list is capped for output size, so the count is what the status can be computed from. + */ + unmanaged: number; +}>; + +export type TAuthzedBackfillFailure = Readonly<{ + /** + * How many attempts stood behind this failure. + * + * Carried because the commands run at `LOG_LEVEL=fatal` to keep stdout a single JSON line, so this + * object is the operator's only diagnostic. Without it, "failed once" and "exhausted the retry + * budget" — a blip versus an outage — are indistinguishable in the report. + */ + attempts: number; + code: string; + organizationId: string; + retryable: boolean; +}>; + +export type TAuthzedBackfillResult = Readonly<{ + /** + * A revision SpiceDB was at *after* this run finished writing, or `null`. + * + * Captured by one read issued once all work is done, so it genuinely post-dates the run's own writes + * and can serve as an `at_least_as_fresh` floor for shadow evaluation. Taking it from the observation + * reads instead would have pre-dated them, which is the opposite of a freshness floor. + * + * `null` for a dry run (nothing was written, so there is nothing to be fresh relative to), for an empty + * store, and if the closing read fails — never a stale value dressed up as a fresh one. + */ + completedAtSnapshot: string | null; + counters: TAuthzedBackfillCounters; + failures: ReadonlyArray; + lastOrganizationId: string | null; + /** + * Parent edges PostgreSQL contradicts. + * + * Reported and never touched. A cross-tenant parent edge is a privilege escalation, but removing it + * safely means deleting a relation the resource legitimately needs one of, so it is deliberately left + * for a human — see the runbook. + */ + mismatchedParents: ReadonlyArray; + /** Exact role/grant relation mismatches. Applying reconciliation repairs these deterministically. */ + mismatchedPermissions: ReadonlyArray; + mode: "apply" | "dry_run"; + orphanScope: "all" | "known_resources"; + orphans: ReadonlyArray; + scope: "all" | "organization" | "workspace"; + status: "drifted" | "failed" | "reconciled"; + /** + * Set when the counters are not exact. Either way, re-run before concluding anything. + * + * Two causes, and they err in opposite directions, so neither "floor" nor "total" describes the + * counts on its own: + * + * - **an observation was abandoned** mid-read, so fewer relationships were seen than exist and the + * counts are a floor. Fail-safe for pruning: fewer orphans found means fewer deleted. + * - **the sweep's deduplication bound was exceeded**, so a record implied by two pages beyond that + * point is counted twice and the counts may over-report. Also safe, because a run with that many + * orphans is orders of magnitude past the prune cap and so deletes nothing. + * + * Deliberately narrow in one respect: it does *not* mean the `orphans` / `failures` / + * `mismatchedParents` lists hit their reporting cap. Those stay capped at 100 entries with the + * counters carrying the true totals, and conflating the two would make a merely-verbose run look like + * an incomplete one — which matters, because this flag forces a non-clean status. + */ + truncated: boolean; + unmanaged: ReadonlyArray>; +}>; + +/** Entries reported individually before the list is capped and only counters remain accurate. */ +const MAX_REPORTED_ENTRIES = 100; + +const toErrorCode = (error: unknown): Readonly<{ attempts: number; code: string; retryable: boolean }> => + error instanceof AuthzedError + ? { attempts: error.attempts, code: error.code, retryable: error.retryable } + : { attempts: 1, code: "authzed_internal", retryable: false }; + +/** The seven target lists the three reconcilers accept between them. */ +type TReconcileTargets = Readonly<{ + apiKeyIds: ReadonlyArray; + apiKeyWorkspaceGrants: ReadonlyArray; + feedbackDirectoryAssignmentObjectIds: ReadonlyArray; + feedbackDirectoryAssignments: ReadonlyArray; + feedbackDirectoryIds: ReadonlyArray; + memberships: ReadonlyArray; + teamIds: ReadonlyArray; + teamMemberships: ReadonlyArray; + workspaceIds: ReadonlyArray; + workspaceTeamGrants: ReadonlyArray; +}>; + +/** + * Turn missing source records into reconciler targets. + * + * A record PostgreSQL does not hold becomes a *target*, never a delete instruction. The reconciler + * re-reads PostgreSQL and decides, so a record recreated between the observation and the reconcile is + * written rather than deleted — the race resolves toward granting access, not revoking it. That + * indirection is the core reason repair is safe. + */ +const toRepairTargets = (refs: ReadonlyArray): TReconcileTargets => { + const apiKeyIds: string[] = []; + const apiKeyWorkspaceGrants: TAuthzedApiKeyWorkspaceTarget[] = []; + const feedbackDirectoryAssignmentObjectIds: string[] = []; + const feedbackDirectoryAssignments: TAuthzedFeedbackDirectoryAssignmentTarget[] = []; + const feedbackDirectoryIds: string[] = []; + const memberships: TAuthzedMembershipTarget[] = []; + const teamIds: string[] = []; + const teamMemberships: TAuthzedTeamMembershipTarget[] = []; + const workspaceIds: string[] = []; + const workspaceTeamGrants: TAuthzedWorkspaceTeamTarget[] = []; + + for (const ref of refs) { + switch (ref.kind) { + case "apiKey": + apiKeyIds.push(ref.apiKeyId); + break; + case "apiKeyWorkspaceGrant": + apiKeyWorkspaceGrants.push({ apiKeyId: ref.apiKeyId, workspaceId: ref.workspaceId }); + break; + case "feedbackDirectory": + feedbackDirectoryIds.push(ref.feedbackDirectoryId); + break; + case "feedbackDirectoryAssignment": + if (ref.feedbackDirectoryId !== undefined && ref.workspaceId !== undefined) { + feedbackDirectoryAssignments.push({ + feedbackDirectoryId: ref.feedbackDirectoryId, + workspaceId: ref.workspaceId, + }); + } else { + feedbackDirectoryAssignmentObjectIds.push(ref.assignmentId); + } + break; + case "membership": + memberships.push({ organizationId: ref.organizationId, userId: ref.userId }); + break; + case "team": + teamIds.push(ref.teamId); + break; + case "teamMembership": + teamMemberships.push({ teamId: ref.teamId, userId: ref.userId }); + break; + case "workspace": + workspaceIds.push(ref.workspaceId); + break; + case "workspaceTeamGrant": + workspaceTeamGrants.push({ teamId: ref.teamId, workspaceId: ref.workspaceId }); + break; + } + } + + return { + apiKeyIds, + apiKeyWorkspaceGrants, + feedbackDirectoryAssignmentObjectIds, + feedbackDirectoryAssignments, + feedbackDirectoryIds, + memberships, + teamIds, + teamMemberships, + workspaceIds, + workspaceTeamGrants, + }; +}; + +/** + * The source records an organization holds, in the same vocabulary an observation produces. + * + * Lets the two sides be compared with a set difference, which is what makes a dry run able to report the + * PostgreSQL-to-SpiceDB direction at all. + */ +const expectedFeedbackDirectoryAssignmentIds = ( + expectedRelationships: ReadonlyArray +): ReadonlySet => + new Set( + expectedRelationships + .filter( + ({ relation, resource, subject }) => + relation === "assignment" && + resource.objectType === "feedback_directory" && + subject.objectType === "feedback_directory_assignment" + ) + .map(({ subject }) => subject.objectId) + ); + +const toSourceRefs = (source: TAuthzedOrganizationSource): ReadonlyArray => { + const activeAssignmentIds = expectedFeedbackDirectoryAssignmentIds(source.expectedRelationships); + + return [ + ...source.memberships.map( + ({ organizationId, userId }): TAuthzedSourceRef => ({ kind: "membership", organizationId, userId }) + ), + ...source.teamIds.map((teamId): TAuthzedSourceRef => ({ kind: "team", teamId })), + ...source.teamMemberships.map( + ({ teamId, userId }): TAuthzedSourceRef => ({ kind: "teamMembership", teamId, userId }) + ), + ...source.workspaceIds.map((workspaceId): TAuthzedSourceRef => ({ kind: "workspace", workspaceId })), + ...source.workspaceTeamGrants.map( + ({ teamId, workspaceId }): TAuthzedSourceRef => ({ kind: "workspaceTeamGrant", teamId, workspaceId }) + ), + ...source.apiKeyIds.map((apiKeyId): TAuthzedSourceRef => ({ apiKeyId, kind: "apiKey" })), + ...source.apiKeyWorkspaceGrants.map( + ({ apiKeyId, workspaceId }): TAuthzedSourceRef => ({ + apiKeyId, + kind: "apiKeyWorkspaceGrant", + workspaceId, + }) + ), + ...source.feedbackDirectoryIds.map( + (feedbackDirectoryId): TAuthzedSourceRef => ({ feedbackDirectoryId, kind: "feedbackDirectory" }) + ), + ...source.feedbackDirectoryAssignments.flatMap( + ({ feedbackDirectoryId, workspaceId }): ReadonlyArray => { + const assignmentId = getFeedbackDirectoryAssignmentObjectId(feedbackDirectoryId, workspaceId); + return activeAssignmentIds.has(assignmentId) + ? [ + { + assignmentId, + feedbackDirectoryId, + kind: "feedbackDirectoryAssignment", + workspaceId, + }, + ] + : []; + } + ), + ]; +}; + +const mergeTargets = (left: TReconcileTargets, right: TReconcileTargets): TReconcileTargets => ({ + apiKeyIds: [...left.apiKeyIds, ...right.apiKeyIds], + apiKeyWorkspaceGrants: [...left.apiKeyWorkspaceGrants, ...right.apiKeyWorkspaceGrants], + feedbackDirectoryAssignmentObjectIds: [ + ...left.feedbackDirectoryAssignmentObjectIds, + ...right.feedbackDirectoryAssignmentObjectIds, + ], + feedbackDirectoryAssignments: [...left.feedbackDirectoryAssignments, ...right.feedbackDirectoryAssignments], + feedbackDirectoryIds: [...left.feedbackDirectoryIds, ...right.feedbackDirectoryIds], + memberships: [...left.memberships, ...right.memberships], + teamIds: [...left.teamIds, ...right.teamIds], + teamMemberships: [...left.teamMemberships, ...right.teamMemberships], + workspaceIds: [...left.workspaceIds, ...right.workspaceIds], + workspaceTeamGrants: [...left.workspaceTeamGrants, ...right.workspaceTeamGrants], +}); + +/** + * Reconcile every target list, reporting the first reconciler that did not project. + * + * One call per reconciler rather than one per list: each reads a single snapshot covering everything it + * was given, so this is three snapshot reads for an organization instead of seven. + * + * `runBestEffortProjection` never throws; a reconciler hands back `{ status: "failed" }` instead. That + * is what gives per-unit isolation for free, since one organization's AuthZed outage cannot abort the + * sweep. `"disabled"` counts as a failure rather than a success — otherwise a run against an instance + * with AuthZed switched off would report every organization as reconciled. + */ +const reconcileTargets = async ( + apply: TAuthzedBackfillApply, + targets: TReconcileTargets +): Promise => { + // Stops at the first reconciler that does not project. Continuing would spend two more three-attempt + // retry budgets against an instance already known to be unreachable, and the unit is failed either way. + const steps = [ + () => runChunked(apply.reconcileMemberships, { memberships: targets.memberships }), + () => + runChunked(apply.reconcileTeamWorkspace, { + teamIds: targets.teamIds, + teamMemberships: targets.teamMemberships, + workspaceIds: targets.workspaceIds, + workspaceTeamGrants: targets.workspaceTeamGrants, + }), + () => + runChunked(apply.reconcileApiKeys, { + apiKeyIds: targets.apiKeyIds, + apiKeyWorkspaceGrants: targets.apiKeyWorkspaceGrants, + }), + () => + runChunked(apply.reconcileFeedbackDirectories, { + assignments: targets.feedbackDirectoryAssignments, + feedbackDirectoryIds: targets.feedbackDirectoryIds, + }), + () => + runChunked( + ({ assignmentIds }: Readonly<{ assignmentIds: ReadonlyArray }>) => + apply.deleteFeedbackDirectoryAssignmentResources(assignmentIds), + { assignmentIds: targets.feedbackDirectoryAssignmentObjectIds } + ), + ]; + + for (const step of steps) { + const outcome = await step(); + if (outcome !== null && outcome.status !== "projected") { + return outcome; + } + } + + return undefined; +}; + +/** + * Observe the relationships on one organization's own resources. + * + * Bounded to resources PostgreSQL still knows about, because SpiceDB relationship filters have no + * notion of "belongs to organization X" and Formbricks object IDs carry no organization prefix. A + * resource whose row is already gone is therefore unreachable from its organization, which is why + * single-organization repair reports `orphanScope: "known_resources"` and only a full sweep can claim + * completeness. + */ +const observeOrganizationResources = async ( + client: Pick, + organizationId: string, + source: TAuthzedOrganizationSource +): Promise; snapshot: string | null }>> => { + const filters = [ + { resourceId: organizationId, resourceType: "organization" }, + ...source.teamIds.map((teamId) => ({ resourceId: teamId, resourceType: "team" })), + ...source.workspaceIds.map((workspaceId) => ({ resourceId: workspaceId, resourceType: "workspace" })), + ...source.apiKeyIds.map((apiKeyId) => ({ resourceId: apiKeyId, resourceType: "api_key" })), + ...source.feedbackDirectoryIds.map((feedbackDirectoryId) => ({ + resourceId: feedbackDirectoryId, + resourceType: "feedback_directory", + })), + ...source.feedbackDirectoryAssignments.map(({ feedbackDirectoryId, workspaceId }) => ({ + resourceId: getFeedbackDirectoryAssignmentObjectId(feedbackDirectoryId, workspaceId), + resourceType: "feedback_directory_assignment", + })), + ]; + + const relationships: TAuthzedRelationship[] = []; + let snapshot: string | null = null; + + // Bounded windows rather than one read at a time: an organization with many workspaces would + // otherwise cost that many sequential round trips. The bound is the same one that caps parallel + // relationship deletes, so this cannot outrun the connection budget the rest of the module assumes. + // + // Each filter resolves its own revision, which is fine: an observation is only ever used to name the + // source record a relationship implies, and the reconciler re-reads PostgreSQL before acting on it. + // Nothing here compares two resources against each other. + for (let start = 0; start < filters.length; start += AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES) { + const observations = await Promise.all( + filters + .slice(start, start + AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES) + .map((filter) => readAllRelationships(client, filter)) + ); + + for (const observation of observations) { + relationships.push(...observation.relationships); + snapshot = observation.snapshot?.token ?? snapshot; + } + } + + return { relationships, snapshot }; +}; + +/** + * One run's mutable tallies and capped report lists. + * + * Held in an explicit object rather than in closure variables so every unit of work can be a + * module-level function. The orchestrator used to close over a dozen `let`s, which made it a single + * function too large to reason about — and every defect found while reviewing this tool was in that + * function. + */ +type TRunState = { + completedAtSnapshot: string | null; + failed: number; + readonly failures: TAuthzedBackfillFailure[]; + ignored: number; + invalid: number; + lastOrganizationId: string | null; + mismatchedParentCount: number; + readonly mismatchedParents: TAuthzedParentEdge[]; + mismatchedPermissionCount: number; + readonly mismatchedPermissions: TAuthzedPermissionMismatch[]; + missingCount: number; + orphaned: number; + readonly orphans: TAuthzedSourceRef[]; + pruned: number; + reconciled: number; + scanned: number; + skipped: number; + truncated: boolean; + readonly unmanaged: Array>; + unmanagedCount: number; +}; + +const createRunState = (): TRunState => ({ + completedAtSnapshot: null, + failed: 0, + failures: [], + ignored: 0, + invalid: 0, + lastOrganizationId: null, + mismatchedParentCount: 0, + mismatchedParents: [], + mismatchedPermissionCount: 0, + mismatchedPermissions: [], + missingCount: 0, + orphaned: 0, + orphans: [], + pruned: 0, + reconciled: 0, + scanned: 0, + skipped: 0, + truncated: false, + unmanaged: [], + unmanagedCount: 0, +}); + +/** A unit of work's whole world: the run's tallies plus the configuration every unit shares. */ +type TRunContext = Readonly<{ + apply: TAuthzedBackfillApply; + client: Pick; + isPruning: boolean; + maxPrune: number; + mode: "apply" | "dry_run"; + /** + * Whether a per-organization observation owns the orphan tallies. + * + * False only for a full scope, where the streamed sweep sees strictly more — counting on both paths + * would report every stale relationship twice, and the default invocation is exactly that + * combination (dry run, full scope). + */ + ownsOrphanAccounting: boolean; + sourceReads: TAuthzedBackfillSource; + state: TRunState; +}>; + +/** Append while honouring the reporting cap, so a run against a broken instance cannot emit a huge line. */ +const pushCapped = (list: T[], items: ReadonlyArray): void => { + list.push(...items.slice(0, Math.max(0, MAX_REPORTED_ENTRIES - list.length))); +}; + +const recordFailure = (state: TRunState, organizationId: string, error: unknown): void => { + state.failed++; + pushCapped(state.failures, [{ organizationId, ...toErrorCode(error) }]); +}; + +const recordProjectionFailure = ( + state: TRunState, + organizationId: string, + result: TAuthzedProjectionResult +): void => { + state.failed++; + pushCapped(state.failures, [ + result.status === "failed" + ? { attempts: result.attempts, code: result.code, organizationId, retryable: result.retryable } + : // `disabled` reaching here means AuthZed was switched off mid-run; nothing was attempted. + { attempts: 0, code: "authzed_disabled", organizationId, retryable: false }, + ]); +}; + +const recordMismatchedParents = (state: TRunState, edges: ReadonlyArray): void => { + state.mismatchedParentCount += edges.length; + pushCapped(state.mismatchedParents, edges); +}; + +const recordMismatchedPermissions = ( + state: TRunState, + mismatches: ReadonlyArray +): void => { + state.mismatchedPermissionCount += mismatches.length; + pushCapped(state.mismatchedPermissions, mismatches); +}; + +/** + * What a unit is permitted to prune. + * + * `overBudget` is carried separately rather than inferred from an empty `refs`, because "nothing to + * prune" and "too much to prune safely" must lead to different decisions and an empty list cannot tell + * them apart. The workspace scope depends on the difference: naming a workspace whose row is gone + * deletes *every* relationship on it, so that target has to be withheld when the budget was exceeded. + */ +type TPruneDecision = Readonly<{ + overBudget: boolean; + refs: ReadonlyArray; +}>; + +/** + * Record the classification tallies an observation implies, and verify the organizations its resources + * claim to belong to. + * + * Shared by all three observation paths — the two narrow scopes and each page of the sweep — because + * this half is identical regardless of how the relationships were reached. + */ +const recordObservationSummary = async ( + ctx: TRunContext, + summary: TAuthzedObservationSummary +): Promise => { + ctx.state.ignored += summary.ignored; + ctx.state.unmanagedCount += summary.unmanaged.length; + pushCapped(ctx.state.unmanaged, summary.unmanaged); + recordMismatchedParents(ctx.state, await ctx.sourceReads.findMismatchedParentEdges(summary.parentEdges)); +}; + +/** + * Record the orphans a narrow-scope observation found, and decide which of them may be pruned. + * + * A count over the budget prunes *nothing* for that unit: a large orphan count is a symptom — wrong + * endpoint, wrong database, a restore in progress — not a big cleanup job, so the run degrades into a + * loud report instead of a partly-destroyed graph. + * + * The sweep deliberately does not use this. It streams, so it has to deduplicate across pages before it + * can count, and it decides the budget against the whole sweep rather than against one unit. + */ +const recordScopedOrphans = async ( + ctx: TRunContext, + summary: TAuthzedObservationSummary +): Promise => { + const missingRefs = await ctx.sourceReads.findMissingSourceRefs(summary.sourceRefs); + ctx.state.orphaned += missingRefs.length; + pushCapped(ctx.state.orphans, missingRefs); + + if (missingRefs.length > ctx.maxPrune) { + ctx.state.skipped++; + + return { overBudget: true, refs: [] }; + } + + return { overBudget: false, refs: ctx.isPruning ? missingRefs : [] }; +}; + +/** + * Observe one organization's own resources and record what that implies. + * + * Returns the refs to hand a reconciler as repair targets. Throws if the observation could not be + * completed, which the caller must treat as "this unit could not be observed" rather than as + * "nothing stale here". + */ +const observeOrganization = async ( + ctx: TRunContext, + organizationId: string, + source: TAuthzedOrganizationSource +): Promise => { + const { state } = ctx; + const observation = await observeOrganizationResources(ctx.client, organizationId, source); + const summary = summarizeObservation(observation.relationships); + + if (ctx.mode === "dry_run") { + // The direction an applying run converges by writing, and the only one a report can speak to. + state.missingCount += findUnprojectedSourceRefs(toSourceRefs(source), summary.sourceRefs).length; + recordMismatchedPermissions( + state, + findMismatchedPermissionRelations(source.expectedRelationships, summary.managedRelationships) + ); + } + + if (!ctx.ownsOrphanAccounting) { + return { overBudget: false, refs: [] }; + } + + await recordObservationSummary(ctx, summary); + + return recordScopedOrphans(ctx, summary); +}; + +const processOrganization = async (ctx: TRunContext, organizationId: string): Promise => { + const { state } = ctx; + state.scanned++; + state.lastOrganizationId = organizationId; + + let source: TAuthzedOrganizationSource; + try { + source = await ctx.sourceReads.readOrganizationSource(organizationId); + } catch (error) { + recordFailure(state, organizationId, error); + + return; + } + + state.invalid += + source.invalidWorkspaceTeamGrants.length + + source.invalidApiKeyWorkspaceGrants.length + + source.invalidFeedbackDirectoryAssignments.length; + + // Observed for two reasons with different owners: a narrow scope owns everything it finds, while a + // full scope observes only to compute the direction the sweep cannot — records PostgreSQL holds that + // SpiceDB is missing. An applying full scope skips it entirely, since its writes converge that + // direction anyway and a read per resource to report what is about to be fixed is waste. + let repairRefs: ReadonlyArray = []; + if (ctx.ownsOrphanAccounting || ctx.mode === "dry_run") { + try { + repairRefs = (await observeOrganization(ctx, organizationId, source)).refs; + } catch (error) { + // An abandoned observation must never be reported as a complete one: fewer relationships seen + // means fewer orphans found, and a caller could otherwise read that as "nothing stale here". + state.truncated = true; + recordFailure(state, organizationId, error); + + return; + } + } + + if (ctx.mode === "dry_run") { + return; + } + + const failure = await reconcileTargets( + ctx.apply, + mergeTargets( + { + apiKeyIds: source.apiKeyIds, + apiKeyWorkspaceGrants: source.apiKeyWorkspaceGrants, + feedbackDirectoryAssignmentObjectIds: [], + feedbackDirectoryAssignments: source.feedbackDirectoryAssignments, + feedbackDirectoryIds: source.feedbackDirectoryIds, + memberships: source.memberships, + teamIds: source.teamIds, + teamMemberships: source.teamMemberships, + workspaceIds: source.workspaceIds, + workspaceTeamGrants: source.workspaceTeamGrants, + }, + toRepairTargets(repairRefs) + ) + ); + + if (failure) { + recordProjectionFailure(state, organizationId, failure); + + return; + } + + // Counted here rather than at detection time so a failed reconcile cannot report relationships as + // pruned that are still present. + state.pruned += repairRefs.length; + state.reconciled++; +}; + +/** + * Tally one page of the sweep, returning the orphans on it that no earlier page already reported. + * + * Deduplicated run-wide because each page is classified on its own, so one record can be implied by + * relationships on two pages — across resource types (an API key is named by both + * `api_key#organization` and `organization#api_key_reader`) and within one, since alternate relations + * on the same resource come back grouped by relation rather than adjacently. + */ +const tallySweepPage = async ( + ctx: TRunContext, + seenOrphanRefs: Set, + relationships: ReadonlyArray +): Promise> => { + const { state } = ctx; + const summary = summarizeObservation(relationships); + await recordObservationSummary(ctx, summary); + + const fresh: TAuthzedSourceRef[] = []; + for (const ref of await ctx.sourceReads.findMissingSourceRefs(summary.sourceRefs)) { + const key = sourceRefKey(ref); + if (seenOrphanRefs.has(key)) { + continue; + } + if (seenOrphanRefs.size >= AUTHZED_MAX_TRACKED_ORPHAN_REFS) { + // Past the bound the count may double-count. Say so rather than let the total read as exact. + state.truncated = true; + } else { + seenOrphanRefs.add(key); + } + + fresh.push(ref); + } + + state.orphaned += fresh.length; + pushCapped(state.orphans, fresh); + + return fresh; +}; + +/** + * Sweep every managed resource type to find resources PostgreSQL no longer holds at all. + * + * Two phases, and the order is the safety property. The whole sweep is observed and counted first; + * only then, and only if the confirmed total fits the budget, is anything deleted. Enforcing the cap + * while streaming would delete every page that fit and halt on the one that did not — so a run aimed + * at the wrong database would revoke a cap's worth of live access instead of revoking none, which + * inverts what the cap is for. + * + * Streamed rather than drained: a resource type has no upper bound in a real deployment, so + * accumulating one would hold the whole store in memory and trip the per-unit observation bound, + * turning the only mode that can remove stale relationships into one that fails permanently on exactly + * the deployments that need it. Only the prunable refs are accumulated, and the budget bounds those. + */ +const sweepGlobalOrphans = async (ctx: TRunContext): Promise => { + const { state } = ctx; + // Bounded by the budget: past it nothing will be pruned anyway, so there is no reason to hold more. + const prunable: TAuthzedSourceRef[] = []; + const seenOrphanRefs = new Set(); + let sweepOrphans = 0; + + for (const resourceType of getManagedResourceTypes()) { + await forEachRelationshipPage(ctx.client, { resourceType }, async (relationships) => { + const fresh = await tallySweepPage(ctx, seenOrphanRefs, relationships); + sweepOrphans += fresh.length; + // Bounded by the prune budget, *not* by the reporting cap: `pushCapped` would silently stop at + // 100 and under-prune a run that is entirely within its budget. + prunable.push(...fresh.slice(0, Math.max(0, ctx.maxPrune - prunable.length))); + }); + } + + if (!ctx.isPruning || sweepOrphans === 0) { + return; + } + + if (sweepOrphans > ctx.maxPrune) { + // Nothing has been deleted yet, and nothing will be. + state.skipped++; + + return; + } + + const failure = await reconcileTargets(ctx.apply, toRepairTargets(prunable)); + if (failure) { + // Attributed to no organization: a fully orphaned resource has none left to attribute it to. + recordProjectionFailure(state, "", failure); + + return; + } + + state.pruned += prunable.length; +}; + +/** The source records a workspace's own relationships should cover, when its row still exists. */ +const toWorkspaceSourceRefs = ( + source: TAuthzedWorkspaceSource, + workspaceId: string +): ReadonlyArray => { + if (!source.workspaceExists) { + return []; + } + + const activeAssignmentIds = expectedFeedbackDirectoryAssignmentIds(source.expectedRelationships); + return [ + ...source.workspaceTeamGrants.map( + ({ teamId, workspaceId: grantWorkspaceId }): TAuthzedSourceRef => ({ + kind: "workspaceTeamGrant", + teamId, + workspaceId: grantWorkspaceId, + }) + ), + ...source.apiKeyWorkspaceGrants.map( + ({ apiKeyId, workspaceId: grantWorkspaceId }): TAuthzedSourceRef => ({ + apiKeyId, + kind: "apiKeyWorkspaceGrant", + workspaceId: grantWorkspaceId, + }) + ), + ...source.feedbackDirectoryAssignments.flatMap( + ({ feedbackDirectoryId, workspaceId: assignmentWorkspaceId }): ReadonlyArray => { + const assignmentId = getFeedbackDirectoryAssignmentObjectId( + feedbackDirectoryId, + assignmentWorkspaceId + ); + return activeAssignmentIds.has(assignmentId) + ? [ + { + assignmentId, + feedbackDirectoryId, + kind: "feedbackDirectoryAssignment", + workspaceId: assignmentWorkspaceId, + }, + ] + : []; + } + ), + { kind: "workspace", workspaceId }, + ]; +}; + +const observeWorkspace = async ( + ctx: TRunContext, + workspaceId: string, + source: TAuthzedWorkspaceSource +): Promise => { + const observations = await Promise.all([ + readAllRelationships(ctx.client, { resourceId: workspaceId, resourceType: "workspace" }), + ...[ + ...new Set(source.feedbackDirectoryAssignments.map(({ feedbackDirectoryId }) => feedbackDirectoryId)), + ].map((feedbackDirectoryId) => + readAllRelationships(ctx.client, { + resourceId: feedbackDirectoryId, + resourceType: "feedback_directory", + }) + ), + ...source.feedbackDirectoryAssignments.map( + ({ feedbackDirectoryId, workspaceId: assignmentWorkspaceId }) => + readAllRelationships(ctx.client, { + resourceId: getFeedbackDirectoryAssignmentObjectId(feedbackDirectoryId, assignmentWorkspaceId), + resourceType: "feedback_directory_assignment", + }) + ), + ]); + const summary = summarizeObservation(observations.flatMap(({ relationships }) => relationships)); + await recordObservationSummary(ctx, summary); + + if (ctx.mode === "dry_run") { + // Dry run only. An applying run converges this direction by writing, so counting it beforehand would + // leave a successful repair reporting `drifted` and exiting 2 on the strength of a pre-write reading. + ctx.state.missingCount += findUnprojectedSourceRefs( + toWorkspaceSourceRefs(source, workspaceId), + summary.sourceRefs + ).length; + recordMismatchedPermissions( + ctx.state, + findMismatchedPermissionRelations(source.expectedRelationships, summary.managedRelationships) + ); + } + + return recordScopedOrphans(ctx, summary); +}; + +/** + * Drop prune targets whose deletion would reach outside the named workspace. + * + * A grant ref implies its principal — `normalizeTargets` in both reconcilers adds the team or API key a + * grant names — and when that principal has no PostgreSQL row the reconciler deletes subject-wide: every + * workspace relationship for that team, or every organization *and* workspace relationship for that key. + * One in-budget orphan on this workspace would therefore delete relationships in other tenants, none of + * them counted against `pruned` or weighed against the cap. + * + * That fan-out is correct convergence — those relationships genuinely should go — but it is the + * organization or full sweep's unit of work, not this one's. Here the ref stays counted in `orphaned` and + * is withheld, so the run finishes `drifted` and tells the operator a wider scope is needed. + */ +const withinWorkspaceScope = async ( + ctx: TRunContext, + refs: ReadonlyArray +): Promise> => { + const principalFor = (ref: TAuthzedSourceRef): TAuthzedSourceRef | null => { + if (ref.kind === "workspaceTeamGrant") { + return { kind: "team", teamId: ref.teamId }; + } + if (ref.kind === "apiKeyWorkspaceGrant") { + return { apiKeyId: ref.apiKeyId, kind: "apiKey" }; + } + + return null; + }; + + const principals = refs.map(principalFor).filter((ref): ref is TAuthzedSourceRef => ref !== null); + if (principals.length === 0) { + return refs; + } + + const missing = new Set( + (await ctx.sourceReads.findMissingSourceRefs(principals)).map((ref) => sourceRefKey(ref)) + ); + + return refs.filter((ref) => { + const principal = principalFor(ref); + + return principal === null || !missing.has(sourceRefKey(principal)); + }); +}; + +/** + * Reconcile one workspace's grants. + * + * The narrowest unit available, and unlike an organization it does not have to exist: a workspace whose + * row is gone is the case most worth repairing, and its relationships are reachable from the ID the + * caller supplied. + * + * Narrow, but not hermetic. The API keys holding grants on this workspace are reconciled in full, which + * covers every *other* workspace those keys hold too — a key is reconciled as a unit, and splitting it + * would mean writing a second, narrower implementation of the same convergence. Everything that reaches + * that way is convergent: it writes what PostgreSQL says. + * + * Deletion is held to the stated scope separately, by `withinWorkspaceScope`. Without it a grant whose + * principal had been deleted would make the reconciler delete subject-wide, reaching other tenants on the + * strength of one orphan here — see that function for why those cases are deferred instead. + */ +const processWorkspace = async (ctx: TRunContext, workspaceId: string): Promise => { + const { state } = ctx; + state.scanned++; + + let source: TAuthzedWorkspaceSource; + try { + source = await ctx.sourceReads.readWorkspaceSource(workspaceId); + } catch (error) { + // No organization to attribute this to: the read that would have told us which one failed. + recordFailure(state, "", error); + + return; + } + + // Attributed to the owning tenant where PostgreSQL knows one. The empty string is also the sweep's + // marker for an orphan with no organization left, so a workspace that still has a row must not + // report it. + const failureOrganizationId = source.organizationId ?? ""; + + state.invalid += + source.invalidWorkspaceTeamGrants.length + + source.invalidApiKeyWorkspaceGrants.length + + source.invalidFeedbackDirectoryAssignments.length; + + // Declared without a value, like `source` above: the `catch` returns, so an initializer here would be + // dead — and a dead initializer on a prune decision is worse than noise, since it reads as a safe + // default that nothing actually falls back to. + let decision: TPruneDecision; + try { + const observed = await observeWorkspace(ctx, workspaceId, source); + decision = { ...observed, refs: await withinWorkspaceScope(ctx, observed.refs) }; + } catch (error) { + state.truncated = true; + recordFailure(state, failureOrganizationId, error); + + return; + } + + if (ctx.mode === "dry_run") { + return; + } + + const failure = await reconcileTargets( + ctx.apply, + mergeTargets( + { + apiKeyIds: [], + apiKeyWorkspaceGrants: source.apiKeyWorkspaceGrants, + feedbackDirectoryAssignmentObjectIds: [], + feedbackDirectoryAssignments: source.feedbackDirectoryAssignments, + feedbackDirectoryIds: [], + memberships: [], + teamIds: [], + teamMemberships: [], + // Naming the workspace projects its parent edge when the row exists, and removes *every* + // relationship on it when the row does not — team grants and API-key grants included. That + // second case is a prune by the definition on `TAuthzedBackfillRequest`: reconciling a record + // observed only in SpiceDB. So it needs the same permission, budget and accounting as any other + // prune, rather than happening as a side effect of naming a stale ID with `--apply`. + // + // `overBudget` is load-bearing here, not decoration: without it an over-cap unit would report + // `skipped: 1, pruned: 0` and still perform the widest deletion available on that workspace, + // which inverts what the cap is for. + workspaceIds: source.workspaceExists || (ctx.isPruning && !decision.overBudget) ? [workspaceId] : [], + workspaceTeamGrants: source.workspaceTeamGrants, + }, + toRepairTargets(decision.refs) + ) + ); + + if (failure) { + recordProjectionFailure(state, failureOrganizationId, failure); + + return; + } + + state.pruned += decision.refs.length; + state.reconciled++; +}; + +/** Walk every organization by keyset page. */ +const enumerateOrganizations = async (ctx: TRunContext, afterOrganizationId?: string): Promise => { + let cursor = afterOrganizationId; + + for (;;) { + let organizationIds: ReadonlyArray; + try { + organizationIds = await ctx.sourceReads.readOrganizationIdPage({ + afterOrganizationId: cursor, + limit: AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE, + }); + } catch (error) { + // Caught rather than propagated so the report survives. Letting this escape would replace the + // whole result with a bare failure line, discarding `lastOrganizationId` — the only thing an + // operator can resume a long sweep from. + ctx.state.truncated = true; + recordFailure(ctx.state, "", error); + + return; + } + + if (organizationIds.length === 0) { + return; + } + + // Strictly sequential, and not only to avoid write contention: `lastOrganizationId` is the resume + // cursor, so processing out of order would let a resume skip an organization that failed while a + // later one succeeded. + for (const organizationId of organizationIds) { + await processOrganization(ctx, organizationId); + } + cursor = organizationIds.at(-1); + } +}; + +const runScope = async (ctx: TRunContext, scope: TAuthzedBackfillScope): Promise => { + if (scope.kind === "workspace") { + await processWorkspace(ctx, scope.workspaceId); + + return; + } + + if (scope.kind === "organization") { + if (!(await ctx.sourceReads.organizationExists(scope.organizationId))) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.NOT_FOUND, + operation: "backfill_scope", + retryable: false, + }); + } + await processOrganization(ctx, scope.organizationId); + + return; + } + + // The sweep runs even if enumeration broke off part way: it is independent of organization paging, + // and it is the only thing that can find a resource no organization can reach. + await enumerateOrganizations(ctx, scope.afterOrganizationId); + try { + await sweepGlobalOrphans(ctx); + } catch (error) { + ctx.state.truncated = true; + recordFailure(ctx.state, "", error); + } +}; + +/** + * A revision read after all work, so it post-dates every write this run made and can serve as an + * `at_least_as_fresh` floor. Any managed type answers: the revision is a property of the datastore, + * not of the filter. + */ +const captureClosingSnapshot = async (ctx: TRunContext): Promise => { + try { + const closing = await ctx.client.readRelationships({ + filter: { resourceType: "organization" }, + limit: 1, + }); + + return closing.snapshot?.token ?? null; + } catch { + // A freshness floor that might pre-date the writes is worse than none at all. + return null; + } +}; + +/** + * A failure outranks drift, and only a run that found nothing outstanding is `reconciled`. + * + * Every category of *unrepaired* state counts, not just the ones this tool can fix. `missing` and + * `mismatchedParents` matter as much as `orphaned` — without them a dry run over an empty SpiceDB would + * report "reconciled", the exact state this tool exists to fix — and so do `invalid` and `unmanaged`, + * which are deliberately left alone. A cross-organization source row or an unrecognized relationship is + * still authorization state nothing accounts for, and a clean exit here is what gates shadow evaluation + * and enforcement, so it must not be reachable while any of them remain. + */ +const toRunStatus = (state: TRunState): TAuthzedBackfillResult["status"] => { + if (state.failed > 0) { + return "failed"; + } + + const hasDrift = + state.orphaned > state.pruned || + state.missingCount > 0 || + state.mismatchedParentCount > 0 || + state.mismatchedPermissionCount > 0 || + state.invalid > 0 || + state.unmanagedCount > 0 || + state.truncated; + + return hasDrift ? "drifted" : "reconciled"; +}; + +export const runAuthzedBackfill = async ( + request: TAuthzedBackfillRequest, + dependencies: TAuthzedBackfillDependencies +): Promise => { + const { apply, client, source: sourceReads = defaultBackfillSource } = dependencies; + const state = createRunState(); + const ctx: TRunContext = { + apply, + client, + isPruning: request.prune && request.mode === "apply", + // Defence in depth: the parser already bounds this, but `runAuthzedBackfill` is exported and a + // caller passing 0 would make an over-cap unit invisible in `status`. + maxPrune: Math.max(1, Math.min(request.maxPrune, AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN)), + mode: request.mode, + ownsOrphanAccounting: request.scope.kind !== "all", + sourceReads, + state, + }; + + await runScope(ctx, request.scope); + + if (request.mode === "apply" && state.failed === 0) { + state.completedAtSnapshot = await captureClosingSnapshot(ctx); + } + + return { + completedAtSnapshot: state.completedAtSnapshot, + counters: { + failed: state.failed, + ignored: state.ignored, + invalid: state.invalid, + mismatchedParents: state.mismatchedParentCount, + mismatchedPermissions: state.mismatchedPermissionCount, + missing: state.missingCount, + orphaned: state.orphaned, + pruned: state.pruned, + reconciled: state.reconciled, + scanned: state.scanned, + skipped: state.skipped, + unmanaged: state.unmanagedCount, + }, + failures: state.failures, + lastOrganizationId: state.lastOrganizationId, + mismatchedParents: state.mismatchedParents, + mismatchedPermissions: state.mismatchedPermissions, + mode: request.mode, + orphanScope: request.scope.kind === "all" ? "all" : "known_resources", + orphans: state.orphans, + scope: request.scope.kind, + status: toRunStatus(state), + truncated: state.truncated, + unmanaged: state.unmanaged, + }; +}; diff --git a/apps/web/lib/authzed/cli.test.ts b/apps/web/lib/authzed/cli.test.ts new file mode 100644 index 000000000000..34d5d5e1d963 --- /dev/null +++ b/apps/web/lib/authzed/cli.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test, vi } from "vitest"; +import { runAuthzedHealthCli } from "./cli"; +import { AUTHZED_ERROR_CODES } from "./errors"; + +vi.mock("./client", () => ({ closeAuthzedClient: vi.fn() })); +vi.mock("./health", () => ({ checkAuthzedHealth: vi.fn() })); + +describe("runAuthzedHealthCli", () => { + test.each([ + [{ status: "disabled" } as const, 1], + [{ latencyMs: 12, status: "healthy" } as const, 0], + [ + { + code: AUTHZED_ERROR_CODES.UNAUTHENTICATED, + latencyMs: 7, + retryable: false, + status: "unhealthy", + } as const, + 1, + ], + ])("serializes one stable JSON result and returns exit code %i", async (healthResult, exitCode) => { + const closeClient = vi.fn(); + const writeOutput = vi.fn(); + + await expect( + runAuthzedHealthCli({ + checkHealth: vi.fn().mockResolvedValue(healthResult), + closeClient, + writeOutput, + }) + ).resolves.toBe(exitCode); + + expect(writeOutput).toHaveBeenCalledOnce(); + expect(writeOutput).toHaveBeenCalledWith(`${JSON.stringify(healthResult)}\n`); + expect(closeClient).toHaveBeenCalledOnce(); + }); + + test("sanitizes unexpected errors and closes the client", async () => { + const token = "never-log-this-authzed-token"; + const closeClient = vi.fn(); + const writeOutput = vi.fn(); + + await expect( + runAuthzedHealthCli({ + checkHealth: vi.fn().mockRejectedValue(new Error(`Bearer ${token}`)), + closeClient, + writeOutput, + }) + ).resolves.toBe(1); + + expect(writeOutput).toHaveBeenCalledWith( + `${JSON.stringify({ + code: AUTHZED_ERROR_CODES.INTERNAL, + latencyMs: 0, + retryable: false, + status: "unhealthy", + })}\n` + ); + expect(JSON.stringify(writeOutput.mock.calls)).not.toContain(token); + expect(closeClient).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/lib/authzed/cli.ts b/apps/web/lib/authzed/cli.ts new file mode 100644 index 000000000000..264aebe0d7b6 --- /dev/null +++ b/apps/web/lib/authzed/cli.ts @@ -0,0 +1,41 @@ +import "server-only"; +import { closeAuthzedClient } from "./client"; +import { AUTHZED_ERROR_CODES } from "./errors"; +import { type TAuthzedHealthResult, checkAuthzedHealth } from "./health"; + +const INTERNAL_FAILURE_RESULT = { + code: AUTHZED_ERROR_CODES.INTERNAL, + latencyMs: 0, + retryable: false, + status: "unhealthy", +} as const satisfies TAuthzedHealthResult; + +type TAuthzedHealthCliDependencies = Readonly<{ + checkHealth: () => Promise; + closeClient: () => void; + writeOutput: (output: string) => void; +}>; + +const defaultDependencies: TAuthzedHealthCliDependencies = { + checkHealth: checkAuthzedHealth, + closeClient: closeAuthzedClient, + writeOutput: (output) => process.stdout.write(output), +}; + +export const runAuthzedHealthCli = async ( + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + let result: TAuthzedHealthResult; + + try { + result = await dependencies.checkHealth(); + } catch { + result = INTERNAL_FAILURE_RESULT; + } finally { + dependencies.closeClient(); + } + + dependencies.writeOutput(`${JSON.stringify(result)}\n`); + return result.status === "healthy" ? 0 : 1; +}; diff --git a/apps/web/lib/authzed/client.test.ts b/apps/web/lib/authzed/client.test.ts new file mode 100644 index 000000000000..44c33c1dc57e --- /dev/null +++ b/apps/web/lib/authzed/client.test.ts @@ -0,0 +1,908 @@ +import { configMocks, envMock, retryMocks, sdkMocks } from "./__mocks__/client-dependencies"; +import { v1 } from "@authzed/authzed-node"; +import { status } from "@grpc/grpc-js"; +import { beforeEach, describe, expect, test } from "vitest"; +import { closeAuthzedClient, configureAuthzedClientForBulkWork, getAuthzedClient } from "./client"; +import { AUTHZED_MAX_RESOURCE_LOOKUP_RESULTS, AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; + +describe("AuthZed client facade", () => { + beforeEach(() => { + closeAuthzedClient(); + sdkMocks.close.mockReset(); + sdkMocks.checkPermission.mockReset(); + sdkMocks.deadlineInterceptor.mockClear(); + sdkMocks.deleteRelationships.mockReset(); + sdkMocks.diffSchema.mockReset(); + sdkMocks.lookupResources.mockReset(); + sdkMocks.newClient.mockReset(); + sdkMocks.readRelationships.mockReset(); + sdkMocks.readSchema.mockReset(); + sdkMocks.writeRelationships.mockReset(); + sdkMocks.writeSchema.mockReset(); + configMocks.isAuthzedEnabled.mockReset(); + retryMocks.execute.mockClear(); + envMock.AUTHZED_CONSISTENCY = undefined; + envMock.AUTHZED_ENDPOINT = "spicedb:50051"; + envMock.AUTHZED_INSECURE = "true"; + envMock.AUTHZED_SYSTEM_KEY = "formbricks"; + envMock.AUTHZED_TOKEN = "private-token"; + sdkMocks.newClient.mockReturnValue({ + close: sdkMocks.close, + promises: { + checkPermission: sdkMocks.checkPermission, + deleteRelationships: sdkMocks.deleteRelationships, + diffSchema: sdkMocks.diffSchema, + lookupResources: sdkMocks.lookupResources, + readRelationships: sdkMocks.readRelationships, + readSchema: sdkMocks.readSchema, + writeRelationships: sdkMocks.writeRelationships, + writeSchema: sdkMocks.writeSchema, + }, + }); + configMocks.isAuthzedEnabled.mockReturnValue(true); + }); + + test("does not construct an SDK client until the facade is requested", () => { + expect(sdkMocks.newClient).not.toHaveBeenCalled(); + }); + + test("throws a typed disabled error without constructing the SDK client", () => { + configMocks.isAuthzedEnabled.mockReturnValue(false); + + expect(() => getAuthzedClient()).toThrow(AuthzedError); + expect(() => getAuthzedClient()).toThrow(AUTHZED_ERROR_CODES.DISABLED); + expect(sdkMocks.newClient).not.toHaveBeenCalled(); + }); + + test.each([ + [undefined, 0], + ["false", 0], + ["0", 0], + ["true", 2], + ["1", 2], + ] as const)("selects the expected SDK security mode for %s", (insecure, expectedSecurity) => { + envMock.AUTHZED_INSECURE = insecure; + + getAuthzedClient(); + + expect(sdkMocks.newClient).toHaveBeenCalledWith( + envMock.AUTHZED_TOKEN, + envMock.AUTHZED_ENDPOINT, + expectedSecurity, + undefined, + { interceptors: [{ timeoutMs: 1_000 }] } + ); + expect(sdkMocks.deadlineInterceptor).toHaveBeenCalledWith(1_000); + }); + + test("exposes authoritative consistency and preserves the configured token only inside the SDK", () => { + envMock.AUTHZED_TOKEN = " token-with-significant-spacing "; + + const client = getAuthzedClient(); + + expect(client.consistency).toBe("fully_consistent"); + expect(client.systemKey).toBe("formbricks"); + expect(sdkMocks.newClient).toHaveBeenCalledWith( + " token-with-significant-spacing ", + "spicedb:50051", + 2, + undefined, + { interceptors: [{ timeoutMs: 1_000 }] } + ); + expect(client).not.toHaveProperty("token"); + }); + + test("does not let migration consistency weaken authoritative checks", () => { + envMock.AUTHZED_CONSISTENCY = "minimize_latency"; + expect(getAuthzedClient().consistency).toBe("fully_consistent"); + }); + + test("reuses the facade singleton without retaining public SDK or credential fields", () => { + const first = getAuthzedClient(); + const second = getAuthzedClient(); + + expect(first).toBe(second); + expect(sdkMocks.newClient).toHaveBeenCalledTimes(1); + expect(Object.keys(first).sort()).toEqual([ + "checkPermission", + "consistency", + "deleteRelationships", + "diffSchema", + "lookupResources", + "readRelationships", + "readSchema", + "systemKey", + "writeRelationships", + "writeSchema", + ]); + expect(first).not.toHaveProperty("token"); + expect(first).not.toHaveProperty("promises"); + expect(first).not.toHaveProperty("close"); + }); + + test("gives a bulk-configured process the long deadline on every call it makes", () => { + // The deadline belongs to the channel, and every projector reaches the channel through + // `getAuthzedClient()` rather than being handed one — so a command that both sweeps and writes gets + // the bulk deadline on its writes too. That is the intent: the alternative is a sweep that dies on + // its first slow page. + configureAuthzedClientForBulkWork(); + + getAuthzedClient(); + + expect(sdkMocks.deadlineInterceptor).toHaveBeenCalledWith(30_000); + expect(sdkMocks.newClient).toHaveBeenCalledWith( + envMock.AUTHZED_TOKEN, + envMock.AUTHZED_ENDPOINT, + 2, + undefined, + { interceptors: [{ timeoutMs: 30_000 }] } + ); + }); + + test("refuses to widen the deadline once a client exists, rather than silently leaving it short", () => { + getAuthzedClient(); + + expect(() => configureAuthzedClientForBulkWork()).toThrow(AuthzedError); + expect(() => configureAuthzedClientForBulkWork()).toThrow(AUTHZED_ERROR_CODES.FAILED_PRECONDITION); + }); + + test("forgets the bulk deadline on close, so it cannot leak into a later client", () => { + configureAuthzedClientForBulkWork(); + getAuthzedClient(); + + closeAuthzedClient(); + getAuthzedClient(); + + expect(sdkMocks.deadlineInterceptor).toHaveBeenLastCalledWith(1_000); + }); + + test("closes, resets, and reconstructs the internal client", () => { + const first = getAuthzedClient(); + + closeAuthzedClient(); + const second = getAuthzedClient(); + + expect(sdkMocks.close).toHaveBeenCalledTimes(1); + expect(sdkMocks.newClient).toHaveBeenCalledTimes(2); + expect(second).not.toBe(first); + }); + + test("returns only the Formbricks schema wrapper through the resilience pipeline", async () => { + sdkMocks.readSchema.mockResolvedValue({ + readAt: { token: "revision" }, + schemaText: "definition user {}", + }); + + await expect(getAuthzedClient().readSchema()).resolves.toEqual({ + schemaText: "definition user {}", + }); + expect(sdkMocks.readSchema).toHaveBeenCalledWith({}); + expect(retryMocks.execute).toHaveBeenCalledWith("read_schema", expect.any(Function)); + }); + + test("checks permission with fully-consistent authority and returns only the decision", async () => { + sdkMocks.checkPermission.mockResolvedValue({ + checkedAt: { token: "private-revision" }, + permissionship: v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, + }); + + await expect( + getAuthzedClient().checkPermission({ + permission: "read", + resource: { objectId: "workspace-1", objectType: "workspace" }, + subject: { objectId: "user-1", objectType: "user" }, + }) + ).resolves.toEqual({ allowed: true }); + + expect(sdkMocks.checkPermission).toHaveBeenCalledWith({ + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + context: undefined, + permission: "read", + resource: { objectId: "workspace-1", objectType: "workspace" }, + subject: { + object: { objectId: "user-1", objectType: "user" }, + optionalRelation: "", + }, + withTracing: false, + }); + expect(retryMocks.execute).toHaveBeenCalledWith("check_permission", expect.any(Function)); + }); + + test("looks up resources with permission-check consistency and returns sorted unique IDs", async () => { + sdkMocks.lookupResources.mockResolvedValue([ + { + afterResultCursor: { token: "private-cursor" }, + lookedUpAt: { token: "private-revision" }, + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: "workspace-2", + }, + { + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: "workspace-1", + }, + { + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: "workspace-2", + }, + ]); + + await expect( + getAuthzedClient().lookupResources({ + permission: "read", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }) + ).resolves.toEqual({ resourceIds: ["workspace-1", "workspace-2"] }); + + expect(sdkMocks.lookupResources).toHaveBeenCalledWith({ + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + context: undefined, + optionalCursor: undefined, + optionalLimit: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE, + permission: "read", + resourceObjectType: "workspace", + subject: { + object: { objectId: "user-1", objectType: "user" }, + optionalRelation: "", + }, + }); + expect(retryMocks.execute).toHaveBeenCalledWith("lookup_resources", expect.any(Function)); + }); + + test("pages resource lookups with a bounded stream allocation and an advancing cursor", async () => { + const firstPage = Array.from({ length: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE }, (_unused, index) => ({ + afterResultCursor: + index === AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE - 1 ? { token: "lookup-cursor-1" } : undefined, + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: `workspace-${index}`, + })); + sdkMocks.lookupResources.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([ + { + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: "workspace-final", + }, + ]); + + const result = await getAuthzedClient().lookupResources({ + permission: "read", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }); + + expect(result.resourceIds).toHaveLength(AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE + 1); + expect(sdkMocks.lookupResources).toHaveBeenCalledTimes(2); + expect(sdkMocks.lookupResources.mock.calls[0][0]).toEqual( + expect.objectContaining({ + optionalCursor: undefined, + optionalLimit: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE, + }) + ); + expect(sdkMocks.lookupResources.mock.calls[1][0]).toEqual( + expect.objectContaining({ + optionalCursor: { token: "lookup-cursor-1" }, + optionalLimit: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE, + }) + ); + expect(retryMocks.execute).toHaveBeenCalledTimes(2); + }); + + test("fails a resource lookup whose cursor does not advance", async () => { + const fullPage = Array.from({ length: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE }, (_unused, index) => ({ + afterResultCursor: + index === AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE - 1 ? { token: "stalled-cursor" } : undefined, + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: `workspace-${index}`, + })); + sdkMocks.lookupResources.mockResolvedValue(fullPage); + + await expect( + getAuthzedClient().lookupResources({ + permission: "read", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }) + ).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "lookup_resources", + retryable: false, + }); + expect(sdkMocks.lookupResources).toHaveBeenCalledTimes(2); + }); + + test("fails instead of returning a truncated lookup beyond the accumulation bound", async () => { + let page = 0; + sdkMocks.lookupResources.mockImplementation(() => { + page += 1; + return Promise.resolve( + Array.from({ length: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE }, (_unused, index) => ({ + afterResultCursor: + index === AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE - 1 ? { token: `cursor-${page}` } : undefined, + permissionship: v1.LookupPermissionship.HAS_PERMISSION, + resourceObjectId: `workspace-${page}-${index}`, + })) + ); + }); + + await expect( + getAuthzedClient().lookupResources({ + permission: "read", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }) + ).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.LIMIT_EXCEEDED, + operation: "lookup_resources", + retryable: false, + }); + expect(sdkMocks.lookupResources).toHaveBeenCalledTimes( + Math.floor(AUTHZED_MAX_RESOURCE_LOOKUP_RESULTS / AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE) + 1 + ); + }); + + test.each([v1.LookupPermissionship.CONDITIONAL_PERMISSION, v1.LookupPermissionship.UNSPECIFIED])( + "rejects unsupported lookup permissionship %s", + async (permissionship) => { + sdkMocks.lookupResources.mockResolvedValue([{ permissionship, resourceObjectId: "workspace-1" }]); + + await expect( + getAuthzedClient().lookupResources({ + permission: "read", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }) + ).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.UNSUPPORTED, + operation: "lookup_resources", + retryable: false, + }); + } + ); + + test("rejects malformed lookup requests before constructing response data", async () => { + await expect( + getAuthzedClient().lookupResources({ + permission: "", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }) + ).rejects.toMatchObject({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "lookup_resources", + }); + expect(sdkMocks.lookupResources).not.toHaveBeenCalled(); + }); + + test("does not let migration consistency weaken an authoritative permission check", async () => { + sdkMocks.checkPermission.mockResolvedValue({ + permissionship: v1.CheckPermissionResponse_Permissionship.NO_PERMISSION, + }); + + await expect( + getAuthzedClient().checkPermission({ + permission: "write", + resource: { objectId: "workspace-1", objectType: "workspace" }, + subject: { objectId: "key-1", objectType: "api_key" }, + }) + ).resolves.toEqual({ allowed: false }); + + expect(sdkMocks.checkPermission).toHaveBeenCalledWith( + expect.objectContaining({ + consistency: { + requirement: { + fullyConsistent: true, + oneofKind: "fullyConsistent", + }, + }, + }) + ); + }); + + test("uses fully-consistent permission checks when configured for enforcement", async () => { + envMock.AUTHZED_CONSISTENCY = "fully_consistent"; + sdkMocks.checkPermission.mockResolvedValue({ + permissionship: v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION, + }); + + await getAuthzedClient().checkPermission({ + permission: "read", + resource: { objectId: "organization-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, + }); + + expect(sdkMocks.checkPermission).toHaveBeenCalledWith( + expect.objectContaining({ + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + }) + ); + }); + + test.each(["minimize_latency", "fully_consistent"] as const)( + "uses fully-consistent resource lookup when configured as %s", + async (consistency) => { + envMock.AUTHZED_CONSISTENCY = consistency; + sdkMocks.lookupResources.mockResolvedValue([]); + + await getAuthzedClient().lookupResources({ + permission: "read", + resourceType: "workspace", + subject: { objectId: "user-1", objectType: "user" }, + }); + + expect(sdkMocks.lookupResources).toHaveBeenCalledWith( + expect.objectContaining({ + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + }) + ); + } + ); + + test.each([ + v1.CheckPermissionResponse_Permissionship.CONDITIONAL_PERMISSION, + v1.CheckPermissionResponse_Permissionship.UNSPECIFIED, + ])("rejects unsupported permission result %s", async (permissionship) => { + sdkMocks.checkPermission.mockResolvedValue({ permissionship }); + + await expect( + getAuthzedClient().checkPermission({ + permission: "read", + resource: { objectId: "workspace-1", objectType: "workspace" }, + subject: { objectId: "user-1", objectType: "user" }, + }) + ).rejects.toMatchObject({ code: AUTHZED_ERROR_CODES.UNSUPPORTED, retryable: false }); + }); + + test("normalizes SpiceDB's uninitialized-schema response to an empty successful schema", async () => { + sdkMocks.readSchema.mockRejectedValue({ code: status.NOT_FOUND }); + + await expect(getAuthzedClient().readSchema()).resolves.toEqual({ schemaText: "" }); + expect(retryMocks.execute).toHaveBeenCalledWith("read_schema", expect.any(Function)); + }); + + test("returns only aggregate schema differences without SDK details", async () => { + sdkMocks.diffSchema.mockResolvedValue({ + diffs: [ + { diff: { definitionAdded: { name: "private_definition" }, oneofKind: "definitionAdded" } }, + { diff: { definitionAdded: { name: "another_private_definition" }, oneofKind: "definitionAdded" } }, + { + diff: { + oneofKind: "permissionExprChanged", + permissionExprChanged: { name: "private_permission" }, + }, + }, + { diff: { oneofKind: undefined } }, + ], + readAt: { token: "private-revision" }, + }); + + await expect(getAuthzedClient().diffSchema("definition user {}")).resolves.toEqual({ + differenceCount: 4, + differenceKinds: { + definition_added: 2, + permission_expr_changed: 1, + unknown: 1, + }, + }); + expect(sdkMocks.diffSchema).toHaveBeenCalledWith({ + comparisonSchema: "definition user {}", + consistency: { + requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" }, + }, + }); + expect(retryMocks.execute).toHaveBeenCalledWith("diff_schema", expect.any(Function)); + }); + + test("writes the supplied schema through an explicitly retried schema operation", async () => { + sdkMocks.writeSchema.mockResolvedValue({ writtenAt: { token: "private-revision" } }); + + await expect(getAuthzedClient().writeSchema("definition user {}")).resolves.toBeUndefined(); + + expect(sdkMocks.writeSchema).toHaveBeenCalledWith({ schema: "definition user {}" }); + expect(retryMocks.execute).toHaveBeenCalledWith("write_schema", expect.any(Function)); + }); + + test("translates Formbricks relationship updates without exposing SDK responses", async () => { + sdkMocks.writeRelationships.mockResolvedValue({ writtenAt: { token: "private-revision" } }); + + await expect( + getAuthzedClient().writeRelationships([ + { + operation: "touch", + relationship: { + relation: "owner", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, + }, + }, + { + operation: "delete", + relationship: { + relation: "manager", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user", relation: "member" }, + }, + }, + ]) + ).resolves.toBeUndefined(); + + expect(sdkMocks.writeRelationships).toHaveBeenCalledWith({ + optionalPreconditions: [], + updates: [ + { + operation: 2, + relationship: { + optionalCaveat: undefined, + optionalExpiresAt: undefined, + relation: "owner", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { + object: { objectId: "user-1", objectType: "user" }, + optionalRelation: "", + }, + }, + }, + { + operation: 3, + relationship: { + optionalCaveat: undefined, + optionalExpiresAt: undefined, + relation: "manager", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { + object: { objectId: "user-1", objectType: "user" }, + optionalRelation: "member", + }, + }, + }, + ], + }); + expect(retryMocks.execute).toHaveBeenCalledWith("write_relationships", expect.any(Function)); + }); + + test.each([0, 1_001])( + "rejects a relationship batch with %i updates before an SDK request", + async (batchSize) => { + const updates = Array.from({ length: batchSize }, () => ({ + operation: "touch" as const, + relationship: { + relation: "owner", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, + }, + })); + + await expect(getAuthzedClient().writeRelationships(updates)).rejects.toMatchObject({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + }); + expect(sdkMocks.writeRelationships).not.toHaveBeenCalled(); + } + ); + + test("requires a narrowed relationship delete filter", async () => { + await expect( + getAuthzedClient().deleteRelationships({ + resourceId: "", + resourceType: "organization", + }) + ).rejects.toMatchObject({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + }); + expect(sdkMocks.deleteRelationships).not.toHaveBeenCalled(); + }); + + test("translates a resource-scoped bulk delete through the resilience pipeline", async () => { + sdkMocks.deleteRelationships.mockResolvedValue({ + deletedAt: { token: "private-revision" }, + deletionProgress: v1.DeleteRelationshipsResponse_DeletionProgress.COMPLETE, + }); + + await expect( + getAuthzedClient().deleteRelationships({ + resourceId: "org-1", + resourceType: "organization", + }) + ).resolves.toBeUndefined(); + + expect(sdkMocks.deleteRelationships).toHaveBeenCalledWith({ + optionalAllowPartialDeletions: false, + optionalLimit: 0, + optionalPreconditions: [], + relationshipFilter: { + optionalRelation: "", + optionalResourceId: "org-1", + optionalResourceIdPrefix: "", + optionalSubjectFilter: undefined, + resourceType: "organization", + }, + }); + expect(retryMocks.execute).toHaveBeenCalledWith("delete_relationships", expect.any(Function)); + }); + + test("refuses to report a partial deletion as a success", async () => { + // The one facade call that destroys access. An unlimited, non-partial delete should always come + // back COMPLETE, so PARTIAL means a server-side cap or a changed default is quietly leaving + // relationships behind — and reporting that as done would leave a half-revoked graph. + sdkMocks.deleteRelationships.mockResolvedValue({ + deletedAt: { token: "private-revision" }, + deletionProgress: v1.DeleteRelationshipsResponse_DeletionProgress.PARTIAL, + }); + + await expect( + getAuthzedClient().deleteRelationships({ resourceId: "org-1", resourceType: "organization" }) + ).rejects.toMatchObject({ code: AUTHZED_ERROR_CODES.INTERNAL, retryable: true }); + }); + + test("translates a subject-scoped bulk delete without broadening the resource filter", async () => { + sdkMocks.deleteRelationships.mockResolvedValue({ + deletedAt: { token: "private-revision" }, + deletionProgress: v1.DeleteRelationshipsResponse_DeletionProgress.COMPLETE, + }); + + await expect( + getAuthzedClient().deleteRelationships({ + resourceType: "organization", + subject: { objectId: "user-1", objectType: "user" }, + }) + ).resolves.toBeUndefined(); + + expect(sdkMocks.deleteRelationships).toHaveBeenCalledWith({ + optionalAllowPartialDeletions: false, + optionalLimit: 0, + optionalPreconditions: [], + relationshipFilter: { + optionalRelation: "", + optionalResourceId: "", + optionalResourceIdPrefix: "", + optionalSubjectFilter: { + optionalRelation: undefined, + optionalSubjectId: "user-1", + subjectType: "user", + }, + resourceType: "organization", + }, + }); + }); + + describe("readRelationships", () => { + const readResponse = ( + relationship: Record, + overrides: Record = {} + ) => ({ + afterResultCursor: { token: "private-cursor" }, + readAt: { token: "private-revision" }, + relationship, + ...overrides, + }); + + const membershipRelationship = { + relation: "owner", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { object: { objectId: "user-1", objectType: "user" }, optionalRelation: "" }, + }; + + test("resolves the first page fully consistently and returns a pinnable revision", async () => { + sdkMocks.readRelationships.mockResolvedValue([readResponse(membershipRelationship)]); + + await expect( + getAuthzedClient().readRelationships({ + filter: { resourceType: "organization" }, + limit: 250, + }) + ).resolves.toEqual({ + // A short page exhausts the filter, so no cursor is offered even though SpiceDB sent one. + cursor: null, + relationships: [ + { + relation: "owner", + resource: { objectId: "org-1", objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, + }, + ], + snapshot: { token: "private-revision" }, + }); + + expect(sdkMocks.readRelationships).toHaveBeenCalledWith({ + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + optionalCursor: undefined, + optionalLimit: 250, + relationshipFilter: { + optionalRelation: "", + optionalResourceId: "", + optionalResourceIdPrefix: "", + optionalSubjectFilter: undefined, + resourceType: "organization", + }, + }); + expect(retryMocks.execute).toHaveBeenCalledWith("read_relationships", expect.any(Function)); + }); + + test("continues a cursored read without altering any other argument", async () => { + sdkMocks.readRelationships.mockResolvedValue([ + readResponse(membershipRelationship, { readAt: { token: "revision-1" } }), + ]); + + const page = await getAuthzedClient().readRelationships({ + cursor: { token: "resume-here" }, + filter: { resourceType: "organization" }, + limit: 250, + }); + + // SpiceDB rejects a cursor presented with any other changed argument, and the cursor already + // carries the revision it was issued at — so the consistency requirement must stay put rather + // than being swapped for an explicit snapshot on later pages. + expect(page.snapshot).toEqual({ token: "revision-1" }); + expect(sdkMocks.readRelationships).toHaveBeenCalledWith( + expect.objectContaining({ + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + optionalCursor: { token: "resume-here" }, + }) + ); + }); + + test("offers a resume cursor only when the page is full", async () => { + sdkMocks.readRelationships.mockResolvedValue([ + readResponse(membershipRelationship), + readResponse(membershipRelationship), + ]); + + await expect( + getAuthzedClient().readRelationships({ + filter: { resourceType: "organization" }, + limit: 2, + }) + ).resolves.toMatchObject({ cursor: { token: "private-cursor" } }); + }); + + test("returns an exhausted empty page without a revision", async () => { + sdkMocks.readRelationships.mockResolvedValue([]); + + await expect( + getAuthzedClient().readRelationships({ + filter: { resourceType: "organization" }, + limit: 250, + }) + ).resolves.toEqual({ cursor: null, relationships: [], snapshot: null }); + }); + + test("preserves a subject relation so team-member grants round-trip", async () => { + sdkMocks.readRelationships.mockResolvedValue([ + readResponse({ + relation: "reader_team", + resource: { objectId: "ws-1", objectType: "workspace" }, + subject: { object: { objectId: "team-1", objectType: "team" }, optionalRelation: "member" }, + }), + ]); + + const page = await getAuthzedClient().readRelationships({ + filter: { resourceType: "workspace", subject: { objectId: "team-1", objectType: "team" } }, + limit: 250, + }); + + expect(page.relationships[0].subject).toEqual({ + objectId: "team-1", + objectType: "team", + relation: "member", + }); + }); + + test("narrows the SDK filter for every supported field", async () => { + sdkMocks.readRelationships.mockResolvedValue([]); + + await getAuthzedClient().readRelationships({ + filter: { + relation: "reader_team", + resourceId: "ws-1", + resourceType: "workspace", + subject: { objectId: "team-1", objectType: "team", relation: "member" }, + }, + limit: 10, + }); + + expect(sdkMocks.readRelationships).toHaveBeenCalledWith( + expect.objectContaining({ + relationshipFilter: { + optionalRelation: "reader_team", + optionalResourceId: "ws-1", + optionalResourceIdPrefix: "", + optionalSubjectFilter: { + optionalRelation: { relation: "member" }, + optionalSubjectId: "team-1", + subjectType: "team", + }, + resourceType: "workspace", + }, + }) + ); + }); + + test.each([ + ["a missing resource type", { filter: { resourceType: "" }, limit: 10 }], + // SpiceDB reads `optionalLimit: 0` as unlimited, which would breach the channel deadline and + // buffer without bound, so an unset or non-positive limit must never reach the wire. + ["an unlimited read", { filter: { resourceType: "organization" }, limit: 0 }], + ["a negative limit", { filter: { resourceType: "organization" }, limit: -1 }], + ["a fractional limit", { filter: { resourceType: "organization" }, limit: 1.5 }], + ["a limit above the page bound", { filter: { resourceType: "organization" }, limit: 251 }], + ["a blank relation", { filter: { relation: "", resourceType: "organization" }, limit: 10 }], + ["a blank resource id", { filter: { resourceId: "", resourceType: "organization" }, limit: 10 }], + [ + "an incomplete subject filter", + { + filter: { resourceType: "workspace", subject: { objectId: "", objectType: "team" } }, + limit: 10, + }, + ], + ["a blank cursor", { cursor: { token: "" }, filter: { resourceType: "organization" }, limit: 10 }], + ])("rejects %s before reaching the SDK", async (_label, query) => { + await expect(getAuthzedClient().readRelationships(query)).rejects.toThrow( + AUTHZED_ERROR_CODES.INVALID_REQUEST + ); + expect(sdkMocks.readRelationships).not.toHaveBeenCalled(); + }); + + test.each([ + ["relationship", {}], + ["resource", { relation: "owner", subject: membershipRelationship.subject }], + ["subject object", { relation: "owner", resource: membershipRelationship.resource, subject: {} }], + ])( + "refuses a response missing its %s rather than reporting a malformed relationship", + async (_label, relationship) => { + sdkMocks.readRelationships.mockResolvedValue([readResponse(relationship)]); + + await expect( + getAuthzedClient().readRelationships({ + filter: { resourceType: "organization" }, + limit: 250, + }) + ).rejects.toThrow(AUTHZED_ERROR_CODES.INTERNAL); + } + ); + + test("refuses a non-empty page that carries no revision to pin", async () => { + sdkMocks.readRelationships.mockResolvedValue([ + readResponse(membershipRelationship, { readAt: undefined }), + ]); + + await expect( + getAuthzedClient().readRelationships({ + filter: { resourceType: "organization" }, + limit: 250, + }) + ).rejects.toThrow(AUTHZED_ERROR_CODES.INTERNAL); + }); + + test.each(["optionalCaveat", "optionalExpiresAt"])( + "refuses a relationship qualified by %s that the facade cannot represent", + async (qualifier) => { + sdkMocks.readRelationships.mockResolvedValue([ + readResponse({ ...membershipRelationship, [qualifier]: { anything: true } }), + ]); + + await expect( + getAuthzedClient().readRelationships({ + filter: { resourceType: "organization" }, + limit: 250, + }) + ).rejects.toThrow(AUTHZED_ERROR_CODES.UNSUPPORTED); + } + ); + + test("routes reads through the resilience pipeline so transient failures are mapped there", async () => { + // Error sanitization belongs to `executeAuthzedOperation` (covered in retry.test.ts); the + // facade's own contract is that it opts this operation into that pipeline rather than calling + // the SDK bare. + sdkMocks.readRelationships.mockRejectedValue( + Object.assign(new Error("private-endpoint-detail"), { code: status.UNAVAILABLE }) + ); + + await expect( + getAuthzedClient().readRelationships({ filter: { resourceType: "organization" }, limit: 250 }) + ).rejects.toThrow(); + + expect(retryMocks.execute).toHaveBeenCalledWith("read_relationships", expect.any(Function)); + }); + }); +}); diff --git a/apps/web/lib/authzed/client.ts b/apps/web/lib/authzed/client.ts new file mode 100644 index 000000000000..b0851c1ed49b --- /dev/null +++ b/apps/web/lib/authzed/client.ts @@ -0,0 +1,803 @@ +import "server-only"; +import { deadlineInterceptor, v1 } from "@authzed/authzed-node"; +import { env } from "@/lib/env"; +import { type TAuthzedConsistency, isAuthzedEnabled } from "./config"; +import { + AUTHZED_BULK_REQUEST_TIMEOUT_MS, + AUTHZED_MAX_RELATIONSHIP_READS, + AUTHZED_MAX_RELATIONSHIP_UPDATES, + AUTHZED_MAX_RESOURCE_LOOKUP_RESULTS, + AUTHZED_REQUEST_TIMEOUT_MS, + AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE, +} from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError, mapAuthzedError } from "./errors"; +import { executeAuthzedOperation } from "./retry"; + +export type TAuthzedSchema = Readonly<{ + schemaText: string; +}>; + +export type TAuthzedSchemaDiff = Readonly<{ + differenceCount: number; + differenceKinds: Readonly>; +}>; + +export type TAuthzedObjectReference = Readonly<{ + objectId: string; + objectType: string; +}>; + +export type TAuthzedPermissionCheck = Readonly<{ + permission: string; + resource: TAuthzedObjectReference; + subject: TAuthzedObjectReference; +}>; + +export type TAuthzedPermissionDecision = Readonly<{ + allowed: boolean; +}>; + +export type TAuthzedResourceLookup = Readonly<{ + permission: string; + resourceType: string; + subject: TAuthzedObjectReference; +}>; + +export type TAuthzedResourceLookupResult = Readonly<{ + resourceIds: ReadonlyArray; +}>; + +export type TAuthzedSubjectReference = TAuthzedObjectReference & + Readonly<{ + relation?: string; + }>; + +export type TAuthzedRelationship = Readonly<{ + relation: string; + resource: TAuthzedObjectReference; + subject: TAuthzedSubjectReference; +}>; + +export type TAuthzedRelationshipUpdate = Readonly<{ + operation: "delete" | "touch"; + relationship: TAuthzedRelationship; +}>; + +type TAuthzedSubjectFilter = Readonly<{ + objectId: string; + objectType: string; + relation?: string; +}>; + +type TAuthzedRelationshipFilterBase = Readonly<{ + relation?: string; + resourceType: string; +}>; + +export type TAuthzedRelationshipFilter = + | (TAuthzedRelationshipFilterBase & + Readonly<{ + resourceId: string; + subject?: TAuthzedSubjectFilter; + }>) + | (TAuthzedRelationshipFilterBase & + Readonly<{ + resourceId?: never; + subject: TAuthzedSubjectFilter; + }>); + +/** An opaque SpiceDB revision. Formbricks-owned wrapper: the SDK's ZedToken never crosses the facade. */ +export type TAuthzedSnapshot = Readonly<{ + token: string; +}>; + +/** An opaque resume position within a relationship read. */ +export type TAuthzedReadCursor = Readonly<{ + token: string; +}>; + +/** + * Filter for reading relationships. + * + * Unlike `TAuthzedRelationshipFilter` this is a plain object rather than a union, so a + * `resourceType`-only sweep is expressible. Because `resourceId` here is `string | undefined`, this type + * satisfies neither branch of the delete filter's union, so passing a read filter to + * `deleteRelationships` is a compile error. + * + * That narrows deletes, it does not bound them: the delete filter also admits a subject-only form with + * no `resourceId`, which is how user-deletion cleanup removes one subject's relationships across every + * organization or team. Such a delete is unlimited and transactional, bounded only by how many + * relationships match — so a new call site has to reason about the match size itself. + * + * `optionalResourceIdPrefix` is deliberately not surfaced: Formbricks object IDs are unprefixed + * cuids, so it could never narrow anything, and unused surface on a frozen facade is a liability. + */ +export type TAuthzedRelationshipReadFilter = Readonly<{ + relation?: string; + resourceId?: string; + resourceType: string; + subject?: TAuthzedSubjectFilter; +}>; + +export type TAuthzedRelationshipQuery = Readonly<{ + /** + * Resume position from a previous page. + * + * The cursor carries the revision it was issued at, so continuing with one keeps the whole read on a + * single consistent view, and SpiceDB rejects a cursor presented alongside *any* other changed + * argument — including a changed consistency requirement. + * + * Both behaviours are verified against SpiceDB v1.52 (`pkg/middleware/consistency` prefers the + * cursor's revision over the stated requirement; `internal/services/v1/hash.go` hashes the + * consistency, filter and limit into the cursor) and against a real engine in the compose smoke test. + * Neither is part of the published API contract, so a server upgrade should re-verify them — the + * revision-stability check in `readAllRelationships` is the guard if they ever change. + */ + cursor?: TAuthzedReadCursor; + filter: TAuthzedRelationshipReadFilter; + limit: number; +}>; + +export type TAuthzedRelationshipPage = Readonly<{ + /** Resume position, or `null` when the page was short and the read is exhausted. */ + cursor: TAuthzedReadCursor | null; + relationships: ReadonlyArray; + /** Revision this page was read at. Constant across the pages of one cursored read. */ + snapshot: TAuthzedSnapshot | null; +}>; + +export type TAuthzedClient = Readonly<{ + checkPermission: (check: TAuthzedPermissionCheck) => Promise; + consistency: TAuthzedConsistency; + deleteRelationships: (filter: TAuthzedRelationshipFilter) => Promise; + diffSchema: (schemaText: string) => Promise; + /** + * Read one page of raw relationships. + * + * **Operational use only — never for permission logic.** AuthZed's guidance is explicit that + * checks and ID listing must go through `Check`, `CheckBulk`, `LookupResources`, and + * `LookupSubjects`; reading raw relationships to decide access reimplements the permission graph + * in application code and silently diverges from the schema. This exists so operational tooling + * can observe what SpiceDB actually holds and reconcile it against PostgreSQL. It is deliberately + * not re-exported from `./index`. + */ + readRelationships: (query: TAuthzedRelationshipQuery) => Promise; + readSchema: () => Promise; + systemKey: string; + writeRelationships: (updates: ReadonlyArray) => Promise; + writeSchema: (schemaText: string) => Promise; +}>; + +/** Direct-path authorization infrastructure only; intentionally absent from the public barrel. */ +export type TAuthzedResourceLookupClient = TAuthzedClient & + Readonly<{ + lookupResources: (lookup: TAuthzedResourceLookup) => Promise; + }>; + +type TAuthzedClientSingleton = Readonly<{ + close: () => void; + facade: TAuthzedResourceLookupClient; +}>; + +type TAuthzedConfig = + | Readonly<{ + enabled: false; + insecure: boolean; + }> + | Readonly<{ + enabled: true; + endpoint: string; + insecure: boolean; + systemKey: string; + token: string; + }>; + +const globalForAuthzed = globalThis as unknown as { + formbricksAuthzedClient: TAuthzedClientSingleton | undefined; + formbricksAuthzedRequestTimeoutMs: number | undefined; +}; + +const STABLE_SCHEMA_DIFF_KINDS = { + caveatAdded: "caveat_added", + caveatDocCommentChanged: "caveat_doc_comment_changed", + caveatExprChanged: "caveat_expr_changed", + caveatParameterAdded: "caveat_parameter_added", + caveatParameterRemoved: "caveat_parameter_removed", + caveatParameterTypeChanged: "caveat_parameter_type_changed", + caveatRemoved: "caveat_removed", + definitionAdded: "definition_added", + definitionDocCommentChanged: "definition_doc_comment_changed", + definitionRemoved: "definition_removed", + permissionAdded: "permission_added", + permissionDocCommentChanged: "permission_doc_comment_changed", + permissionExprChanged: "permission_expr_changed", + permissionRemoved: "permission_removed", + relationAdded: "relation_added", + relationDocCommentChanged: "relation_doc_comment_changed", + relationRemoved: "relation_removed", + relationSubjectTypeAdded: "relation_subject_type_added", + relationSubjectTypeRemoved: "relation_subject_type_removed", +} as const; + +const toStableDiffKind = (kind: string | undefined): string => { + if (!kind || !Object.hasOwn(STABLE_SCHEMA_DIFF_KINDS, kind)) { + return "unknown"; + } + + return STABLE_SCHEMA_DIFF_KINDS[kind as keyof typeof STABLE_SCHEMA_DIFF_KINDS]; +}; + +const getAuthzedConfig = (): TAuthzedConfig => { + const insecure = env.AUTHZED_INSECURE === "true" || env.AUTHZED_INSECURE === "1"; + + if (!isAuthzedEnabled()) { + return { enabled: false, insecure }; + } + + const { AUTHZED_ENDPOINT: endpoint, AUTHZED_SYSTEM_KEY: systemKey, AUTHZED_TOKEN: token } = env; + + if (!endpoint || !systemKey || !token) { + throw new Error("Enabled AuthZed configuration was not validated"); + } + + return { + enabled: true, + endpoint, + insecure, + systemKey, + token, + }; +}; + +const isNonEmpty = (value: string): boolean => value.length > 0; + +const validatePermissionCheck = (check: TAuthzedPermissionCheck): void => { + if ( + !isNonEmpty(check.permission) || + !isNonEmpty(check.resource.objectId) || + !isNonEmpty(check.resource.objectType) || + !isNonEmpty(check.subject.objectId) || + !isNonEmpty(check.subject.objectType) + ) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "check_permission", + retryable: false, + }); + } +}; + +const validateResourceLookup = (lookup: TAuthzedResourceLookup): void => { + if ( + !isNonEmpty(lookup.permission) || + !isNonEmpty(lookup.resourceType) || + !isNonEmpty(lookup.subject.objectId) || + !isNonEmpty(lookup.subject.objectType) + ) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "lookup_resources", + retryable: false, + }); + } +}; + +const getAuthorizationConsistency = () => ({ + requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" as const }, +}); + +const validateRelationshipUpdates = (updates: ReadonlyArray): void => { + if (updates.length === 0 || updates.length > AUTHZED_MAX_RELATIONSHIP_UPDATES) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "write_relationships", + retryable: false, + }); + } + + const valid = updates.every( + ({ relationship }) => + isNonEmpty(relationship.resource.objectType) && + isNonEmpty(relationship.resource.objectId) && + isNonEmpty(relationship.relation) && + isNonEmpty(relationship.subject.objectType) && + isNonEmpty(relationship.subject.objectId) && + (relationship.subject.relation === undefined || isNonEmpty(relationship.subject.relation)) + ); + + if (!valid) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "write_relationships", + retryable: false, + }); + } +}; + +const validateRelationshipFilter = (filter: TAuthzedRelationshipFilter): void => { + const hasResourceId = filter.resourceId !== undefined && isNonEmpty(filter.resourceId); + const hasSubjectId = filter.subject !== undefined && isNonEmpty(filter.subject.objectId); + const valid = + isNonEmpty(filter.resourceType) && + (filter.relation === undefined || isNonEmpty(filter.relation)) && + (filter.subject === undefined || + (isNonEmpty(filter.subject.objectType) && + (filter.subject.relation === undefined || isNonEmpty(filter.subject.relation)))); + + if (!valid || (!hasResourceId && !hasSubjectId)) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "delete_relationships", + retryable: false, + }); + } +}; + +const invalidReadRequest = (): AuthzedError => + new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INVALID_REQUEST, + operation: "read_relationships", + retryable: false, + }); + +const validateRelationshipQuery = (query: TAuthzedRelationshipQuery): void => { + const { filter } = query; + const optionalFieldsValid = + (filter.relation === undefined || isNonEmpty(filter.relation)) && + (filter.resourceId === undefined || isNonEmpty(filter.resourceId)) && + (filter.subject === undefined || + (isNonEmpty(filter.subject.objectType) && + isNonEmpty(filter.subject.objectId) && + (filter.subject.relation === undefined || isNonEmpty(filter.subject.relation)))); + + // A limit is mandatory and must be positive: SpiceDB treats `optionalLimit: 0` as *unlimited*, + // which under the channel-wide deadline is a guaranteed timeout and an unbounded allocation, + // because the promisified streaming call buffers every message before it resolves. + const limitValid = + Number.isSafeInteger(query.limit) && query.limit >= 1 && query.limit <= AUTHZED_MAX_RELATIONSHIP_READS; + + const tokensValid = query.cursor === undefined || isNonEmpty(query.cursor.token); + + if (!isNonEmpty(filter.resourceType) || !optionalFieldsValid || !limitValid || !tokensValid) { + throw invalidReadRequest(); + } +}; + +/** + * Convert one streamed response into a facade relationship. + * + * Strict by design. Every field below is optional in the generated SDK types, and a missing one + * would yield a relationship that compares unequal to the tuple Formbricks wrote — which reconciling + * tooling would classify as orphaned and delete. Failing loudly is the only safe reading. + */ +const toFacadeRelationship = (response: v1.ReadRelationshipsResponse): TAuthzedRelationship => { + const relationship = response.relationship; + const resource = relationship?.resource; + const subject = relationship?.subject?.object; + + // The message fields are optional in the generated types; the scalars inside them are plain protobuf + // strings that default to `""` when absent from the wire. Both have to be rejected, or a relationship + // with an empty relation or object ID passes here, matches no tuple Formbricks ever wrote, and gets + // classified as orphaned — which under `--prune` means deleted. Same fail-loud rule, same reason. + if ( + !relationship || + !resource || + !subject || + !relationship.relation || + !resource.objectId || + !resource.objectType || + !subject.objectId || + !subject.objectType + ) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "read_relationships", + retryable: false, + }); + } + + // Formbricks never writes caveated or expiring relationships and the facade cannot represent them, so + // dropping the qualifier would misreport the tuple — and a misreported tuple is exactly what + // reconciling tooling would classify as stale. Refusing is therefore the safe reading today. + // + // Note this becomes a tripwire the day Formbricks adopts SpiceDB's expiration feature, which AuthZed + // recommends for time-limited access: the facade must learn to represent it before anything writes + // one, or reconciliation will start refusing to run. + if (relationship.optionalCaveat !== undefined || relationship.optionalExpiresAt !== undefined) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.UNSUPPORTED, + operation: "read_relationships", + retryable: false, + }); + } + + const subjectRelation = relationship.subject?.optionalRelation; + + return { + relation: relationship.relation, + resource: { objectId: resource.objectId, objectType: resource.objectType }, + subject: { + objectId: subject.objectId, + objectType: subject.objectType, + // Normalize the wire's empty string back to `undefined` so a subject-relation tuple such as + // `workspace:x#reader_team@team:y#member` round-trips equal to the update that wrote it. + ...(subjectRelation ? { relation: subjectRelation } : {}), + }, + }; +}; + +const createAuthzedClient = (requestTimeoutMs: number): TAuthzedClientSingleton => { + const config = getAuthzedConfig(); + + if (!config.enabled) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.DISABLED, + operation: "client_initialization", + retryable: false, + }); + } + + const security = config.insecure + ? v1.ClientSecurity.INSECURE_PLAINTEXT_CREDENTIALS + : v1.ClientSecurity.SECURE; + // The SDK appends its own 30s deadline interceptor last, and that interceptor only sets a deadline + // when none is present — so whatever is installed here wins for every call on this channel. + const sdkClient = v1.NewClient(config.token, config.endpoint, security, undefined, { + interceptors: [deadlineInterceptor(requestTimeoutMs)], + }); + + const facade = Object.freeze({ + checkPermission: async (check) => { + validatePermissionCheck(check); + + return executeAuthzedOperation("check_permission", async () => { + const response = await sdkClient.promises.checkPermission({ + consistency: getAuthorizationConsistency(), + context: undefined, + permission: check.permission, + resource: { + objectId: check.resource.objectId, + objectType: check.resource.objectType, + }, + subject: { + object: { + objectId: check.subject.objectId, + objectType: check.subject.objectType, + }, + optionalRelation: "", + }, + withTracing: false, + }); + + if (response.permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION) { + return { allowed: true }; + } + + if (response.permissionship === v1.CheckPermissionResponse_Permissionship.NO_PERMISSION) { + return { allowed: false }; + } + + throw new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.UNSUPPORTED, + operation: "check_permission", + retryable: false, + }); + }); + }, + consistency: "fully_consistent", + deleteRelationships: async (filter) => { + validateRelationshipFilter(filter); + + await executeAuthzedOperation("delete_relationships", async () => { + const response = await sdkClient.promises.deleteRelationships({ + optionalAllowPartialDeletions: false, + optionalLimit: 0, + optionalPreconditions: [], + relationshipFilter: { + optionalRelation: filter.relation ?? "", + optionalResourceId: filter.resourceId ?? "", + optionalResourceIdPrefix: "", + optionalSubjectFilter: filter.subject + ? { + optionalRelation: filter.subject.relation + ? { relation: filter.subject.relation } + : undefined, + optionalSubjectId: filter.subject.objectId, + subjectType: filter.subject.objectType, + } + : undefined, + resourceType: filter.resourceType, + }, + }); + + // Asserted rather than assumed. An unlimited, non-partial delete should always report + // `COMPLETE`, but this is the one call in the facade that destroys access, and "SpiceDB said it + // only got part way and we carried on believing it finished" is the failure that leaves a + // half-revoked graph while the caller reports success. A server-side cap or a change in SDK + // defaults would surface here instead of silently. + if (response.deletionProgress !== v1.DeleteRelationshipsResponse_DeletionProgress.COMPLETE) { + throw new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "delete_relationships", + retryable: true, + }); + } + }); + }, + diffSchema: async (schemaText) => + executeAuthzedOperation("diff_schema", async () => { + const response = await sdkClient.promises.diffSchema({ + comparisonSchema: schemaText, + // Operational schema checks must observe the latest write. The application's configurable + // permission-check consistency is intentionally not used for deployment verification. + consistency: { + requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" }, + }, + }); + const differenceKinds = response.diffs.reduce>((counts, difference) => { + const kind = toStableDiffKind(difference.diff.oneofKind); + counts[kind] = (counts[kind] ?? 0) + 1; + return counts; + }, {}); + + return { + differenceCount: response.diffs.length, + differenceKinds: Object.freeze(differenceKinds), + }; + }), + lookupResources: async (lookup) => { + validateResourceLookup(lookup); + + const consistency = getAuthorizationConsistency(); + const resourceIds = new Set(); + let cursor: string | undefined; + let resultCount = 0; + + do { + const responses = await executeAuthzedOperation("lookup_resources", () => + sdkClient.promises.lookupResources({ + consistency, + context: undefined, + optionalCursor: cursor ? { token: cursor } : undefined, + optionalLimit: AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE, + permission: lookup.permission, + resourceObjectType: lookup.resourceType, + subject: { + object: { + objectId: lookup.subject.objectId, + objectType: lookup.subject.objectType, + }, + optionalRelation: "", + }, + }) + ); + + if ( + responses.some( + (response) => + response.permissionship !== v1.LookupPermissionship.HAS_PERMISSION || + !isNonEmpty(response.resourceObjectId) + ) + ) { + throw new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.UNSUPPORTED, + operation: "lookup_resources", + retryable: false, + }); + } + + resultCount += responses.length; + if (resultCount > AUTHZED_MAX_RESOURCE_LOOKUP_RESULTS) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.LIMIT_EXCEEDED, + operation: "lookup_resources", + retryable: false, + }); + } + + for (const { resourceObjectId } of responses) { + resourceIds.add(resourceObjectId); + } + + if (responses.length < AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE) { + cursor = undefined; + continue; + } + + const nextCursor = responses.at(-1)?.afterResultCursor?.token; + if (!nextCursor || nextCursor === cursor) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "lookup_resources", + retryable: false, + }); + } + cursor = nextCursor; + } while (cursor); + + return { + resourceIds: Object.freeze([...resourceIds].sort((left, right) => left.localeCompare(right))), + }; + }, + readRelationships: async (query) => { + validateRelationshipQuery(query); + + return executeAuthzedOperation("read_relationships", async () => { + const { filter } = query; + const responses = await sdkClient.promises.readRelationships({ + // Fully consistent so reconciliation observes the latest write, matching `diffSchema`: the + // application's configurable permission-check consistency is never used for operational + // verification. + // + // The same requirement is sent on every page rather than pinning the first page's revision on + // later ones. It has to be: SpiceDB hashes the consistency into the cursor and rejects a + // mismatch. It also does not need the help — the cursor's own revision takes precedence over + // whatever requirement is stated — so substituting `atExactSnapshot` on later pages would both + // invalidate the cursor and add snapshot-expiry exposure for nothing. + consistency: { requirement: { fullyConsistent: true, oneofKind: "fullyConsistent" } }, + optionalCursor: query.cursor ? { token: query.cursor.token } : undefined, + optionalLimit: query.limit, + relationshipFilter: { + optionalRelation: filter.relation ?? "", + optionalResourceId: filter.resourceId ?? "", + optionalResourceIdPrefix: "", + optionalSubjectFilter: filter.subject + ? { + optionalRelation: filter.subject.relation + ? { relation: filter.subject.relation } + : undefined, + optionalSubjectId: filter.subject.objectId, + subjectType: filter.subject.objectType, + } + : undefined, + resourceType: filter.resourceType, + }, + }); + + const lastResponse = responses.at(-1); + const readAt = lastResponse?.readAt?.token; + + // A non-empty page always carries the revision it was read at. Without it a caller cannot tell + // whether successive pages describe the same view, so refuse rather than silently degrade. + if (lastResponse && !readAt) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "read_relationships", + retryable: false, + }); + } + + const afterResultCursor = lastResponse?.afterResultCursor?.token; + + return { + // A short page means the filter is exhausted. Only a full page can have more behind it, and + // only then is a cursor meaningful. + cursor: responses.length === query.limit && afterResultCursor ? { token: afterResultCursor } : null, + relationships: responses.map(toFacadeRelationship), + snapshot: readAt ? { token: readAt } : null, + }; + }); + }, + readSchema: async () => { + const schemaText = await executeAuthzedOperation("read_schema", async () => { + try { + const response = await sdkClient.promises.readSchema({}); + return response.schemaText; + } catch (error) { + // SpiceDB reports NOT_FOUND until the first schema is installed. For ReadSchema specifically, + // that is the empty-schema state rather than a failed connection. + if (mapAuthzedError(error, "read_schema", 1).code === AUTHZED_ERROR_CODES.NOT_FOUND) { + return ""; + } + + throw error; + } + }); + return { schemaText }; + }, + systemKey: config.systemKey, + // TOUCH and DELETE relationship updates are idempotent. Keep retries opt-in at this facade + // operation so future non-idempotent mutations cannot inherit them accidentally. + writeRelationships: async (updates) => { + validateRelationshipUpdates(updates); + + await executeAuthzedOperation("write_relationships", async () => { + await sdkClient.promises.writeRelationships({ + optionalPreconditions: [], + updates: updates.map(({ operation, relationship }) => ({ + operation: + operation === "touch" + ? v1.RelationshipUpdate_Operation.TOUCH + : v1.RelationshipUpdate_Operation.DELETE, + relationship: { + optionalCaveat: undefined, + optionalExpiresAt: undefined, + relation: relationship.relation, + resource: { + objectId: relationship.resource.objectId, + objectType: relationship.resource.objectType, + }, + subject: { + object: { + objectId: relationship.subject.objectId, + objectType: relationship.subject.objectType, + }, + optionalRelation: relationship.subject.relation ?? "", + }, + }, + })), + }); + }); + }, + // Repeating WriteSchema with the exact same schema is idempotent. Keep this explicit so future + // relationship writes cannot inherit retries accidentally. + writeSchema: async (schemaText) => { + await executeAuthzedOperation("write_schema", async () => { + await sdkClient.promises.writeSchema({ schema: schemaText }); + }); + }, + }); + + return { + close: () => sdkClient.close(), + facade, + }; +}; + +/** + * Widen this process's channel deadline to the bulk one, before any client exists. + * + * The deadline belongs to the channel, not to a call — the SDK's promisified streaming wrappers accept + * no per-call options at all — and every projector reaches the channel through `getAuthzedClient()` + * rather than being handed one. So "a separate client for bulk work" is not expressible: a command that + * both sweeps and writes would need one channel at two deadlines. + * + * It is a property of the *process* instead. A request-serving process wants the short deadline on + * everything, because a projection that hangs holds a user's request open; a command-line process wants + * the long one on everything, because a page of relationships legitimately takes longer than a single + * `Check`. Command entry points call this first, and it refuses to run once a client exists — silently + * leaving the short deadline in place would strand the sweep on its first slow page, which is precisely + * the failure this replaced. + */ +export const configureAuthzedClientForBulkWork = (): void => { + if (globalForAuthzed.formbricksAuthzedClient) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, + operation: "configure_authzed_client_for_bulk_work", + retryable: false, + }); + } + + globalForAuthzed.formbricksAuthzedRequestTimeoutMs = AUTHZED_BULK_REQUEST_TIMEOUT_MS; +}; + +/** The shared client. Deadline sized for a single cheap call unless the process asked for bulk work. */ +export const getAuthzedClient = (): TAuthzedResourceLookupClient => { + globalForAuthzed.formbricksAuthzedClient ??= createAuthzedClient( + globalForAuthzed.formbricksAuthzedRequestTimeoutMs ?? AUTHZED_REQUEST_TIMEOUT_MS + ); + + return globalForAuthzed.formbricksAuthzedClient.facade; +}; + +export const closeAuthzedClient = (): void => { + globalForAuthzed.formbricksAuthzedClient?.close(); + globalForAuthzed.formbricksAuthzedClient = undefined; + globalForAuthzed.formbricksAuthzedRequestTimeoutMs = undefined; +}; diff --git a/apps/web/lib/authzed/config.test.ts b/apps/web/lib/authzed/config.test.ts new file mode 100644 index 000000000000..251352c27312 --- /dev/null +++ b/apps/web/lib/authzed/config.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { isAuthzedEnabled } from "./config"; + +const envMock = vi.hoisted(() => ({ + AUTHZED_CONSISTENCY: undefined as "minimize_latency" | "fully_consistent" | undefined, + AUTHZED_ENABLED: undefined as "true" | "false" | "1" | "0" | undefined, + AUTHZED_ENDPOINT: undefined as string | undefined, + AUTHZED_INSECURE: undefined as "true" | "false" | "1" | "0" | undefined, + AUTHZED_SYSTEM_KEY: undefined as string | undefined, + AUTHZED_TOKEN: undefined as string | undefined, +})); + +vi.mock("@/lib/env", () => ({ env: envMock })); + +describe("AuthZed configuration", () => { + beforeEach(() => { + envMock.AUTHZED_CONSISTENCY = undefined; + envMock.AUTHZED_ENABLED = undefined; + envMock.AUTHZED_ENDPOINT = undefined; + envMock.AUTHZED_INSECURE = undefined; + envMock.AUTHZED_SYSTEM_KEY = undefined; + envMock.AUTHZED_TOKEN = undefined; + }); + + test.each([ + [undefined, false], + ["false", false], + ["0", false], + ["true", true], + ["1", true], + ] as const)("normalizes enabled value %s", (value, expected) => { + envMock.AUTHZED_ENABLED = value; + + expect(isAuthzedEnabled()).toBe(expected); + }); +}); diff --git a/apps/web/lib/authzed/config.ts b/apps/web/lib/authzed/config.ts new file mode 100644 index 000000000000..3779c00034e1 --- /dev/null +++ b/apps/web/lib/authzed/config.ts @@ -0,0 +1,9 @@ +import "server-only"; +import { env } from "@/lib/env"; + +export type TAuthzedConsistency = "minimize_latency" | "fully_consistent"; + +const parseAuthzedBoolean = (value: "true" | "false" | "1" | "0" | undefined): boolean => + value === "true" || value === "1"; + +export const isAuthzedEnabled = (): boolean => parseAuthzedBoolean(env.AUTHZED_ENABLED); diff --git a/apps/web/lib/authzed/constants.ts b/apps/web/lib/authzed/constants.ts new file mode 100644 index 000000000000..b78a3240e9c7 --- /dev/null +++ b/apps/web/lib/authzed/constants.ts @@ -0,0 +1,98 @@ +import "server-only"; + +export const AUTHZED_REQUEST_TIMEOUT_MS = 1_000; +export const AUTHZED_MAX_ATTEMPTS = 3; +export const AUTHZED_RETRY_BASE_DELAYS_MS = [100, 200] as const; +export const AUTHZED_RETRY_JITTER_RATIO = 0.2; +export const AUTHZED_MAX_RELATIONSHIP_UPDATES = 1_000; +export const AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES = 10; + +/** + * Deadline for administrative calls: bulk reads and wide deletes. + * + * The request-path deadline is sized for a single cheap call. A server-streaming read or an unbounded + * delete needs room, and SpiceDB's own `--streaming-api-response-delay-timeout` defaults to 30s, so + * matching it is the conservative choice. Applied by giving the command-line client its own channel; + * the request-path client keeps `AUTHZED_REQUEST_TIMEOUT_MS`. + */ +export const AUTHZED_BULK_REQUEST_TIMEOUT_MS = 30_000; + +/** + * Relationships requested per `readRelationships` page. + * + * The deadline interceptor bounds the *entire* server-streaming call — the promisified SDK buffers + * every message before resolving — so a page must fully stream within it. 250 leaves generous headroom + * even on the request-path deadline, and stays well under SpiceDB's own + * `--max-read-relationships-limit` (1,000 by default), which rejects a larger limit outright. + */ +export const AUTHZED_MAX_RELATIONSHIP_READS = 250; + +/** + * Resources requested per `LookupResources` page. + * + * The promise SDK buffers a server stream before resolving, so an unlimited request can consume + * unbounded memory and run through the channel deadline. Paging at the same conservative size as raw + * relationship reads keeps each individual allocation and retry bounded. + */ +export const AUTHZED_RESOURCE_LOOKUP_PAGE_SIZE = 250; + +/** + * Resource IDs accumulated by one complete permission lookup. + * + * Authoritative resource discovery must return complete sets. Crossing this guard fails the protected + * operation closed instead of returning a partial authorization result or allowing a pathological + * relationship graph to exhaust the process. + */ +export const AUTHZED_MAX_RESOURCE_LOOKUP_RESULTS = 20_000; + +/** + * Observed relationships held in memory for a single backfill unit before the unit is abandoned. + * + * Bounds the drainer so a pathological store cannot exhaust the process. This is a *per-unit* bound and + * only applies to filters narrow enough to drain — one organization's resources, say. A sweep across a + * whole resource type must stream instead (`forEachRelationshipPage`), because applying this bound there + * would make the sweep fail outright on any deployment holding more relationships than the bound. + */ +export const AUTHZED_MAX_OBSERVED_RELATIONSHIPS_PER_UNIT = 20_000; + +/** Organizations fetched per keyset page while enumerating backfill units. */ +export const AUTHZED_BACKFILL_ORGANIZATION_PAGE_SIZE = 100; + +/** + * Projection targets handed to a reconciler in one call. + * + * Reconcilers read their source snapshot with `where: { OR: targets.map(...) }`, which is unbounded + * by construction — a large tenant would build a query that is both a planner disaster and close to + * PostgreSQL's bound-parameter ceiling. 200 targets also keeps the widest fan-out (4 updates per + * membership) under `AUTHZED_MAX_RELATIONSHIP_UPDATES`. + */ +export const AUTHZED_TARGET_CHUNK_SIZE = 200; + +/** + * Orphaned resources a single run may prune. + * + * A large orphan count is a symptom — wrong endpoint, wrong database, a mid-restore SpiceDB — not a + * big cleanup job. Exceeding the cap prunes nothing for that unit so the run degrades into a loud + * report instead of a partly-destroyed authorization graph. Operators may lower it, never raise it. + * + * "Nothing" is the whole point, and it is why every unit — the streaming sweep included — counts its + * orphans to completion before deleting any of them. A cap enforced per page would let the pages that + * fit through and halt on the one that did not, leaving the graph partly destroyed by exactly the + * mistake the cap exists to catch. + */ +export const AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN = 500; + +/** + * Distinct orphaned records the global sweep tracks to keep its count exact. + * + * The sweep streams, so one record can be implied by relationships on more than one page: a user who + * holds both `member` and `owner` is two tuples, and SpiceDB returns them grouped by relation rather + * than adjacently, so they straddle a page boundary in any organization larger than a page. Counting + * that record twice would inflate the total, and the total is the diagnostic. + * + * Bounded because this set is the one structure in a streaming sweep that grows with the store. Past the + * bound the sweep keeps counting and reports `truncated`: a total that may double-count is far better + * than an unbounded heap, and a run with this many orphans is already three orders of magnitude past the + * prune cap, so nothing is deleted on the strength of it. + */ +export const AUTHZED_MAX_TRACKED_ORPHAN_REFS = 50_000; diff --git a/apps/web/lib/authzed/errors.test.ts b/apps/web/lib/authzed/errors.test.ts new file mode 100644 index 000000000000..be15670db965 --- /dev/null +++ b/apps/web/lib/authzed/errors.test.ts @@ -0,0 +1,76 @@ +import { status } from "@grpc/grpc-js"; +import { describe, expect, test } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError, mapAuthzedError } from "./errors"; + +describe("mapAuthzedError", () => { + test.each([ + [status.DEADLINE_EXCEEDED, AUTHZED_ERROR_CODES.TIMEOUT, true], + [status.UNAVAILABLE, AUTHZED_ERROR_CODES.UNAVAILABLE, true], + [status.RESOURCE_EXHAUSTED, AUTHZED_ERROR_CODES.OVERLOADED, true], + [status.ABORTED, AUTHZED_ERROR_CODES.ABORTED, true], + [status.UNAUTHENTICATED, AUTHZED_ERROR_CODES.UNAUTHENTICATED, false], + [status.PERMISSION_DENIED, AUTHZED_ERROR_CODES.PERMISSION_DENIED, false], + [status.INVALID_ARGUMENT, AUTHZED_ERROR_CODES.INVALID_REQUEST, false], + [status.OUT_OF_RANGE, AUTHZED_ERROR_CODES.INVALID_REQUEST, false], + [status.FAILED_PRECONDITION, AUTHZED_ERROR_CODES.FAILED_PRECONDITION, false], + [status.NOT_FOUND, AUTHZED_ERROR_CODES.NOT_FOUND, false], + [status.ALREADY_EXISTS, AUTHZED_ERROR_CODES.CONFLICT, false], + [status.CANCELLED, AUTHZED_ERROR_CODES.CANCELLED, false], + [status.UNIMPLEMENTED, AUTHZED_ERROR_CODES.UNSUPPORTED, false], + [status.UNKNOWN, AUTHZED_ERROR_CODES.INTERNAL, false], + [status.INTERNAL, AUTHZED_ERROR_CODES.INTERNAL, false], + [status.DATA_LOSS, AUTHZED_ERROR_CODES.INTERNAL, false], + ])("maps gRPC status %i to %s", (grpcStatus, code, retryable) => { + const sourceError = { code: grpcStatus, details: "raw-sdk-details" }; + + const result = mapAuthzedError(sourceError, "read_schema", 2); + + expect(result).toBeInstanceOf(AuthzedError); + expect(result).toMatchObject({ + attempts: 2, + code, + grpcStatus, + message: code, + name: "AuthzedError", + operation: "read_schema", + retryable, + }); + expect(result.cause).toBe(sourceError); + }); + + test.each([new Error("socket failure"), "string failure", null, { code: "14" }])( + "maps a non-gRPC error to an internal error", + (sourceError) => { + const result = mapAuthzedError(sourceError, "read_schema", 1); + + expect(result).toMatchObject({ + attempts: 1, + code: AUTHZED_ERROR_CODES.INTERNAL, + grpcStatus: undefined, + operation: "read_schema", + retryable: false, + }); + } + ); + + test("preserves an existing AuthzedError classification while updating operation metadata", () => { + const sourceError = new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + grpcStatus: status.UNAVAILABLE, + operation: "previous_operation", + retryable: true, + }); + + const result = mapAuthzedError(sourceError, "read_schema", 3); + + expect(result).not.toBe(sourceError); + expect(result).toMatchObject({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + grpcStatus: status.UNAVAILABLE, + operation: "read_schema", + retryable: true, + }); + }); +}); diff --git a/apps/web/lib/authzed/errors.ts b/apps/web/lib/authzed/errors.ts new file mode 100644 index 000000000000..853a9b7b7a41 --- /dev/null +++ b/apps/web/lib/authzed/errors.ts @@ -0,0 +1,122 @@ +import "server-only"; +import { status } from "@grpc/grpc-js"; + +export const AUTHZED_ERROR_CODES = { + ABORTED: "authzed_aborted", + CANCELLED: "authzed_cancelled", + CONFLICT: "authzed_conflict", + DISABLED: "authzed_disabled", + FAILED_PRECONDITION: "authzed_failed_precondition", + INTERNAL: "authzed_internal", + INVALID_REQUEST: "authzed_invalid_request", + LIMIT_EXCEEDED: "authzed_limit_exceeded", + NOT_FOUND: "authzed_not_found", + OVERLOADED: "authzed_overloaded", + PERMISSION_DENIED: "authzed_permission_denied", + PROJECTION_STALE: "authzed_projection_stale", + SCHEMA_CHANGED: "authzed_schema_changed", + SCHEMA_VERIFICATION_FAILED: "authzed_schema_verification_failed", + TIMEOUT: "authzed_timeout", + UNAUTHENTICATED: "authzed_unauthenticated", + UNAVAILABLE: "authzed_unavailable", + UNSUPPORTED: "authzed_unsupported", +} as const; + +export type TAuthzedErrorCode = (typeof AUTHZED_ERROR_CODES)[keyof typeof AUTHZED_ERROR_CODES]; + +type TAuthzedErrorOptions = Readonly<{ + attempts: number; + cause?: unknown; + code: TAuthzedErrorCode; + grpcStatus?: number; + operation: string; + retryable: boolean; +}>; + +export class AuthzedError extends Error { + readonly attempts: number; + readonly cause?: unknown; + readonly code: TAuthzedErrorCode; + readonly grpcStatus?: number; + readonly operation: string; + readonly retryable: boolean; + + constructor({ attempts, cause, code, grpcStatus, operation, retryable }: TAuthzedErrorOptions) { + super(code); + this.name = "AuthzedError"; + this.attempts = attempts; + this.cause = cause; + this.code = code; + this.grpcStatus = grpcStatus; + this.operation = operation; + this.retryable = retryable; + } +} + +const getGrpcStatus = (error: unknown): number | undefined => { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + + const code = error.code; + return typeof code === "number" ? code : undefined; +}; + +const getErrorDescriptor = ( + grpcStatus: number | undefined +): Readonly<{ code: TAuthzedErrorCode; retryable: boolean }> => { + switch (grpcStatus) { + case status.DEADLINE_EXCEEDED: + return { code: AUTHZED_ERROR_CODES.TIMEOUT, retryable: true }; + case status.UNAVAILABLE: + return { code: AUTHZED_ERROR_CODES.UNAVAILABLE, retryable: true }; + case status.RESOURCE_EXHAUSTED: + return { code: AUTHZED_ERROR_CODES.OVERLOADED, retryable: true }; + case status.ABORTED: + return { code: AUTHZED_ERROR_CODES.ABORTED, retryable: true }; + case status.UNAUTHENTICATED: + return { code: AUTHZED_ERROR_CODES.UNAUTHENTICATED, retryable: false }; + case status.PERMISSION_DENIED: + return { code: AUTHZED_ERROR_CODES.PERMISSION_DENIED, retryable: false }; + case status.INVALID_ARGUMENT: + case status.OUT_OF_RANGE: + return { code: AUTHZED_ERROR_CODES.INVALID_REQUEST, retryable: false }; + case status.FAILED_PRECONDITION: + return { code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, retryable: false }; + case status.NOT_FOUND: + return { code: AUTHZED_ERROR_CODES.NOT_FOUND, retryable: false }; + case status.ALREADY_EXISTS: + return { code: AUTHZED_ERROR_CODES.CONFLICT, retryable: false }; + case status.CANCELLED: + return { code: AUTHZED_ERROR_CODES.CANCELLED, retryable: false }; + case status.UNIMPLEMENTED: + return { code: AUTHZED_ERROR_CODES.UNSUPPORTED, retryable: false }; + default: + return { code: AUTHZED_ERROR_CODES.INTERNAL, retryable: false }; + } +}; + +export const mapAuthzedError = (error: unknown, operation: string, attempts: number): AuthzedError => { + if (error instanceof AuthzedError) { + return new AuthzedError({ + attempts, + cause: error.cause ?? error, + code: error.code, + grpcStatus: error.grpcStatus, + operation, + retryable: error.retryable, + }); + } + + const grpcStatus = getGrpcStatus(error); + const descriptor = getErrorDescriptor(grpcStatus); + + return new AuthzedError({ + attempts, + cause: error, + code: descriptor.code, + grpcStatus, + operation, + retryable: descriptor.retryable, + }); +}; diff --git a/apps/web/lib/authzed/feedback-directory-assignment-id.test.ts b/apps/web/lib/authzed/feedback-directory-assignment-id.test.ts new file mode 100644 index 000000000000..5e6091494f85 --- /dev/null +++ b/apps/web/lib/authzed/feedback-directory-assignment-id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test, vi } from "vitest"; +import { getFeedbackDirectoryAssignmentObjectId } from "./feedback-directory-assignment-id"; + +vi.mock("node:crypto", async (importOriginal) => importOriginal()); + +describe("getFeedbackDirectoryAssignmentObjectId", () => { + test("is deterministic, opaque, and uses the reserved prefix", () => { + const first = getFeedbackDirectoryAssignmentObjectId("directory-1", "workspace-1"); + const second = getFeedbackDirectoryAssignmentObjectId("directory-1", "workspace-1"); + + expect(first).toBe(second); + expect(first).toMatch(/^fdwa_[a-f0-9]{64}$/); + expect(first).not.toContain("directory-1"); + expect(first).not.toContain("workspace-1"); + }); + + test("is order-sensitive", () => { + expect(getFeedbackDirectoryAssignmentObjectId("directory", "workspace")).not.toBe( + getFeedbackDirectoryAssignmentObjectId("workspace", "directory") + ); + }); + + test("length framing distinguishes concatenation collisions", () => { + expect(getFeedbackDirectoryAssignmentObjectId("ab", "c")).not.toBe( + getFeedbackDirectoryAssignmentObjectId("a", "bc") + ); + }); + + test("uses UTF-8 byte framing consistently", () => { + expect(getFeedbackDirectoryAssignmentObjectId("é", "a")).not.toBe( + getFeedbackDirectoryAssignmentObjectId("e", "́a") + ); + }); +}); diff --git a/apps/web/lib/authzed/feedback-directory-assignment-id.ts b/apps/web/lib/authzed/feedback-directory-assignment-id.ts new file mode 100644 index 000000000000..ab19045073cb --- /dev/null +++ b/apps/web/lib/authzed/feedback-directory-assignment-id.ts @@ -0,0 +1,18 @@ +import "server-only"; +import { createHash } from "node:crypto"; + +const frame = (value: string): string => `${Buffer.byteLength(value, "utf8")}:${value}`; + +/** + * Stable opaque SpiceDB object ID for one `FeedbackDirectoryWorkspace` pair. + * + * Length framing makes concatenation unambiguous and UTF-8 byte lengths keep the hash stable across + * runtimes. This module is direct-path and server-only so neither source identifier reaches client code. + */ +export const getFeedbackDirectoryAssignmentObjectId = ( + feedbackDirectoryId: string, + workspaceId: string +): string => + `fdwa_${createHash("sha256") + .update(`${frame(feedbackDirectoryId)}${frame(workspaceId)}`, "utf8") + .digest("hex")}`; diff --git a/apps/web/lib/authzed/feedback-directory.test.ts b/apps/web/lib/authzed/feedback-directory.test.ts new file mode 100644 index 000000000000..801a5ea66288 --- /dev/null +++ b/apps/web/lib/authzed/feedback-directory.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { type TAuthzedRelationshipUpdate, getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { reconcileFeedbackDirectoryRelationships } from "./feedback-directory"; +import { getFeedbackDirectoryAssignmentObjectId } from "./feedback-directory-assignment-id"; + +vi.mock("node:crypto", async (importOriginal) => importOriginal()); + +const client = { + deleteRelationships: vi.fn(), + writeRelationships: vi.fn(), +}; + +vi.mock("@formbricks/database", () => ({ + prisma: { + feedbackDirectory: { findMany: vi.fn() }, + feedbackDirectoryWorkspace: { findMany: vi.fn() }, + workspace: { findMany: vi.fn() }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { debug: vi.fn(), warn: vi.fn() }, +})); + +vi.mock("./client", () => ({ getAuthzedClient: vi.fn() })); +vi.mock("./config", () => ({ isAuthzedEnabled: vi.fn() })); + +const DIRECTORY_ID = "directory-private-id"; +const WORKSPACE_ID = "workspace-private-id"; +const ORGANIZATION_ID = "organization-private-id"; + +const directory = ( + workspaces: ReadonlyArray = [WORKSPACE_ID], + overrides: Readonly> = {} +) => ({ + id: DIRECTORY_ID, + isArchived: false, + organizationId: ORGANIZATION_ID, + workspaces: workspaces.map((workspaceId) => ({ + workspace: { organizationId: ORGANIZATION_ID }, + workspaceId, + })), + ...overrides, +}); + +const setStableSnapshot = ( + directories: ReadonlyArray> = [directory()] +): void => { + vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValue(directories as never); + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([] as never); +}; + +describe("feedback directory relationship projection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(getAuthzedClient).mockReturnValue(client as unknown as ReturnType); + client.deleteRelationships.mockResolvedValue(undefined); + client.writeRelationships.mockResolvedValue(undefined); + setStableSnapshot(); + }); + + test("projects directory and workspace parents plus the exact three-edge assignment", async () => { + await expect( + reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }) + ).resolves.toEqual({ passes: 1, status: "projected" }); + + const updates = client.writeRelationships.mock.calls.flatMap(([batch]) => batch); + const assignmentId = getFeedbackDirectoryAssignmentObjectId(DIRECTORY_ID, WORKSPACE_ID); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ + relation: "organization", + resource: { objectId: DIRECTORY_ID, objectType: "feedback_directory" }, + }), + }), + expect.objectContaining({ + operation: "touch", + relationship: { + relation: "assignment", + resource: { objectId: DIRECTORY_ID, objectType: "feedback_directory" }, + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + }), + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ + relation: "directory", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }), + }), + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ + relation: "workspace", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }), + }), + ]) + ); + }); + + test("removes every previous directory and workspace parent before restoring current parents", async () => { + await reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }); + + expect(client.deleteRelationships).toHaveBeenCalledWith({ + relation: "organization", + resourceId: DIRECTORY_ID, + resourceType: "feedback_directory", + }); + expect(client.deleteRelationships).toHaveBeenCalledWith({ + relation: "organization", + resourceId: WORKSPACE_ID, + resourceType: "workspace", + }); + expect(Math.max(...client.deleteRelationships.mock.invocationCallOrder)).toBeLessThan( + client.writeRelationships.mock.invocationCallOrder[0] + ); + }); + + test("removes all three edges for archived and removed assignments", async () => { + setStableSnapshot([directory([WORKSPACE_ID], { isArchived: true })]); + + await reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }); + let assignmentUpdates = client.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.relation !== "organization"); + expect(assignmentUpdates).toHaveLength(3); + expect(assignmentUpdates.every(({ operation }) => operation === "delete")).toBe(true); + + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(getAuthzedClient).mockReturnValue(client as unknown as ReturnType); + client.deleteRelationships.mockResolvedValue(undefined); + client.writeRelationships.mockResolvedValue(undefined); + setStableSnapshot([directory([])]); + await reconcileFeedbackDirectoryRelationships({ + assignments: [{ feedbackDirectoryId: DIRECTORY_ID, workspaceId: WORKSPACE_ID }], + }); + assignmentUpdates = client.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.relation !== "organization"); + expect(assignmentUpdates).toHaveLength(3); + expect(assignmentUpdates.every(({ operation }) => operation === "delete")).toBe(true); + }); + + test("rejects a cross-organization source before writing relationships", async () => { + setStableSnapshot([directory([WORKSPACE_ID], { organizationId: "other-organization" })]); + + await expect( + reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }) + ).resolves.toEqual({ + attempts: 1, + code: "authzed_projection_invalid_source", + retryable: false, + status: "failed", + }); + expect(client.writeRelationships).not.toHaveBeenCalled(); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain(DIRECTORY_ID); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain(WORKSPACE_ID); + }); + + test("removes resource-side and subject-side relationships for a missing directory", async () => { + setStableSnapshot([]); + + await reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }); + + expect(client.deleteRelationships).toHaveBeenCalledWith({ + resourceId: DIRECTORY_ID, + resourceType: "feedback_directory", + }); + expect(client.deleteRelationships).toHaveBeenCalledWith({ + resourceType: "feedback_directory_assignment", + subject: { objectId: DIRECTORY_ID, objectType: "feedback_directory" }, + }); + }); + + test("packs at most 1,000 operations without splitting an assignment group", async () => { + const workspaceIds = Array.from({ length: 334 }, (_, index) => `workspace-${index}`); + setStableSnapshot([directory(workspaceIds)]); + + await reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }); + + const batches = client.writeRelationships.mock.calls.map( + ([batch]) => batch as ReadonlyArray + ); + expect(batches.length).toBeGreaterThan(1); + expect(batches.every((batch) => batch.length <= 1000)).toBe(true); + for (const workspaceId of workspaceIds) { + const assignmentId = getFeedbackDirectoryAssignmentObjectId(DIRECTORY_ID, workspaceId); + const containingBatches = batches.filter((batch) => + batch.some( + ({ relationship }) => + relationship.resource.objectId === assignmentId || relationship.subject.objectId === assignmentId + ) + ); + expect(containingBatches).toHaveLength(1); + expect( + containingBatches[0].filter( + ({ relationship }) => + relationship.resource.objectId === assignmentId || relationship.subject.objectId === assignmentId + ) + ).toHaveLength(3); + } + }); + + test("returns disabled before database access and treats an empty target set as a zero-pass no-op", async () => { + vi.mocked(isAuthzedEnabled).mockReturnValue(false); + await expect( + reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }) + ).resolves.toEqual({ status: "disabled" }); + expect(prisma.feedbackDirectory.findMany).not.toHaveBeenCalled(); + expect(getAuthzedClient).not.toHaveBeenCalled(); + + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + await expect(reconcileFeedbackDirectoryRelationships({})).resolves.toEqual({ + passes: 0, + status: "projected", + }); + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("retries a changing source snapshot and converges on the second pass", async () => { + const first = directory([WORKSPACE_ID]); + const changed = directory([]); + vi.mocked(prisma.feedbackDirectory.findMany) + .mockResolvedValueOnce([first] as never) + .mockResolvedValueOnce([changed] as never) + .mockResolvedValueOnce([changed] as never) + .mockResolvedValueOnce([changed] as never); + + await expect( + reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }) + ).resolves.toEqual({ passes: 2, status: "projected" }); + }); + + test("reports an unstable source after three complete changing passes", async () => { + const present = directory([WORKSPACE_ID]); + const absent = directory([]); + vi.mocked(prisma.feedbackDirectory.findMany) + .mockResolvedValueOnce([present] as never) + .mockResolvedValueOnce([absent] as never) + .mockResolvedValueOnce([present] as never) + .mockResolvedValueOnce([absent] as never) + .mockResolvedValueOnce([present] as never) + .mockResolvedValueOnce([absent] as never); + + await expect( + reconcileFeedbackDirectoryRelationships({ feedbackDirectoryIds: [DIRECTORY_ID] }) + ).resolves.toEqual({ + attempts: 3, + code: "authzed_projection_unstable", + retryable: false, + status: "failed", + }); + }); + + test("deduplicates repeated pair targets before writing their logical group", async () => { + await reconcileFeedbackDirectoryRelationships({ + assignments: [ + { feedbackDirectoryId: DIRECTORY_ID, workspaceId: WORKSPACE_ID }, + { feedbackDirectoryId: DIRECTORY_ID, workspaceId: WORKSPACE_ID }, + ], + feedbackDirectoryIds: [DIRECTORY_ID, DIRECTORY_ID], + }); + + const assignmentId = getFeedbackDirectoryAssignmentObjectId(DIRECTORY_ID, WORKSPACE_ID); + const updates = client.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.resource.objectId === assignmentId); + expect(updates).toHaveLength(2); + }); +}); diff --git a/apps/web/lib/authzed/feedback-directory.ts b/apps/web/lib/authzed/feedback-directory.ts new file mode 100644 index 000000000000..fa2b837c8335 --- /dev/null +++ b/apps/web/lib/authzed/feedback-directory.ts @@ -0,0 +1,334 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import type { TAuthzedClient, TAuthzedRelationshipUpdate } from "./client"; +import { getAuthzedClient } from "./client"; +import { getFeedbackDirectoryAssignmentObjectId } from "./feedback-directory-assignment-id"; +import { deleteOrganizationParentRelationships } from "./organization-parent"; +import { + AUTHZED_MAX_RECONCILIATION_PASSES, + AuthzedProjectionInvalidSourceError, + AuthzedProjectionUnstableError, + type TAuthzedProjectionResult, + runBestEffortProjection, +} from "./projection"; +import { deleteRelationshipsInBoundedBatches, packRelationshipUpdateGroups } from "./relationship-batches"; + +export type TFeedbackDirectoryAssignmentProjectionTarget = Readonly<{ + feedbackDirectoryId: string; + workspaceId: string; +}>; + +export type TFeedbackDirectoryProjectionTargets = Readonly<{ + feedbackDirectoryIds?: ReadonlyArray; + assignments?: ReadonlyArray; +}>; + +type TNormalizedTargets = Readonly<{ + assignments: ReadonlyArray; + feedbackDirectoryIds: ReadonlyArray; + workspaceIds: ReadonlyArray; +}>; + +type TFeedbackDirectorySnapshot = Readonly<{ + assignments: ReadonlyArray< + Readonly<{ + feedbackDirectoryId: string; + feedbackDirectoryOrganizationId: string; + isDirectoryArchived: boolean; + workspaceId: string; + workspaceOrganizationId: string; + }> + >; + directories: ReadonlyArray< + Readonly<{ + id: string; + isArchived: boolean; + organizationId: string; + }> + >; + workspaces: ReadonlyArray>; +}>; + +const pairKey = (feedbackDirectoryId: string, workspaceId: string): string => + `${feedbackDirectoryId.length}:${feedbackDirectoryId}${workspaceId.length}:${workspaceId}`; + +const normalizeTargets = (targets: TFeedbackDirectoryProjectionTargets): TNormalizedTargets => { + const assignments = new Map(); + const feedbackDirectoryIds = new Set(targets.feedbackDirectoryIds ?? []); + const workspaceIds = new Set(); + + for (const assignment of targets.assignments ?? []) { + assignments.set(pairKey(assignment.feedbackDirectoryId, assignment.workspaceId), assignment); + feedbackDirectoryIds.add(assignment.feedbackDirectoryId); + workspaceIds.add(assignment.workspaceId); + } + + return { + assignments: [...assignments.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, assignment]) => assignment), + feedbackDirectoryIds: [...feedbackDirectoryIds].sort((left, right) => left.localeCompare(right)), + workspaceIds: [...workspaceIds].sort((left, right) => left.localeCompare(right)), + }; +}; + +const isEmpty = (targets: TNormalizedTargets): boolean => targets.feedbackDirectoryIds.length === 0; + +const readSnapshot = async (targets: TNormalizedTargets): Promise => { + const [directories, explicitAssignments, explicitWorkspaces] = await Promise.all([ + prisma.feedbackDirectory.findMany({ + where: { id: { in: [...targets.feedbackDirectoryIds] } }, + select: { + id: true, + isArchived: true, + organizationId: true, + workspaces: { + select: { + workspace: { select: { organizationId: true } }, + workspaceId: true, + }, + orderBy: { workspaceId: "asc" }, + }, + }, + orderBy: { id: "asc" }, + }), + targets.assignments.length === 0 + ? [] + : prisma.feedbackDirectoryWorkspace.findMany({ + where: { + OR: targets.assignments.map(({ feedbackDirectoryId, workspaceId }) => ({ + feedbackDirectoryId, + workspaceId, + })), + }, + select: { + feedbackDirectory: { + select: { isArchived: true, organizationId: true }, + }, + feedbackDirectoryId: true, + workspace: { select: { organizationId: true } }, + workspaceId: true, + }, + orderBy: [{ feedbackDirectoryId: "asc" }, { workspaceId: "asc" }], + }), + targets.workspaceIds.length === 0 + ? [] + : prisma.workspace.findMany({ + where: { id: { in: [...targets.workspaceIds] } }, + select: { id: true, organizationId: true }, + orderBy: { id: "asc" }, + }), + ]); + + const assignments = new Map(); + const workspaces = new Map( + explicitWorkspaces.map((workspace) => [workspace.id, workspace]) + ); + + for (const directory of directories) { + for (const assignment of directory.workspaces) { + assignments.set(pairKey(directory.id, assignment.workspaceId), { + feedbackDirectoryId: directory.id, + feedbackDirectoryOrganizationId: directory.organizationId, + isDirectoryArchived: directory.isArchived, + workspaceId: assignment.workspaceId, + workspaceOrganizationId: assignment.workspace.organizationId, + }); + workspaces.set(assignment.workspaceId, { + id: assignment.workspaceId, + organizationId: assignment.workspace.organizationId, + }); + } + } + for (const assignment of explicitAssignments) { + assignments.set(pairKey(assignment.feedbackDirectoryId, assignment.workspaceId), { + feedbackDirectoryId: assignment.feedbackDirectoryId, + feedbackDirectoryOrganizationId: assignment.feedbackDirectory.organizationId, + isDirectoryArchived: assignment.feedbackDirectory.isArchived, + workspaceId: assignment.workspaceId, + workspaceOrganizationId: assignment.workspace.organizationId, + }); + workspaces.set(assignment.workspaceId, { + id: assignment.workspaceId, + organizationId: assignment.workspace.organizationId, + }); + } + + return { + assignments: [...assignments.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, assignment]) => assignment), + directories: directories.map(({ id, isArchived, organizationId }) => ({ + id, + isArchived, + organizationId, + })), + workspaces: [...workspaces.values()].sort((left, right) => left.id.localeCompare(right.id)), + }; +}; + +const snapshotsMatch = (left: TFeedbackDirectorySnapshot, right: TFeedbackDirectorySnapshot): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const parentUpdate = ( + resourceType: "feedback_directory" | "workspace", + resourceId: string, + organizationId: string +): TAuthzedRelationshipUpdate => ({ + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: resourceId, objectType: resourceType }, + subject: { objectId: organizationId, objectType: "organization" }, + }, +}); + +const assignmentUpdates = ( + target: TFeedbackDirectoryAssignmentProjectionTarget, + active: boolean +): ReadonlyArray => { + const assignmentId = getFeedbackDirectoryAssignmentObjectId(target.feedbackDirectoryId, target.workspaceId); + const operation = active ? "touch" : "delete"; + + return [ + { + operation, + relationship: { + relation: "assignment", + resource: { objectId: target.feedbackDirectoryId, objectType: "feedback_directory" }, + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + }, + { + operation, + relationship: { + relation: "directory", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: target.feedbackDirectoryId, objectType: "feedback_directory" }, + }, + }, + { + operation, + relationship: { + relation: "workspace", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: target.workspaceId, objectType: "workspace" }, + }, + }, + ]; +}; + +const writeSnapshot = async ( + client: TAuthzedClient, + targets: TNormalizedTargets, + snapshot: TFeedbackDirectorySnapshot +): Promise => { + const currentAssignments = new Map( + snapshot.assignments.map((assignment) => [ + pairKey(assignment.feedbackDirectoryId, assignment.workspaceId), + assignment, + ]) + ); + const allAssignmentTargets = new Map(); + for (const target of targets.assignments) { + allAssignmentTargets.set(pairKey(target.feedbackDirectoryId, target.workspaceId), target); + } + for (const assignment of snapshot.assignments) { + allAssignmentTargets.set(pairKey(assignment.feedbackDirectoryId, assignment.workspaceId), { + feedbackDirectoryId: assignment.feedbackDirectoryId, + workspaceId: assignment.workspaceId, + }); + } + + for (const assignment of snapshot.assignments) { + if (assignment.feedbackDirectoryOrganizationId !== assignment.workspaceOrganizationId) { + throw new AuthzedProjectionInvalidSourceError(); + } + } + + const groups: TAuthzedRelationshipUpdate[][] = []; + for (const directory of snapshot.directories) { + groups.push([parentUpdate("feedback_directory", directory.id, directory.organizationId)]); + } + for (const workspace of snapshot.workspaces) { + groups.push([parentUpdate("workspace", workspace.id, workspace.organizationId)]); + } + for (const [key, target] of [...allAssignmentTargets.entries()].sort(([left], [right]) => + left.localeCompare(right) + )) { + const current = currentAssignments.get(key); + groups.push([...assignmentUpdates(target, current !== undefined && !current.isDirectoryArchived)]); + } + + await deleteOrganizationParentRelationships(client, [ + ...snapshot.directories.map(({ id }) => ({ resourceId: id, resourceType: "feedback_directory" })), + ...snapshot.workspaces.map(({ id }) => ({ resourceId: id, resourceType: "workspace" })), + ]); + + for (const batch of packRelationshipUpdateGroups(groups)) { + await client.writeRelationships(batch); + } + + const existingDirectoryIds = new Set(snapshot.directories.map(({ id }) => id)); + await deleteRelationshipsInBoundedBatches( + client, + targets.feedbackDirectoryIds + .filter((id) => !existingDirectoryIds.has(id)) + .flatMap((id) => [ + { resourceId: id, resourceType: "feedback_directory" }, + { + resourceType: "feedback_directory_assignment", + subject: { objectId: id, objectType: "feedback_directory" }, + }, + ]) + ); +}; + +export const reconcileFeedbackDirectoryRelationships = async ( + targets: TFeedbackDirectoryProjectionTargets +): Promise => + runBestEffortProjection("reconcile_feedback_directory_relationships", "feedback_directory", async () => { + const normalizedTargets = normalizeTargets(targets); + if (isEmpty(normalizedTargets)) { + return 0; + } + + const client = getAuthzedClient(); + for (let pass = 1; pass <= AUTHZED_MAX_RECONCILIATION_PASSES; pass++) { + const sourceSnapshot = await readSnapshot(normalizedTargets); + await writeSnapshot(client, normalizedTargets, sourceSnapshot); + const verifiedSnapshot = await readSnapshot(normalizedTargets); + if (snapshotsMatch(sourceSnapshot, verifiedSnapshot)) { + return pass; + } + } + + throw new AuthzedProjectionUnstableError(); + }); + +/** Full-deployment prune for an unattributable hashed assignment resource. */ +export const deleteFeedbackDirectoryAssignmentRelationships = async ( + assignmentIds: ReadonlyArray +): Promise => + runBestEffortProjection( + "delete_feedback_directory_assignment_relationships", + "feedback_directory", + async () => { + const uniqueIds = [...new Set(assignmentIds)].sort((left, right) => left.localeCompare(right)); + if (uniqueIds.length === 0) { + return 0; + } + const client = getAuthzedClient(); + await deleteRelationshipsInBoundedBatches( + client, + uniqueIds.flatMap((assignmentId) => [ + { resourceId: assignmentId, resourceType: "feedback_directory_assignment" }, + { + resourceType: "feedback_directory", + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + ]) + ); + return 1; + } + ); diff --git a/apps/web/lib/authzed/health.test.ts b/apps/web/lib/authzed/health.test.ts new file mode 100644 index 000000000000..8d210859d458 --- /dev/null +++ b/apps/web/lib/authzed/health.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { checkAuthzedHealth } from "./health"; + +const healthMocks = vi.hoisted(() => ({ + getAuthzedClient: vi.fn(), + isAuthzedEnabled: vi.fn(), + now: vi.fn(), + readSchema: vi.fn(), +})); + +vi.mock("node:perf_hooks", () => ({ + performance: { now: healthMocks.now }, +})); +vi.mock("./client", () => ({ + getAuthzedClient: healthMocks.getAuthzedClient, +})); +vi.mock("./config", () => ({ + isAuthzedEnabled: healthMocks.isAuthzedEnabled, +})); + +describe("checkAuthzedHealth", () => { + beforeEach(() => { + healthMocks.getAuthzedClient.mockReset(); + healthMocks.isAuthzedEnabled.mockReset(); + healthMocks.now.mockReset(); + healthMocks.readSchema.mockReset(); + healthMocks.getAuthzedClient.mockReturnValue({ readSchema: healthMocks.readSchema }); + healthMocks.now.mockReturnValueOnce(10).mockReturnValueOnce(22.6); + }); + + test("returns disabled without constructing a client", async () => { + healthMocks.isAuthzedEnabled.mockReturnValue(false); + + await expect(checkAuthzedHealth()).resolves.toEqual({ status: "disabled" }); + expect(healthMocks.getAuthzedClient).not.toHaveBeenCalled(); + expect(healthMocks.now).not.toHaveBeenCalled(); + }); + + test.each(["", "definition user {}"])( + "treats schema text %j as a healthy connection", + async (schemaText) => { + healthMocks.isAuthzedEnabled.mockReturnValue(true); + healthMocks.readSchema.mockResolvedValue({ schemaText }); + + const result = await checkAuthzedHealth(); + + expect(result).toEqual({ latencyMs: 13, status: "healthy" }); + expect(JSON.stringify(result)).not.toContain(schemaText || "schemaText"); + } + ); + + test.each([ + [AUTHZED_ERROR_CODES.UNAUTHENTICATED, false], + [AUTHZED_ERROR_CODES.TIMEOUT, true], + [AUTHZED_ERROR_CODES.OVERLOADED, true], + [AUTHZED_ERROR_CODES.UNAVAILABLE, true], + ] as const)("returns sanitized unhealthy result for %s", async (code, retryable) => { + healthMocks.isAuthzedEnabled.mockReturnValue(true); + healthMocks.readSchema.mockRejectedValue( + new AuthzedError({ attempts: 3, code, operation: "read_schema", retryable }) + ); + + await expect(checkAuthzedHealth()).resolves.toEqual({ + code, + latencyMs: 13, + retryable, + status: "unhealthy", + }); + }); + + test("maps unexpected failures to a non-retryable internal result", async () => { + healthMocks.isAuthzedEnabled.mockReturnValue(true); + healthMocks.readSchema.mockRejectedValue(new Error("raw sdk details")); + + await expect(checkAuthzedHealth()).resolves.toEqual({ + code: AUTHZED_ERROR_CODES.INTERNAL, + latencyMs: 13, + retryable: false, + status: "unhealthy", + }); + }); +}); diff --git a/apps/web/lib/authzed/health.ts b/apps/web/lib/authzed/health.ts new file mode 100644 index 000000000000..3ef9e6b5855c --- /dev/null +++ b/apps/web/lib/authzed/health.ts @@ -0,0 +1,39 @@ +import "server-only"; +import { performance } from "node:perf_hooks"; +import { getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { AuthzedError, type TAuthzedErrorCode, mapAuthzedError } from "./errors"; + +export type TAuthzedHealthResult = + | Readonly<{ status: "disabled" }> + | Readonly<{ latencyMs: number; status: "healthy" }> + | Readonly<{ + code: TAuthzedErrorCode; + latencyMs: number; + retryable: boolean; + status: "unhealthy"; + }>; + +const getLatencyMs = (startedAt: number): number => Math.max(0, Math.round(performance.now() - startedAt)); + +export const checkAuthzedHealth = async (): Promise => { + if (!isAuthzedEnabled()) { + return { status: "disabled" }; + } + + const startedAt = performance.now(); + + try { + await getAuthzedClient().readSchema(); + return { latencyMs: getLatencyMs(startedAt), status: "healthy" }; + } catch (error) { + const authzedError = error instanceof AuthzedError ? error : mapAuthzedError(error, "health_check", 1); + + return { + code: authzedError.code, + latencyMs: getLatencyMs(startedAt), + retryable: authzedError.retryable, + status: "unhealthy", + }; + } +}; diff --git a/apps/web/lib/authzed/index.ts b/apps/web/lib/authzed/index.ts new file mode 100644 index 000000000000..83c566ec8828 --- /dev/null +++ b/apps/web/lib/authzed/index.ts @@ -0,0 +1,27 @@ +import "server-only"; +import { getAuthzedClient as getInternalAuthzedClient } from "./client"; + +/** Public facade: list lookup stays a direct-path authorization-infrastructure operation. */ +export const getAuthzedClient = (): import("./client").TAuthzedClient => getInternalAuthzedClient(); + +export { + type TAuthzedClient, + type TAuthzedObjectReference, + type TAuthzedPermissionCheck, + type TAuthzedPermissionDecision, + type TAuthzedRelationship, + type TAuthzedRelationshipFilter, + type TAuthzedRelationshipUpdate, + type TAuthzedSchema, + type TAuthzedSchemaDiff, + type TAuthzedSubjectReference, +} from "./client"; +export { isAuthzedEnabled, type TAuthzedConsistency } from "./config"; +export { AuthzedError, type TAuthzedErrorCode } from "./errors"; +export { checkAuthzedHealth, type TAuthzedHealthResult } from "./health"; +export { + applyCanonicalAuthzedSchema, + checkCanonicalAuthzedSchema, + type TAuthzedSchemaApplyResult, + type TAuthzedSchemaCheckResult, +} from "./schema"; diff --git a/apps/web/lib/authzed/index.typecheck.test.ts b/apps/web/lib/authzed/index.typecheck.test.ts new file mode 100644 index 000000000000..65bf7dffa5d0 --- /dev/null +++ b/apps/web/lib/authzed/index.typecheck.test.ts @@ -0,0 +1,10 @@ +import "server-only"; +import { expectTypeOf, test } from "vitest"; +import { getAuthzedClient } from "./index"; + +test("keeps LookupResources out of the public AuthZed barrel", () => { + type TPublicClientHasLookup = "lookupResources" extends keyof ReturnType + ? true + : false; + expectTypeOf().toEqualTypeOf(); +}); diff --git a/apps/web/lib/authzed/metrics-buckets.test.ts b/apps/web/lib/authzed/metrics-buckets.test.ts new file mode 100644 index 000000000000..6fbd54275aa8 --- /dev/null +++ b/apps/web/lib/authzed/metrics-buckets.test.ts @@ -0,0 +1,85 @@ +import { metrics } from "@opentelemetry/api"; +import { + AggregationTemporality, + InMemoryMetricExporter, + MeterProvider, + PeriodicExportingMetricReader, +} from "@opentelemetry/sdk-metrics"; +import { afterEach, describe, expect, test } from "vitest"; + +/** + * Bucket configuration, verified against the real SDK rather than by asserting the advice object. + * + * The advice is only a *hint*: whether it takes effect depends on the SDK honouring it, so asserting the + * literal we passed in would restate the source and prove nothing. What is worth pinning is the + * boundaries that end up on the exported data point. + * + * What this guards against is concrete. The SDK's default boundaries are `[0, 5, 10, 25, … 10000]`, a + * millisecond scale, and this histogram records seconds. Under the defaults every healthy projection + * lands in the single `(0, 5]` bucket, and because `histogram_quantile` interpolates *within* a bucket, a + * p95 over observations that are all ~100ms reports something near 4.75s. The runbook's `> 0.5` alert + * would then fire continuously on healthy traffic — worse than no alert, because it teaches its audience + * to ignore it. + */ + +const HISTOGRAM_NAME = "formbricks_authzed_projection_duration_seconds"; + +const recordOneProjection = async () => { + const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE); + // A long interval so nothing exports on a timer; `forceFlush` is what drives the export here. + const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 }); + const provider = new MeterProvider({ readers: [reader] }); + metrics.setGlobalMeterProvider(provider); + + const { recordAuthzedProjection } = await import("./metrics"); + recordAuthzedProjection({ + durationMs: 100, + operation: "reconcile_organization_memberships", + projection: "organization_membership", + status: "projected", + }); + + await reader.forceFlush(); + const histogram = exporter + .getMetrics() + .flatMap((resourceMetric) => resourceMetric.scopeMetrics) + .flatMap((scopeMetric) => scopeMetric.metrics) + .find((metric) => metric.descriptor.name === HISTOGRAM_NAME); + + return { provider, value: histogram?.dataPoints[0]?.value }; +}; + +describe("projection duration histogram", () => { + let shutdown: (() => Promise) | undefined; + + afterEach(async () => { + await shutdown?.(); + shutdown = undefined; + metrics.disable(); + }); + + test("exports second-scale buckets, so a sub-second p95 is measurable", async () => { + const { provider, value } = await recordOneProjection(); + shutdown = () => provider.shutdown(); + + const boundaries = (value as Readonly<{ buckets: Readonly<{ boundaries: number[] }> }> | undefined) + ?.buckets.boundaries; + + expect(boundaries).toBeDefined(); + // The alert threshold has to fall on a boundary. Inside a bucket, the quantile it is compared against + // is an interpolation across whatever range contains it. + expect(boundaries).toContain(0.5); + // Seconds, not milliseconds: the default scale reaches 10,000 and swallows every real observation. + expect(Math.max(...(boundaries ?? []))).toBeLessThanOrEqual(10); + // And enough resolution below the threshold for a healthy value to be distinguishable from it. + expect((boundaries ?? []).filter((boundary) => boundary < 0.5)).toHaveLength(7); + }); + + test("records the observation in seconds, not milliseconds", async () => { + const { provider, value } = await recordOneProjection(); + shutdown = () => provider.shutdown(); + + // 100ms in, 0.1 recorded. A unit mismatch here would be invisible to the bucket assertions above. + expect((value as Readonly<{ sum?: number }> | undefined)?.sum).toBeCloseTo(0.1); + }); +}); diff --git a/apps/web/lib/authzed/metrics.test.ts b/apps/web/lib/authzed/metrics.test.ts new file mode 100644 index 000000000000..f65b0e691471 --- /dev/null +++ b/apps/web/lib/authzed/metrics.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AUTHZED_ERROR_CODES } from "./errors"; + +const counters = new Map }>(); +const gauges = new Map }>(); +const histograms = new Map }>(); + +vi.mock("@opentelemetry/api", () => ({ + metrics: { + getMeter: vi.fn(() => ({ + createCounter: vi.fn((name: string) => { + const instrument = { add: vi.fn() }; + counters.set(name, instrument); + return instrument; + }), + createGauge: vi.fn((name: string) => { + const instrument = { record: vi.fn() }; + gauges.set(name, instrument); + return instrument; + }), + createHistogram: vi.fn((name: string) => { + const instrument = { record: vi.fn() }; + histograms.set(name, instrument); + return instrument; + }), + })), + }, +})); + +const { + recordAuthzedOutboxDelivery, + recordAuthzedOutboxStatus, + recordAuthzedProjection, + recordAuthzedReconciliationAudit, + recordAuthzedReconciliationRepair, + recordAuthzedRequestFailure, + recordAuthzedRequestRetry, + recordAuthzedRevocationDelivery, +} = await import("./metrics"); + +const counter = (name: string) => counters.get(name)!; +const histogram = (name: string) => histograms.get(name)!; + +beforeEach(() => { + for (const instrument of counters.values()) { + instrument.add.mockClear(); + } + for (const instrument of histograms.values()) { + instrument.record.mockClear(); + } + for (const instrument of gauges.values()) { + instrument.record.mockClear(); + } +}); + +describe("recordAuthzedProjection", () => { + test.each(["projected", "failed"] as const)("records a %s outcome and its duration", (status) => { + recordAuthzedProjection({ + durationMs: 4200, + operation: "reconcile_organization_memberships", + projection: "organization_membership", + status, + }); + + const attributes = { + operation: "reconcile_organization_memberships", + projection: "organization_membership", + status, + }; + expect(counter("formbricks_authzed_projection_total").add).toHaveBeenCalledWith(1, attributes); + // Seconds, per the OpenTelemetry duration convention. + expect(histogram("formbricks_authzed_projection_duration_seconds").record).toHaveBeenCalledWith( + 4.2, + attributes + ); + }); + + test("counts a disabled projection but keeps its structural zero out of the latency histogram", () => { + recordAuthzedProjection({ + durationMs: 0, + operation: "reconcile_api_key_relationships", + projection: "api_key", + status: "disabled", + }); + + expect(counter("formbricks_authzed_projection_total").add).toHaveBeenCalledOnce(); + expect(histogram("formbricks_authzed_projection_duration_seconds").record).not.toHaveBeenCalled(); + }); + + test("names the duration instrument so both exporters produce the series the runbook queries", () => { + // The two exporters configured side by side derive the series name differently: the Prometheus + // exporter appends only `_total` and emits the unit as a comment, while OTLP's translation appends + // the unit unless the name already carries it. `_seconds` is the one spelling both agree on — and + // the runbook's histogram_quantile query names exactly this series. + expect(histograms.has("formbricks_authzed_projection_duration_seconds")).toBe(true); + // The unit-less name would export as `..._duration` on a scrape, matching nothing the runbook asks + // for; `_ms` was the original defect. + expect(histograms.has("formbricks_authzed_projection_duration")).toBe(false); + expect(histograms.has("formbricks_authzed_projection_duration_ms")).toBe(false); + }); +}); + +describe("recordAuthzedRequestFailure", () => { + test("records the sanitized code, operation, and retryability", () => { + recordAuthzedRequestFailure({ + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "write_relationships", + retryable: true, + }); + + expect(counter("formbricks_authzed_request_failures_total").add).toHaveBeenCalledWith(1, { + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "write_relationships", + retryable: true, + }); + }); +}); + +describe("recordAuthzedRequestRetry", () => { + test("records retries separately from failures", () => { + // A retry that later succeeds never reaches the failure counter, so a degraded SpiceDB would + // otherwise be invisible until it started dropping writes outright. + recordAuthzedRequestRetry({ + code: AUTHZED_ERROR_CODES.TIMEOUT, + operation: "read_relationships", + }); + + expect(counter("formbricks_authzed_request_retries_total").add).toHaveBeenCalledWith(1, { + code: AUTHZED_ERROR_CODES.TIMEOUT, + operation: "read_relationships", + }); + expect(counter("formbricks_authzed_request_failures_total").add).not.toHaveBeenCalled(); + }); +}); + +describe("recordAuthzedOutboxStatus", () => { + test("records point-in-time queue state without identifier attributes", () => { + recordAuthzedOutboxStatus({ + deadLettered: 2, + oldestPendingAgeSeconds: 47, + pending: 11, + revocationsPastCritical: 1, + revocationsPastWarning: 3, + }); + + const status = gauges.get("formbricks_authzed_projection_outbox_status")!; + expect(status.record.mock.calls).toEqual([ + [11, { state: "pending" }], + [2, { state: "dead_lettered" }], + [3, { state: "revocation_warning" }], + [1, { state: "revocation_critical" }], + ]); + expect( + gauges.get("formbricks_authzed_projection_outbox_oldest_pending_age_seconds")!.record + ).toHaveBeenCalledWith(47); + }); +}); + +describe("direct-authority recovery metrics", () => { + test("records exact revocation propagation in seconds without attributes", () => { + recordAuthzedRevocationDelivery(12_500); + + expect( + histogram("formbricks_authzed_projection_revocation_delivery_duration_seconds").record + ).toHaveBeenCalledWith(12.5); + }); + + test("records repaired and failed relationship counts separately", () => { + recordAuthzedReconciliationRepair({ failed: 2, repaired: 7 }); + + expect(counter("formbricks_authzed_reconciliation_repair_total").add.mock.calls).toEqual([ + [7, { status: "repaired" }], + [2, { status: "failed" }], + ]); + }); + + test("does not let exporter failures alter revocation delivery or repair", () => { + histogram( + "formbricks_authzed_projection_revocation_delivery_duration_seconds" + ).record.mockImplementationOnce(() => { + throw new Error("exporter unavailable"); + }); + counter("formbricks_authzed_reconciliation_repair_total").add.mockImplementationOnce(() => { + throw new Error("exporter unavailable"); + }); + + expect(() => recordAuthzedRevocationDelivery(1)).not.toThrow(); + expect(() => recordAuthzedReconciliationRepair({ failed: 0, repaired: 1 })).not.toThrow(); + }); + + test("does not let exporter failures alter delivery, drain, or audit results", () => { + counter("formbricks_authzed_projection_outbox_delivery_total").add.mockImplementationOnce(() => { + throw new Error("exporter unavailable"); + }); + gauges.get("formbricks_authzed_projection_outbox_status")!.record.mockImplementationOnce(() => { + throw new Error("exporter unavailable"); + }); + counter("formbricks_authzed_reconciliation_audit_total").add.mockImplementationOnce(() => { + throw new Error("exporter unavailable"); + }); + + expect(() => recordAuthzedOutboxDelivery({ count: 1, durationMs: 2, status: "delivered" })).not.toThrow(); + expect(() => + recordAuthzedOutboxStatus({ + deadLettered: 0, + oldestPendingAgeSeconds: 1, + pending: 1, + revocationsPastCritical: 0, + revocationsPastWarning: 0, + }) + ).not.toThrow(); + expect(() => + recordAuthzedReconciliationAudit({ drift: 0, failures: 0, status: "reconciled" }) + ).not.toThrow(); + }); +}); + +describe("attribute cardinality", () => { + test("never carries an identifier", () => { + // These attributes leave the deployment when an OTLP endpoint is configured. An organization or + // user ID here would be both a cardinality explosion and a privacy leak — the same rule the + // logger follows. + recordAuthzedProjection({ + durationMs: 1, + operation: "reconcile_api_key_relationships", + projection: "api_key", + status: "failed", + }); + recordAuthzedRequestFailure({ + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "write_relationships", + retryable: false, + }); + recordAuthzedOutboxStatus({ + deadLettered: 0, + oldestPendingAgeSeconds: null, + pending: 1, + revocationsPastCritical: 0, + revocationsPastWarning: 0, + }); + recordAuthzedReconciliationRepair({ failed: 1, repaired: 2 }); + recordAuthzedRevocationDelivery(1); + + const recordedAttributes = [ + ...counter("formbricks_authzed_projection_total").add.mock.calls, + ...counter("formbricks_authzed_request_failures_total").add.mock.calls, + ...counter("formbricks_authzed_reconciliation_repair_total").add.mock.calls, + ...gauges.get("formbricks_authzed_projection_outbox_status")!.record.mock.calls, + ].flatMap(([, attributes]) => Object.keys(attributes as object)); + + expect([...new Set(recordedAttributes)].sort()).toEqual([ + "code", + "operation", + "projection", + "retryable", + "state", + "status", + ]); + }); +}); diff --git a/apps/web/lib/authzed/metrics.ts b/apps/web/lib/authzed/metrics.ts new file mode 100644 index 000000000000..319fed1eab0d --- /dev/null +++ b/apps/web/lib/authzed/metrics.ts @@ -0,0 +1,256 @@ +import "server-only"; +import { metrics } from "@opentelemetry/api"; + +/** + * Operational metrics for AuthZed relationship sync. + * + * Projection is best-effort by design: an outage never turns a successful PostgreSQL mutation into an + * application error. That is the right trade-off, and it is also why drift can accumulate silently — + * these counters are how an operator finds out, and what tells them to run `pnpm authzed:backfill`. + * + * Uses the OpenTelemetry metrics API, which the app already exports through both the Prometheus and + * OTLP readers configured in `instrumentation-node.ts`. When neither is enabled `getMeter` returns a + * no-op meter, so recording is safe with zero configuration and costs nothing. + * + * **Every attribute is a bounded, enumerable value — never an identifier.** Same rule as the logger: + * these leave the deployment when an OTLP endpoint is configured, and an organization or user ID here + * would be both a cardinality explosion and a privacy leak. + * + * Note this covers the always-on request path only. The backfill command is a short-lived process with + * no scrape window and no flush, so its observability is the counters in its own JSON result and its + * exit code. + * + * **A deliberate deviation from the semantic conventions, which prescribe dots as namespace delimiters + * (`formbricks.authzed.projection.duration`) and say a unit need not appear in the name.** This app + * configures the Prometheus reader *and* an OTLP reader at once, and the two derive a series name + * differently: the Prometheus exporter sanitizes dots to underscores and appends no unit, while OTLP's + * Prometheus translation appends the unit unless the name already carries it. Under the conventional + * spelling the same instrument would surface as `..._duration` on a scrape and `..._duration_seconds` + * through a collector — so the runbook could not name one series, which is exactly the defect this + * naming replaced. Prometheus-style names with the unit spelled out are the only form both paths agree + * on. Revisit if the Prometheus reader is ever dropped. + */ + +const meter = metrics.getMeter("formbricks.authzed"); + +/** Projection outcomes, by operation and projector. Includes `disabled` so a misconfigured deployment is visible. */ +const projectionTotal = meter.createCounter("formbricks_authzed_projection_total", { + description: "AuthZed relationship projections by outcome", +}); + +/** + * Seconds, with the unit spelled out in the instrument name. + * + * The semantic conventions prescribe seconds for durations, which rules out the `_ms` this started as. + * The unit belongs in the *name* as well because the two exporters this app configures side by side + * derive the series name differently: `@opentelemetry/exporter-prometheus` appends only `_total`, to + * monotonic sums, and emits the unit as a `# UNIT` comment rather than a suffix, while OTLP's Prometheus + * translation appends the unit — skipping it when the name already carries it. Naming it `_seconds` is + * therefore the one spelling both paths agree on, and the runbook's alert queries can name a single + * series. Leaving the unit out of the name would export `..._duration` on a scrape and + * `..._duration_seconds` through a collector, which is how the runbook's histogram query came to match + * nothing on the scrape path. + */ +const projectionDuration = meter.createHistogram("formbricks_authzed_projection_duration_seconds", { + // The SDK's default boundaries are `[0, 5, 10, 25, … 10000]` — a millisecond scale. Recording seconds + // against them puts every healthy projection in the single `(0, 5]` bucket, and `histogram_quantile` + // interpolates within a bucket: a p95 over observations that are all ~100ms reports something close to + // 4.75s, so the runbook's `> 0.5` alert would fire continuously on healthy traffic. These are the + // semantic conventions' second-scale boundaries, which include 0.5 exactly so the alert threshold + // falls on a boundary rather than inside a bucket. + advice: { + explicitBucketBoundaries: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10], + }, + description: "Duration of AuthZed relationship projections", + unit: "s", +}); + +/** + * Requests that exhausted their retry budget. + * + * The signal that distinguishes a blip from an outage: a sustained rate here means relationships are + * being dropped and a backfill will be needed once the cause is resolved. + */ +const requestFailuresTotal = meter.createCounter("formbricks_authzed_request_failures_total", { + description: "AuthZed requests that failed after exhausting retries", +}); + +/** Retries scheduled. Elevated but non-failing means SpiceDB is degraded rather than down. */ +const requestRetriesTotal = meter.createCounter("formbricks_authzed_request_retries_total", { + description: "AuthZed requests retried after a retryable failure", +}); + +const outboxDeliveryTotal = meter.createCounter("formbricks_authzed_projection_outbox_delivery_total", { + description: "Authorization projection outbox events processed by outcome", +}); + +const outboxDeliveryDuration = meter.createHistogram( + "formbricks_authzed_projection_outbox_delivery_duration_seconds", + { + advice: { + explicitBucketBoundaries: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30], + }, + description: "Duration of an authorization projection outbox delivery batch", + unit: "s", + } +); + +const reconciliationAuditTotal = meter.createCounter("formbricks_authzed_reconciliation_audit_total", { + description: "Scheduled authorization relationship audits by outcome", +}); + +const reconciliationDriftTotal = meter.createCounter("formbricks_authzed_reconciliation_drift_total", { + description: "Attributable relationship differences observed by scheduled audits", +}); + +const reconciliationRepairTotal = meter.createCounter("formbricks_authzed_reconciliation_repair_total", { + description: "Attributable relationship repair results from scheduled reconciliation", +}); + +const revocationDeliveryDuration = meter.createHistogram( + "formbricks_authzed_projection_revocation_delivery_duration_seconds", + { + advice: { + explicitBucketBoundaries: [0.1, 0.5, 1, 2.5, 5, 10, 15, 30, 45, 60, 120, 300], + }, + description: "Time from a committed authorization revocation to successful SpiceDB delivery", + unit: "s", + } +); + +const outboxStatus = meter.createGauge("formbricks_authzed_projection_outbox_status", { + description: "Point-in-time authorization projection outbox counts by bounded state", + unit: "{event}", +}); + +const outboxOldestPendingAge = meter.createGauge( + "formbricks_authzed_projection_outbox_oldest_pending_age_seconds", + { + description: "Point-in-time age of the oldest pending authorization projection event", + unit: "s", + } +); + +export type TAuthzedProjectionMetric = Readonly<{ + durationMs: number; + operation: string; + projection: string; + status: "disabled" | "failed" | "projected"; +}>; + +export const recordAuthzedProjection = ({ + durationMs, + operation, + projection, + status, +}: TAuthzedProjectionMetric): void => { + const attributes = { operation, projection, status }; + projectionTotal.add(1, attributes); + + // `disabled` short-circuits before any work, so its duration is a structural zero rather than a + // measurement. Recording it would drag the latency distribution of every quantile toward zero on a + // deployment that has AuthZed switched off — and latency is the signal the runbook calls user-visible. + if (status !== "disabled") { + projectionDuration.record(durationMs / 1000, attributes); + } +}; + +export type TAuthzedRequestFailureMetric = Readonly<{ + code: string; + operation: string; + retryable: boolean; +}>; + +export const recordAuthzedRequestFailure = ({ + code, + operation, + retryable, +}: TAuthzedRequestFailureMetric): void => { + requestFailuresTotal.add(1, { code, operation, retryable }); +}; + +export const recordAuthzedRequestRetry = ({ + code, + operation, +}: Readonly<{ code: string; operation: string }>): void => { + requestRetriesTotal.add(1, { code, operation }); +}; + +export const recordAuthzedOutboxDelivery = ({ + count, + durationMs, + status, +}: Readonly<{ + count: number; + durationMs: number; + status: "delivered" | "failed"; +}>): void => { + try { + outboxDeliveryTotal.add(count, { status }); + outboxDeliveryDuration.record(durationMs / 1000, { status }); + } catch { + // Observability cannot turn an already-committed delivery result into an outbox failure. + } +}; + +export const recordAuthzedRevocationDelivery = (durationMs: number): void => { + try { + revocationDeliveryDuration.record(Math.max(0, durationMs) / 1_000); + } catch { + // Observability cannot turn an already-delivered revocation into an outbox failure. + } +}; + +export const recordAuthzedOutboxStatus = ({ + deadLettered, + oldestPendingAgeSeconds, + pending, + revocationsPastCritical, + revocationsPastWarning, +}: Readonly<{ + deadLettered: number; + oldestPendingAgeSeconds: number | null; + pending: number; + revocationsPastCritical: number; + revocationsPastWarning: number; +}>): void => { + try { + outboxStatus.record(pending, { state: "pending" }); + outboxStatus.record(deadLettered, { state: "dead_lettered" }); + outboxStatus.record(revocationsPastWarning, { state: "revocation_warning" }); + outboxStatus.record(revocationsPastCritical, { state: "revocation_critical" }); + outboxOldestPendingAge.record(oldestPendingAgeSeconds ?? 0); + } catch { + // A metrics exporter failure must not discard the caller's drain result. + } +}; + +export const recordAuthzedReconciliationAudit = ({ + drift, + failures, + status, +}: Readonly<{ + drift: number; + failures: number; + status: "drifted" | "failed" | "reconciled"; +}>): void => { + try { + reconciliationAuditTotal.add(1, { status }); + if (drift > 0) reconciliationDriftTotal.add(drift, { kind: "attributable" }); + if (failures > 0) reconciliationDriftTotal.add(failures, { kind: "failure" }); + } catch { + // Pruning and dead-letter recovery must still run when the exporter is unavailable. + } +}; + +export const recordAuthzedReconciliationRepair = ({ + failed, + repaired, +}: Readonly<{ failed: number; repaired: number }>): void => { + try { + if (repaired > 0) reconciliationRepairTotal.add(repaired, { status: "repaired" }); + if (failed > 0) reconciliationRepairTotal.add(failed, { status: "failed" }); + } catch { + // The second verification audit must still run when a metrics exporter is unavailable. + } +}; diff --git a/apps/web/lib/authzed/organization-membership.test.ts b/apps/web/lib/authzed/organization-membership.test.ts new file mode 100644 index 000000000000..35b0a5cce03a --- /dev/null +++ b/apps/web/lib/authzed/organization-membership.test.ts @@ -0,0 +1,334 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { OrganizationRole } from "@formbricks/database/prisma"; +import { logger } from "@formbricks/logger"; +import { type TAuthzedRelationshipUpdate, getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { + deleteOrganizationRelationships, + deleteUserOrganizationRelationships, + reconcileOrganizationMembership, + reconcileOrganizationMemberships, +} from "./organization-membership"; + +const clientMocks = { + deleteRelationships: vi.fn(), + writeRelationships: vi.fn(), +}; + +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock("./client", () => ({ + getAuthzedClient: vi.fn(), +})); + +vi.mock("./config", () => ({ + isAuthzedEnabled: vi.fn(), +})); + +const ORGANIZATION_ID = "organization-private-id"; +const USER_ID = "user-private-id"; + +const membershipRows = (role: OrganizationRole, userId = USER_ID, organizationId = ORGANIZATION_ID) => [ + { organizationId, role, userId }, +]; + +describe("organization membership projection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(getAuthzedClient).mockReturnValue( + clientMocks as unknown as ReturnType + ); + clientMocks.deleteRelationships.mockResolvedValue(undefined); + clientMocks.writeRelationships.mockResolvedValue(undefined); + }); + + test.each(["owner", "manager", "member", "billing"] as const)( + "atomically touches the %s relationship and deletes the other organization roles", + async (role) => { + vi.mocked(prisma.membership.findMany).mockResolvedValue(membershipRows(role) as never); + + await expect(reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID)).resolves.toEqual({ + passes: 1, + status: "projected", + }); + + const updates = clientMocks.writeRelationships.mock + .calls[0][0] as ReadonlyArray; + expect(updates).toHaveLength(4); + expect(updates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ relation: role }), + }), + ]) + ); + expect(updates.filter((update) => update.operation === "delete")).toHaveLength(3); + expect(updates.every(({ relationship }) => relationship.resource.objectId === ORGANIZATION_ID)).toBe( + true + ); + expect(updates.every(({ relationship }) => relationship.subject.objectId === USER_ID)).toBe(true); + expect(prisma.membership.findMany).toHaveBeenCalledWith({ + orderBy: [{ organizationId: "asc" }, { userId: "asc" }], + select: { organizationId: true, role: true, userId: true }, + where: { + OR: [{ organizationId: ORGANIZATION_ID, userId: USER_ID }], + }, + }); + } + ); + + test("projects accepted and pending Membership rows identically by reading only their role", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue(membershipRows("member") as never); + + await reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID); + + // Reading only the role is what makes accepted and pending rows project identically. + expect(prisma.membership.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: { organizationId: true, role: true, userId: true }, + }) + ); + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(1); + }); + + test("deletes every organization role when the source membership no longer exists", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([] as never); + + await expect(reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID)).resolves.toEqual({ + passes: 1, + status: "projected", + }); + + expect(clientMocks.writeRelationships.mock.calls[0][0]).toEqual( + expect.arrayContaining( + ["owner", "manager", "member", "billing"].map((relation) => + expect.objectContaining({ + operation: "delete", + relationship: expect.objectContaining({ relation }), + }) + ) + ) + ); + }); + + test("reconciles again when the source role changes during projection", async () => { + vi.mocked(prisma.membership.findMany) + .mockResolvedValueOnce(membershipRows("owner") as never) + .mockResolvedValueOnce(membershipRows("manager") as never) + .mockResolvedValueOnce(membershipRows("manager") as never) + .mockResolvedValueOnce(membershipRows("manager") as never); + + await expect(reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID)).resolves.toEqual({ + passes: 2, + status: "projected", + }); + + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(2); + expect(clientMocks.writeRelationships.mock.calls[1][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ relation: "manager" }), + }), + ]) + ); + }); + + test("returns a stable internal failure after three concurrently changing passes", async () => { + vi.mocked(prisma.membership.findMany) + .mockResolvedValueOnce(membershipRows("owner") as never) + .mockResolvedValueOnce(membershipRows("manager") as never) + .mockResolvedValueOnce(membershipRows("owner") as never) + .mockResolvedValueOnce(membershipRows("manager") as never) + .mockResolvedValueOnce(membershipRows("owner") as never) + .mockResolvedValueOnce(membershipRows("manager") as never); + + await expect(reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID)).resolves.toEqual({ + attempts: 3, + code: "authzed_projection_unstable", + retryable: false, + status: "failed", + }); + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(3); + }); + + test("does not read the database or construct a client when AuthZed is disabled", async () => { + vi.mocked(isAuthzedEnabled).mockReturnValue(false); + + await expect(reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID)).resolves.toEqual({ + status: "disabled", + }); + + expect(prisma.membership.findMany).not.toHaveBeenCalled(); + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("contains operational failures at the projection boundary with sanitized logs", async () => { + const privateCause = new Error("raw-sdk-message-with-private-token"); + vi.mocked(prisma.membership.findMany).mockResolvedValue(membershipRows("owner") as never); + clientMocks.writeRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 3, + cause: privateCause, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "write_relationships", + retryable: true, + }) + ); + + await expect(reconcileOrganizationMembership(ORGANIZATION_ID, USER_ID)).resolves.toEqual({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + const serializedLog = JSON.stringify(vi.mocked(logger.warn).mock.calls[0]); + expect(serializedLog).not.toContain(ORGANIZATION_ID); + expect(serializedLog).not.toContain(USER_ID); + expect(serializedLog).not.toContain("private-token"); + expect(serializedLog).not.toContain("raw-sdk-message"); + expect(serializedLog).toContain(AUTHZED_ERROR_CODES.UNAVAILABLE); + }); + + describe("batched reconciliation", () => { + test("reads every named membership in one query and writes one group per target", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { organizationId: "org-1", role: "owner", userId: "user-1" }, + { organizationId: "org-2", role: "billing", userId: "user-2" }, + ] as never); + + await expect( + reconcileOrganizationMemberships({ + memberships: [ + { organizationId: "org-1", userId: "user-1" }, + { organizationId: "org-2", userId: "user-2" }, + ], + }) + ).resolves.toEqual({ passes: 1, status: "projected" }); + + // One read and one write for two memberships, rather than two of each. + expect(prisma.membership.findMany).toHaveBeenCalledTimes(2); // source + verify + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(1); + expect(clientMocks.writeRelationships.mock.calls[0][0]).toHaveLength(8); + }); + + test("deletes every role for a target with no source row while preserving other targets", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { organizationId: "org-1", role: "manager", userId: "user-1" }, + ] as never); + + await reconcileOrganizationMemberships({ + memberships: [ + { organizationId: "org-1", userId: "user-1" }, + // Observed in SpiceDB but absent from PostgreSQL — the repair path. + { organizationId: "org-1", userId: "ghost-user" }, + ], + }); + + const updates = clientMocks.writeRelationships.mock + .calls[0][0] as ReadonlyArray; + const ghostUpdates = updates.filter( + ({ relationship }) => relationship.subject.objectId === "ghost-user" + ); + expect(ghostUpdates).toHaveLength(4); + expect(ghostUpdates.every(({ operation }) => operation === "delete")).toBe(true); + expect( + updates.filter( + ({ operation, relationship }) => operation === "touch" && relationship.subject.objectId === "user-1" + ) + ).toHaveLength(1); + }); + + test("deduplicates repeated targets so a membership is written once", async () => { + vi.mocked(prisma.membership.findMany).mockResolvedValue(membershipRows("owner") as never); + + await reconcileOrganizationMemberships({ + memberships: [ + { organizationId: ORGANIZATION_ID, userId: USER_ID }, + { organizationId: ORGANIZATION_ID, userId: USER_ID }, + ], + }); + + expect(clientMocks.writeRelationships.mock.calls[0][0]).toHaveLength(4); + }); + + test.each([[undefined], [[]]])( + "short-circuits an empty target set without constructing a client (%s)", + async (memberships) => { + await expect(reconcileOrganizationMemberships({ memberships })).resolves.toEqual({ + passes: 0, + status: "projected", + }); + + // `writeRelationships` rejects an empty batch, so reaching the client at all would fail. + expect(getAuthzedClient).not.toHaveBeenCalled(); + expect(prisma.membership.findMany).not.toHaveBeenCalled(); + } + ); + + test("splits a target set that exceeds the write batch limit without splitting a role group", async () => { + const memberships = Array.from({ length: 251 }, (_unused, index) => ({ + organizationId: ORGANIZATION_ID, + userId: `user-${index}`, + })); + vi.mocked(prisma.membership.findMany).mockResolvedValue([] as never); + + await expect(reconcileOrganizationMemberships({ memberships })).resolves.toEqual({ + passes: 1, + status: "projected", + }); + + // 251 targets * 4 relations = 1004 updates, so it must split, and the four updates for a single + // membership must stay in one request or the role would not change atomically. + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(2); + expect(clientMocks.writeRelationships.mock.calls[0][0]).toHaveLength(1_000); + expect(clientMocks.writeRelationships.mock.calls[1][0]).toHaveLength(4); + }); + }); + + test("deletes all organization-resource relationships after an organization cascade", async () => { + await expect(deleteOrganizationRelationships(ORGANIZATION_ID)).resolves.toEqual({ + passes: 1, + status: "projected", + }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + resourceId: ORGANIZATION_ID, + resourceType: "organization", + }); + }); + + test("deletes only organization relationships for a deleted user subject", async () => { + await expect(deleteUserOrganizationRelationships(USER_ID)).resolves.toEqual({ + passes: 1, + status: "projected", + }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + resourceType: "organization", + subject: { + objectId: USER_ID, + objectType: "user", + }, + }); + }); +}); diff --git a/apps/web/lib/authzed/organization-membership.ts b/apps/web/lib/authzed/organization-membership.ts new file mode 100644 index 000000000000..f5aafd878239 --- /dev/null +++ b/apps/web/lib/authzed/organization-membership.ts @@ -0,0 +1,167 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import type { OrganizationRole } from "@formbricks/database/prisma"; +import { type TAuthzedClient, type TAuthzedRelationshipUpdate, getAuthzedClient } from "./client"; +import { + AUTHZED_MAX_RECONCILIATION_PASSES, + AuthzedProjectionUnstableError, + type TAuthzedProjectionResult, + runBestEffortProjection, +} from "./projection"; +import { packRelationshipUpdateGroups } from "./relationship-batches"; +import { ORGANIZATION_RELATIONS } from "./relationship-map"; + +export type { TAuthzedProjectionResult } from "./projection"; + +const ORGANIZATION_RELATION_NAMES = Object.values(ORGANIZATION_RELATIONS); + +export type TOrganizationMembershipProjectionTarget = Readonly<{ + organizationId: string; + userId: string; +}>; + +export type TOrganizationMembershipProjectionTargets = Readonly<{ + memberships?: ReadonlyArray; +}>; + +type TOrganizationMembershipSnapshot = ReadonlyArray< + Readonly<{ organizationId: string; role: OrganizationRole; userId: string }> +>; + +/** Length-prefixed so `("ab", "c")` and `("a", "bc")` cannot collide. */ +const pairKey = (first: string, second: string): string => `${first.length}:${first}${second}`; + +const normalizeTargets = ( + targets: TOrganizationMembershipProjectionTargets +): ReadonlyArray => { + const uniqueTargets = new Map(); + + for (const target of targets.memberships ?? []) { + uniqueTargets.set(pairKey(target.organizationId, target.userId), target); + } + + // Deterministic ordering is what makes the snapshot comparison below a plain string equality. + return [...uniqueTargets.values()].sort((left, right) => + pairKey(left.organizationId, left.userId).localeCompare(pairKey(right.organizationId, right.userId)) + ); +}; + +const readSnapshot = async ( + targets: ReadonlyArray +): Promise => + prisma.membership.findMany({ + where: { + OR: targets.map(({ organizationId, userId }) => ({ organizationId, userId })), + }, + // The current contract does not gate organization membership on `accepted`. Project every row so + // the SpiceDB graph preserves that behavior exactly. + select: { organizationId: true, role: true, userId: true }, + orderBy: [{ organizationId: "asc" }, { userId: "asc" }], + }); + +const snapshotsMatch = ( + left: TOrganizationMembershipSnapshot, + right: TOrganizationMembershipSnapshot +): boolean => JSON.stringify(left) === JSON.stringify(right); + +const createMembershipUpdates = ( + target: TOrganizationMembershipProjectionTarget, + role: OrganizationRole | null +): ReadonlyArray => + ORGANIZATION_RELATION_NAMES.map((relation) => ({ + operation: role !== null && relation === ORGANIZATION_RELATIONS[role] ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: target.organizationId, objectType: "organization" }, + subject: { objectId: target.userId, objectType: "user" }, + }, + })); + +const writeSnapshot = async ( + client: TAuthzedClient, + targets: ReadonlyArray, + snapshot: TOrganizationMembershipSnapshot +): Promise => { + const rolesByPair = new Map( + snapshot.map((membership) => [pairKey(membership.organizationId, membership.userId), membership.role]) + ); + + // A target with no row yields four deletes, so a membership removed outside a mutation hook — or + // one that only ever existed in SpiceDB — is healed by being named here. + const updateGroups = targets.map((target) => [ + ...createMembershipUpdates( + target, + rolesByPair.get(pairKey(target.organizationId, target.userId)) ?? null + ), + ]); + + for (const batch of packRelationshipUpdateGroups(updateGroups)) { + await client.writeRelationships(batch); + } +}; + +/** + * Reconcile every named organization membership. + * + * Targets are explicit `(organizationId, userId)` pairs rather than a list of organizations to expand. + * That is deliberate: expanding an organization into its current PostgreSQL memberships could only + * ever produce targets that still exist, so a relationship present in SpiceDB with no source row + * would never be named and never be removed. Naming pairs lets a caller feed in what it observed in + * SpiceDB as well as what PostgreSQL holds, which is what makes stale-relationship repair possible. + */ +export const reconcileOrganizationMemberships = async ( + targets: TOrganizationMembershipProjectionTargets +): Promise => + runBestEffortProjection("reconcile_organization_memberships", "organization_membership", async () => { + const normalizedTargets = normalizeTargets(targets); + // `writeRelationships` rejects an empty batch, so an empty target set must short-circuit before + // the client is even constructed. + if (normalizedTargets.length === 0) { + return 0; + } + + const client = getAuthzedClient(); + for (let pass = 1; pass <= AUTHZED_MAX_RECONCILIATION_PASSES; pass++) { + const sourceSnapshot = await readSnapshot(normalizedTargets); + await writeSnapshot(client, normalizedTargets, sourceSnapshot); + + const verifiedSnapshot = await readSnapshot(normalizedTargets); + if (snapshotsMatch(sourceSnapshot, verifiedSnapshot)) { + return pass; + } + } + + throw new AuthzedProjectionUnstableError(); + }); + +/** Reconcile a single membership. Retained for the mutation-hook call sites. */ +export const reconcileOrganizationMembership = async ( + organizationId: string, + userId: string +): Promise => + reconcileOrganizationMemberships({ memberships: [{ organizationId, userId }] }); + +export const deleteOrganizationRelationships = async ( + organizationId: string +): Promise => + runBestEffortProjection("delete_organization_relationships", "organization_membership", async () => { + await getAuthzedClient().deleteRelationships({ + resourceId: organizationId, + resourceType: "organization", + }); + return 1; + }); + +export const deleteUserOrganizationRelationships = async ( + userId: string +): Promise => + runBestEffortProjection("delete_user_organization_relationships", "organization_membership", async () => { + await getAuthzedClient().deleteRelationships({ + resourceType: "organization", + subject: { + objectId: userId, + objectType: "user", + }, + }); + return 1; + }); diff --git a/apps/web/lib/authzed/organization-parent.ts b/apps/web/lib/authzed/organization-parent.ts new file mode 100644 index 000000000000..a752d47a8860 --- /dev/null +++ b/apps/web/lib/authzed/organization-parent.ts @@ -0,0 +1,31 @@ +import "server-only"; +import type { TAuthzedClient } from "./client"; +import { deleteRelationshipsInBoundedBatches } from "./relationship-batches"; + +export type TOrganizationParentTarget = Readonly<{ + resourceId: string; + resourceType: string; +}>; + +/** + * Clear every projected organization parent before the current PostgreSQL parent is restored. + * + * SpiceDB relations are additive, so touching a new parent cannot replace an old one. Clearing the + * relation by resource ID removes both the current and any stale parent without needing to know the + * previous PostgreSQL value. The caller must restore the current parent before reporting projection + * success. Outbox revocations therefore remain pending, and authorization stays fail-closed, until + * the complete clear-then-rebuild operation succeeds. + */ +export const deleteOrganizationParentRelationships = async ( + client: TAuthzedClient, + targets: ReadonlyArray +): Promise => { + await deleteRelationshipsInBoundedBatches( + client, + targets.map(({ resourceId, resourceType }) => ({ + relation: "organization", + resourceId, + resourceType, + })) + ); +}; diff --git a/apps/web/lib/authzed/outbox-cli-command.test.ts b/apps/web/lib/authzed/outbox-cli-command.test.ts new file mode 100644 index 000000000000..f2af61f3eaf1 --- /dev/null +++ b/apps/web/lib/authzed/outbox-cli-command.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; +import { parseAuthzedOutboxCliCommand } from "./outbox-cli-command"; + +describe("AuthZed outbox CLI command", () => { + test.each([ + [["status"], { action: "status" }], + [["replay"], { action: "replay" }], + [["drain"], { action: "drain", maxBatches: 100 }], + [["drain", "--max-batches=1"], { action: "drain", maxBatches: 1 }], + [["drain", "--max-batches=1000"], { action: "drain", maxBatches: 1000 }], + ])("parses %j", (args, expected) => { + expect(parseAuthzedOutboxCliCommand(args as string[])).toEqual(expected); + }); + + test.each([ + [[]], + [["unknown"]], + [["status", "extra"]], + [["replay", "extra"]], + [["drain", "--max-batches=0"]], + [["drain", "--max-batches=1001"]], + [["drain", "--max-batches=1", "--max-batches=2"]], + ])("rejects %j", (args) => { + expect(parseAuthzedOutboxCliCommand(args)).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/authzed/outbox-cli-command.ts b/apps/web/lib/authzed/outbox-cli-command.ts new file mode 100644 index 000000000000..f3e7b05aeed7 --- /dev/null +++ b/apps/web/lib/authzed/outbox-cli-command.ts @@ -0,0 +1,25 @@ +import "server-only"; + +export type TAuthzedOutboxCliCommand = + | Readonly<{ action: "drain"; maxBatches: number }> + | Readonly<{ action: "replay" }> + | Readonly<{ action: "status" }>; + +const MAX_BATCHES = 1_000; + +export const parseAuthzedOutboxCliCommand = ( + args: ReadonlyArray +): TAuthzedOutboxCliCommand | undefined => { + const [action, ...flags] = args; + if (action === "status" || action === "replay") { + return flags.length === 0 ? { action } : undefined; + } + if (action !== "drain") return undefined; + if (flags.length === 0) return { action, maxBatches: 100 }; + if (flags.length !== 1 || !flags[0].startsWith("--max-batches=")) return undefined; + + const value = flags[0].slice("--max-batches=".length); + if (!/^[1-9]\d{0,3}$/.test(value)) return undefined; + const maxBatches = Number(value); + return maxBatches <= MAX_BATCHES ? { action, maxBatches } : undefined; +}; diff --git a/apps/web/lib/authzed/outbox-cli.test.ts b/apps/web/lib/authzed/outbox-cli.test.ts new file mode 100644 index 000000000000..c1471b49150d --- /dev/null +++ b/apps/web/lib/authzed/outbox-cli.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { runAuthzedOutboxCli } from "./outbox-cli"; + +const dependencies = { + closeClient: vi.fn(), + drain: vi.fn(), + replay: vi.fn(), + status: vi.fn(), + writeOutput: vi.fn(), +}; + +describe("AuthZed outbox CLI", () => { + beforeEach(() => vi.clearAllMocks()); + + test("prints a healthy identifier-free status", async () => { + dependencies.status.mockResolvedValue({ + deadLettered: 0, + oldestPendingAgeSeconds: 4, + overdueRevocations: 0, + pending: 2, + revocationsPastCritical: 0, + revocationsPastWarning: 0, + }); + + await expect(runAuthzedOutboxCli({ action: "status" }, dependencies)).resolves.toBe(0); + expect(dependencies.writeOutput).toHaveBeenCalledWith( + '{"deadLettered":0,"oldestPendingAgeSeconds":4,"overdueRevocations":0,"pending":2,"revocationsPastCritical":0,"revocationsPastWarning":0,"status":"healthy"}\n' + ); + expect(dependencies.closeClient).toHaveBeenCalledOnce(); + }); + + test("uses exit 2 for stale revocations without exposing rows", async () => { + dependencies.status.mockResolvedValue({ + deadLettered: 1, + oldestPendingAgeSeconds: 61, + overdueRevocations: 1, + pending: 1, + revocationsPastCritical: 1, + revocationsPastWarning: 1, + }); + + await expect(runAuthzedOutboxCli({ action: "status" }, dependencies)).resolves.toBe(2); + const output = dependencies.writeOutput.mock.calls[0][0] as string; + expect(JSON.parse(output)).toEqual({ + deadLettered: 1, + oldestPendingAgeSeconds: 61, + overdueRevocations: 1, + pending: 1, + revocationsPastCritical: 1, + revocationsPastWarning: 1, + status: "critical", + }); + expect(output).not.toContain("primaryId"); + expect(output).not.toContain("secondaryId"); + }); + + test("drains and replays with stable exit codes", async () => { + dependencies.drain.mockResolvedValue({ + claimed: 2, + deadLettered: 0, + delivered: 2, + failed: 0, + remaining: 0, + status: "drained", + }); + dependencies.replay.mockResolvedValue(3); + + await expect(runAuthzedOutboxCli({ action: "drain", maxBatches: 4 }, dependencies)).resolves.toBe(0); + expect(dependencies.drain).toHaveBeenCalledWith(4); + + await expect(runAuthzedOutboxCli({ action: "replay" }, dependencies)).resolves.toBe(0); + expect(dependencies.writeOutput).toHaveBeenLastCalledWith('{"replayed":3,"status":"replayed"}\n'); + }); + + test("sanitizes unexpected failures", async () => { + dependencies.status.mockRejectedValue(new Error("token and row identifier")); + + await expect(runAuthzedOutboxCli({ action: "status" }, dependencies)).resolves.toBe(1); + expect(dependencies.writeOutput).toHaveBeenCalledWith( + '{"code":"authzed_internal","retryable":false,"status":"failed"}\n' + ); + }); +}); diff --git a/apps/web/lib/authzed/outbox-cli.ts b/apps/web/lib/authzed/outbox-cli.ts new file mode 100644 index 000000000000..55ab73025565 --- /dev/null +++ b/apps/web/lib/authzed/outbox-cli.ts @@ -0,0 +1,66 @@ +import "server-only"; +import { closeAuthzedClient } from "./client"; +import type { TAuthzedOutboxCliCommand } from "./outbox-cli-command"; +import { drainAuthzedOutbox } from "./outbox-processor"; +import { getAuthzedOutboxStatus, replayAuthzedOutboxDeadLetters } from "./outbox-repository"; + +type TOutboxCliDependencies = Readonly<{ + closeClient: () => void; + drain: typeof drainAuthzedOutbox; + replay: typeof replayAuthzedOutboxDeadLetters; + status: typeof getAuthzedOutboxStatus; + writeOutput: (output: string) => void; +}>; + +const defaultDependencies: TOutboxCliDependencies = { + closeClient: closeAuthzedClient, + drain: drainAuthzedOutbox, + replay: replayAuthzedOutboxDeadLetters, + status: getAuthzedOutboxStatus, + writeOutput: (output) => process.stdout.write(output), +}; + +export const runAuthzedOutboxCli = async ( + command: TAuthzedOutboxCliCommand, + overrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...overrides }; + let result: object; + let exitCode = 1; + + try { + switch (command.action) { + case "status": { + const status = await dependencies.status(); + const health = + status.deadLettered > 0 || status.revocationsPastCritical > 0 + ? "critical" + : status.revocationsPastWarning > 0 + ? "warning" + : "healthy"; + result = { ...status, status: health }; + exitCode = health === "healthy" ? 0 : 2; + break; + } + case "drain": { + const drainResult = await dependencies.drain(command.maxBatches); + result = drainResult; + exitCode = drainResult.status === "drained" ? 0 : 2; + break; + } + case "replay": { + result = { replayed: await dependencies.replay(), status: "replayed" }; + exitCode = 0; + break; + } + } + } catch { + result = { code: "authzed_internal", retryable: false, status: "failed" }; + exitCode = 1; + } finally { + dependencies.closeClient(); + } + + dependencies.writeOutput(`${JSON.stringify(result)}\n`); + return exitCode; +}; diff --git a/apps/web/lib/authzed/outbox-freshness.test.ts b/apps/web/lib/authzed/outbox-freshness.test.ts new file mode 100644 index 000000000000..35347145093d --- /dev/null +++ b/apps/web/lib/authzed/outbox-freshness.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { hasStaleAuthzedRevocation } from "./outbox-repository"; + +vi.mock("./outbox-repository", () => ({ hasStaleAuthzedRevocation: vi.fn() })); + +/** + * Each test imports the module fresh, because the memo is module state on purpose: React's `cache()` + * used to sit here and silently did nothing in Route Handlers, which is where nine of the eleven + * rollout targets live. + */ +const loadGuard = async () => { + vi.resetModules(); + return import("./outbox-freshness"); +}; + +describe("AuthZed projection freshness guard", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.useRealTimers()); + + test("allows a fresh graph", async () => { + vi.mocked(hasStaleAuthzedRevocation).mockResolvedValue(false); + const { assertAuthzedProjectionFreshness } = await loadGuard(); + + await expect(assertAuthzedProjectionFreshness()).resolves.toBeUndefined(); + }); + + test("fails closed for an overdue or dead-letter revocation", async () => { + vi.mocked(hasStaleAuthzedRevocation).mockResolvedValue(true); + const { assertAuthzedProjectionFreshness } = await loadGuard(); + + await expect(assertAuthzedProjectionFreshness()).rejects.toMatchObject({ + code: "authzed_projection_stale", + retryable: false, + }); + }); + + test("collapses a fan-out of concurrent checks into one query", async () => { + // A request makes many checks — three for workspace navigation, one per directory in the + // feedback-directory fan-out — and they all start before any of them finishes. + let release: (stale: boolean) => void = () => undefined; + vi.mocked(hasStaleAuthzedRevocation).mockReturnValue( + new Promise((resolve) => { + release = resolve; + }) + ); + const { assertAuthzedProjectionFreshness } = await loadGuard(); + + const checks = Promise.all(Array.from({ length: 5 }, () => assertAuthzedProjectionFreshness())); + release(false); + + await expect(checks).resolves.toHaveLength(5); + expect(hasStaleAuthzedRevocation).toHaveBeenCalledOnce(); + }); + + test("re-reads once the memo window has elapsed", async () => { + // `performance` is not in vitest's default `toFake` set, and the memo reads it rather than + // `Date.now()` so a backwards clock step cannot freeze the guard. + vi.useFakeTimers({ toFake: ["performance"] }); + vi.mocked(hasStaleAuthzedRevocation).mockResolvedValue(false); + const { AUTHZED_FRESHNESS_MEMO_TTL_MS, assertAuthzedProjectionFreshness } = await loadGuard(); + + await assertAuthzedProjectionFreshness(); + await assertAuthzedProjectionFreshness(); + expect(hasStaleAuthzedRevocation).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(AUTHZED_FRESHNESS_MEMO_TTL_MS); + await assertAuthzedProjectionFreshness(); + expect(hasStaleAuthzedRevocation).toHaveBeenCalledTimes(2); + }); + + test("keeps denying rather than memoizing a failed read", async () => { + vi.mocked(hasStaleAuthzedRevocation) + .mockRejectedValueOnce(new Error("connection terminated")) + .mockResolvedValue(false); + const { assertAuthzedProjectionFreshness } = await loadGuard(); + + await expect(assertAuthzedProjectionFreshness()).rejects.toThrow("connection terminated"); + // The rejection is not an answer, so the next check must ask again rather than reuse it. + await expect(assertAuthzedProjectionFreshness()).resolves.toBeUndefined(); + expect(hasStaleAuthzedRevocation).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/lib/authzed/outbox-freshness.ts b/apps/web/lib/authzed/outbox-freshness.ts new file mode 100644 index 000000000000..3162f551c121 --- /dev/null +++ b/apps/web/lib/authzed/outbox-freshness.ts @@ -0,0 +1,65 @@ +import "server-only"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { hasStaleAuthzedRevocation } from "./outbox-repository"; + +/** + * How long one staleness answer is reused across authorization checks. + * + * React's `cache()` was here and did almost nothing: the Route Handler runtime carries no cache + * dispatcher, so `cache` falls through to a plain call there — and nine of the eleven rollout targets + * are Route-Handler-only. Every authorization check therefore paid its own PostgreSQL round trip, and + * a request makes many checks: three for workspace navigation, one per access item in the action + * client, one per directory in the feedback-directory fan-out. + * + * A short process-wide memo dedupes across all of them, including the surfaces that open no + * authorization context at all and the shadow comparisons that run after the response. The cost is + * bounded and explicit: the guard can arm up to this much later than the sixty-second window it + * enforces. + */ +export const AUTHZED_FRESHNESS_MEMO_TTL_MS = 1_000; + +// Monotonic on purpose. `Date.now()` steps backwards on an NTP correction, a VM resume, or a host +// booting from a bad RTC, and a negative elapsed time is always under the TTL — which would freeze +// this answer, in whichever direction it happened to hold, for the entire duration of the step. That +// is the one way the bound below could be exceeded without limit, on every process sharing the clock. +let memoizedAt = Number.NEGATIVE_INFINITY; +let memoizedValue = false; +let inFlight: Promise | null = null; + +const readStaleness = (): Promise => { + if (performance.now() - memoizedAt < AUTHZED_FRESHNESS_MEMO_TTL_MS) return Promise.resolve(memoizedValue); + + // Concurrent checks share one read. A value-only memo would not collapse a fan-out, because every + // check in it starts before any of them has finished. + inFlight ??= hasStaleAuthzedRevocation() + .then((stale) => { + memoizedValue = stale; + memoizedAt = performance.now(); + return stale; + }) + // Deliberately not memoized on rejection: a failed read must keep denying, not be cached as an + // answer. Clearing the slot here also lets the next check retry rather than reuse the rejection. + .finally(() => { + inFlight = null; + }); + + return inFlight; +}; + +/** + * Refuse to authorize from a graph that may still contain access revoked in PostgreSQL. + * + * This is deliberately called only by the SpiceDB evaluator. The bridge release keeps the legacy + * evaluator authoritative while the durable queue is populated and drained; direct authority fails + * closed once a revocation is older than the bounded delivery window or has entered dead letter. + */ +export const assertAuthzedProjectionFreshness = async (): Promise => { + if (!(await readStaleness())) return; + + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.PROJECTION_STALE, + operation: "authorization_projection_freshness", + retryable: false, + }); +}; diff --git a/apps/web/lib/authzed/outbox-migration.test.ts b/apps/web/lib/authzed/outbox-migration.test.ts new file mode 100644 index 000000000000..6bd895956d20 --- /dev/null +++ b/apps/web/lib/authzed/outbox-migration.test.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +const migration = readFileSync( + new URL( + "../../../../packages/database/migration/20260818120000_add_authzed_projection_outbox/migration.sql", + import.meta.url + ), + "utf8" +); + +describe("AuthZed projection outbox migration contract", () => { + test("covers every authorization relationship source table", () => { + const sourceTables = [ + "ApiKey", + "ApiKeyWorkspace", + "FeedbackDirectory", + "FeedbackDirectoryWorkspace", + "Membership", + "Organization", + "Team", + "TeamUser", + "User", + "Workspace", + "WorkspaceTeam", + ]; + + for (const sourceTable of sourceTables) { + expect(migration).toContain(` ON "${sourceTable}"`); + } + expect(migration.match(/CREATE TRIGGER/g)).toHaveLength(sourceTables.length); + }); + + test("enqueues the previous relationship key as a revocation when a source pair moves", () => { + expect(migration).toContain( + "previous_source ->> primary_field IS DISTINCT FROM source ->> primary_field" + ); + expect(migration).toContain( + "previous_source ->> secondary_field IS DISTINCT FROM source ->> secondary_field" + ); + expect(migration).toContain("previous_source ->> primary_field"); + expect(migration).toMatch(/previous_source[\s\S]*?true,[\s\S]*?NOW\(\)/); + }); + + test("watches only the columns that can change a projected relationship", () => { + expect(migration).toContain('UPDATE OF "role", "accepted", "organizationId", "userId"'); + expect(migration).toContain('UPDATE OF "permission", "workspaceId", "teamId"'); + expect(migration).toContain('UPDATE OF "permission", "apiKeyId", "workspaceId"'); + expect(migration).not.toContain('UPDATE OF "lastUsedAt"'); + }); + + // Whether the classifier is *correct* is only observable against a real PostgreSQL, so the + // transition table lives in outbox-trigger.integration.test.ts. What a text assertion can see, and + // a runtime one cannot, is that no future target type is added without a deliberate decision: + // the CASE has no catch-all beyond `ELSE false`, so an unmapped type is a revocation. + test("classifies updates through a deny-by-default grant predicate", () => { + expect(migration).toContain("CREATE OR REPLACE FUNCTION authzed_projection_is_grant("); + expect(migration).toContain("ELSE NOT authzed_projection_is_grant(target_type, previous_source, source)"); + expect(migration).toContain("ELSE false"); + expect(migration).not.toContain("TG_OP <> 'INSERT'"); + }); + + test("keeps every hot-path index off the seven days of retained history", () => { + const createIndexStatements = migration.match(/CREATE INDEX IF NOT EXISTS[\s\S]*?;/g) ?? []; + expect(createIndexStatements).not.toHaveLength(0); + for (const statement of createIndexStatements) { + expect(statement).toMatch(/WHERE "processedAt" IS (NULL|NOT NULL)/); + } + }); + + // Repeated execution and catalog convergence are verified against PostgreSQL in + // outbox-trigger.integration.test.ts. This source-level count remains intentionally exhaustive so + // a new relationship source cannot omit its matching trigger replacement. + test("declares every relationship-source trigger replacement", () => { + expect(migration.match(/DROP TRIGGER IF EXISTS/g)).toHaveLength(11); + }); +}); diff --git a/apps/web/lib/authzed/outbox-processor.test.ts b/apps/web/lib/authzed/outbox-processor.test.ts new file mode 100644 index 000000000000..7b50dd7d9209 --- /dev/null +++ b/apps/web/lib/authzed/outbox-processor.test.ts @@ -0,0 +1,374 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { reconcileApiKeyRelationships } from "./api-key"; +import { isAuthzedEnabled } from "./config"; +import { reconcileFeedbackDirectoryRelationships } from "./feedback-directory"; +import { recordAuthzedRevocationDelivery } from "./metrics"; +import { + deleteOrganizationRelationships, + deleteUserOrganizationRelationships, + reconcileOrganizationMemberships, +} from "./organization-membership"; +import { + drainAuthzedOutbox, + processAuthzedOutboxBatch, + processAuthzedProjectionDeliveryJob, +} from "./outbox-processor"; +import { + claimAuthzedOutboxEvents, + getAuthzedOutboxStatus, + markAuthzedOutboxEventsDelivered, + markAuthzedOutboxEventsFailed, +} from "./outbox-repository"; +import type { TAuthzedOutboxEvent, TAuthzedOutboxTargetType } from "./outbox-types"; +import type { TAuthzedProjectionResult } from "./projection"; +import { deleteUserTeamRelationships, reconcileTeamWorkspaceRelationships } from "./team-workspace"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + organization: { findMany: vi.fn() }, + user: { findMany: vi.fn() }, + }, +})); +vi.mock("@formbricks/logger", () => ({ logger: { warn: vi.fn() } })); +vi.mock("./api-key", () => ({ reconcileApiKeyRelationships: vi.fn() })); +vi.mock("./config", () => ({ isAuthzedEnabled: vi.fn() })); +vi.mock("./feedback-directory", () => ({ reconcileFeedbackDirectoryRelationships: vi.fn() })); +vi.mock("./metrics", () => ({ + recordAuthzedOutboxDelivery: vi.fn(), + recordAuthzedOutboxStatus: vi.fn(), + recordAuthzedRevocationDelivery: vi.fn(), +})); +vi.mock("./organization-membership", () => ({ + deleteOrganizationRelationships: vi.fn(), + deleteUserOrganizationRelationships: vi.fn(), + reconcileOrganizationMemberships: vi.fn(), +})); +vi.mock("./outbox-repository", () => ({ + AUTHZED_OUTBOX_BATCH_SIZE: 200, + claimAuthzedOutboxEvents: vi.fn(), + createAuthzedOutboxLeaseOwner: vi.fn(() => "lease"), + getAuthzedOutboxStatus: vi.fn(), + markAuthzedOutboxEventsDelivered: vi.fn(), + markAuthzedOutboxEventsFailed: vi.fn(), +})); +vi.mock("./team-workspace", () => ({ + deleteUserTeamRelationships: vi.fn(), + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + +const projected = { passes: 1, status: "projected" } as const; + +type TFailedProjectionResult = Extract; + +const failed = (code: TFailedProjectionResult["code"], retryable: boolean): TFailedProjectionResult => ({ + attempts: 3, + code, + retryable, + status: "failed", +}); + +const event = ( + targetType: TAuthzedOutboxTargetType, + primaryId: string, + secondaryId: string | null = null +): TAuthzedOutboxEvent => ({ + attempts: 1, + createdAt: new Date(0), + id: `${targetType}-${primaryId}-${secondaryId ?? ""}`, + isRevocation: false, + primaryId, + secondaryId, + targetType, +}); + +/** Every target type, one event each, in the order `buildDeliveryGroups` consumes them. */ +const everyTarget = (): ReadonlyArray => [ + event("organization", "org"), + event("membership", "org", "user"), + event("user", "deleted-user"), + event("team", "team"), + event("team_membership", "team", "user"), + event("workspace", "workspace"), + event("workspace_team", "workspace", "team"), + event("api_key", "key"), + event("api_key_workspace", "key", "workspace"), + event("feedback_directory", "directory"), + event("feedback_directory_assignment", "directory", "workspace"), +]; + +const sorted = (ids: ReadonlyArray): ReadonlyArray => + [...ids].sort((left, right) => left.localeCompare(right)); + +/** Sorted because delivery reports in group order, which is not the order events were claimed in. */ +const deliveredIds = (): ReadonlyArray => + sorted(vi.mocked(markAuthzedOutboxEventsDelivered).mock.calls.flatMap(([, ids]) => [...ids])); + +describe("AuthZed projection outbox processor", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(reconcileOrganizationMemberships).mockResolvedValue(projected); + vi.mocked(deleteOrganizationRelationships).mockResolvedValue(projected); + vi.mocked(deleteUserOrganizationRelationships).mockResolvedValue(projected); + vi.mocked(deleteUserTeamRelationships).mockResolvedValue(projected); + vi.mocked(reconcileTeamWorkspaceRelationships).mockResolvedValue(projected); + vi.mocked(reconcileApiKeyRelationships).mockResolvedValue(projected); + vi.mocked(reconcileFeedbackDirectoryRelationships).mockResolvedValue(projected); + vi.mocked(prisma.organization.findMany).mockResolvedValue([]); + vi.mocked(prisma.user.findMany).mockResolvedValue([]); + vi.mocked(markAuthzedOutboxEventsFailed).mockResolvedValue(0); + vi.mocked(getAuthzedOutboxStatus).mockResolvedValue({ + deadLettered: 0, + oldestPendingAgeSeconds: null, + overdueRevocations: 0, + pending: 0, + revocationsPastCritical: 0, + revocationsPastWarning: 0, + }); + }); + + test("maps every durable target to the existing idempotent reconcilers", async () => { + const events = everyTarget(); + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue(events); + + await expect(processAuthzedOutboxBatch("lease")).resolves.toEqual({ + claimed: 11, + deadLettered: 0, + delivered: 11, + failed: 0, + }); + + expect(reconcileOrganizationMemberships).toHaveBeenCalledWith({ + memberships: [{ organizationId: "org", userId: "user" }], + }); + expect(deleteOrganizationRelationships).toHaveBeenCalledWith("org"); + expect(deleteUserOrganizationRelationships).toHaveBeenCalledWith("deleted-user"); + expect(deleteUserTeamRelationships).toHaveBeenCalledWith("deleted-user"); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamIds: ["team"], + teamMemberships: [{ teamId: "team", userId: "user" }], + workspaceIds: ["workspace"], + workspaceTeamGrants: [{ teamId: "team", workspaceId: "workspace" }], + }); + expect(reconcileApiKeyRelationships).toHaveBeenCalledWith({ + apiKeyIds: ["key"], + apiKeyWorkspaceGrants: [{ apiKeyId: "key", workspaceId: "workspace" }], + }); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledWith({ + assignments: [{ feedbackDirectoryId: "directory", workspaceId: "workspace" }], + feedbackDirectoryIds: ["directory"], + }); + expect(deliveredIds()).toEqual(sorted(events.map(({ id }) => id))); + }); + + test("delivers every healthy group when one of them fails", async () => { + const events = everyTarget(); + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue(events); + vi.mocked(reconcileApiKeyRelationships).mockResolvedValue(failed("authzed_internal", false)); + + await expect(processAuthzedOutboxBatch("lease")).resolves.toMatchObject({ + delivered: 9, + failed: 2, + }); + + const apiKeyIds = events + .filter(({ targetType }) => targetType === "api_key" || targetType === "api_key_workspace") + .map(({ id }) => id); + expect(deliveredIds()).toEqual( + sorted(events.filter(({ id }) => !apiKeyIds.includes(id)).map(({ id }) => id)) + ); + expect(markAuthzedOutboxEventsFailed).toHaveBeenCalledWith("lease", apiKeyIds, "authzed_internal", { + attributable: false, + retryable: false, + }); + }); + + test("stops spending retry budget once a fault is known to be transient", async () => { + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue(everyTarget()); + // The membership group runs first, so a retryable failure there must release the rest untried. + vi.mocked(reconcileOrganizationMemberships).mockResolvedValue(failed("authzed_unavailable", true)); + + await expect(processAuthzedOutboxBatch("lease")).resolves.toMatchObject({ delivered: 0, failed: 11 }); + + expect(reconcileTeamWorkspaceRelationships).not.toHaveBeenCalled(); + expect(reconcileApiKeyRelationships).not.toHaveBeenCalled(); + expect(reconcileFeedbackDirectoryRelationships).not.toHaveBeenCalled(); + // Nothing here earned a permanent failure: dead-lettering needs `!retryable && isolated`, and a + // transient fault never satisfies the first half however the events happened to be grouped. + for (const [, , , attribution] of vi.mocked(markAuthzedOutboxEventsFailed).mock.calls) { + expect(attribution).toMatchObject({ retryable: true }); + } + }); + + test("keeps going when a failure is local to one group", async () => { + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue(everyTarget()); + vi.mocked(reconcileOrganizationMemberships).mockResolvedValue(failed("authzed_internal", false)); + + await processAuthzedOutboxBatch("lease"); + + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalled(); + expect(reconcileApiKeyRelationships).toHaveBeenCalled(); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalled(); + }); + + test("treats an unstable source snapshot as transient rather than as a poison event", async () => { + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue([event("membership", "org", "user")]); + // The projector reports this as non-retryable, but it means the row moved under the reconciler. + vi.mocked(reconcileOrganizationMemberships).mockResolvedValue( + failed("authzed_projection_unstable", false) + ); + + await processAuthzedOutboxBatch("lease"); + + expect(markAuthzedOutboxEventsFailed).toHaveBeenCalledWith( + "lease", + ["membership-org-user"], + "authzed_projection_unstable", + // Retryable is what matters: it is what keeps the event out of the dead-letter budget. + { attributable: false, retryable: true } + ); + }); + + test("does not blame a lone event for a failure that describes the instance", async () => { + // A five-second cadence means most groups hold exactly one event, so "the attempt covered one + // event" is nearly always true and says nothing about fault. If size alone drove attribution, a + // rotated SpiceDB credential would dead-letter whichever revocations happened to be travelling + // alone — and a dead-lettered revocation denies the whole deployment until something replays it. + for (const code of [ + "authzed_unauthenticated", + "authzed_internal", + "authzed_permission_denied", + ] as const) { + vi.clearAllMocks(); + vi.mocked(markAuthzedOutboxEventsFailed).mockResolvedValue(0); + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue([event("membership", "org", "user")]); + vi.mocked(reconcileOrganizationMemberships).mockResolvedValue(failed(code, false)); + + await processAuthzedOutboxBatch("lease"); + + expect(markAuthzedOutboxEventsFailed).toHaveBeenCalledWith("lease", ["membership-org-user"], code, { + attributable: false, + retryable: false, + }); + } + }); + + test("isolates the one event a per-event failure is attributable to", async () => { + const events = ["a", "b", "c", "d"].map((suffix) => + event("feedback_directory_assignment", `directory-${suffix}`, "workspace") + ); + const poison = events[2]; + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue(events); + vi.mocked(reconcileFeedbackDirectoryRelationships).mockImplementation(({ assignments }) => + Promise.resolve( + (assignments ?? []).some(({ feedbackDirectoryId }) => feedbackDirectoryId === poison.primaryId) + ? failed("authzed_projection_invalid_source", false) + : projected + ) + ); + + await expect(processAuthzedOutboxBatch("lease")).resolves.toMatchObject({ delivered: 3, failed: 1 }); + + expect(deliveredIds()).toEqual(sorted(events.filter(({ id }) => id !== poison.id).map(({ id }) => id))); + expect(markAuthzedOutboxEventsFailed).toHaveBeenCalledWith( + "lease", + [poison.id], + "authzed_projection_invalid_source", + { attributable: true, retryable: false } + ); + }); + + test("does not split a group for a failure that describes the instance", async () => { + const events = ["a", "b", "c", "d"].map((suffix) => + event("feedback_directory_assignment", `directory-${suffix}`, "workspace") + ); + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue(events); + vi.mocked(reconcileFeedbackDirectoryRelationships).mockResolvedValue(failed("authzed_internal", false)); + + await processAuthzedOutboxBatch("lease"); + + // One call, not the 2N a blind split would spend every five seconds against a broken instance. + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledOnce(); + expect(markAuthzedOutboxEventsFailed).toHaveBeenCalledWith( + "lease", + events.map(({ id }) => id), + "authzed_internal", + { attributable: false, retryable: false } + ); + }); + + test("reads every claimed user once and treats a missing row as inactive", async () => { + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue([ + event("user", "active-one"), + event("user", "active-two"), + event("user", "active-two"), + event("user", "gone"), + ]); + vi.mocked(prisma.user.findMany).mockResolvedValue([ + { + id: "active-one", + isActive: true, + memberships: [{ organizationId: "org-1" }], + teamUsers: [{ teamId: "team-1" }], + }, + { + id: "active-two", + isActive: true, + memberships: [{ organizationId: "org-2" }], + teamUsers: [], + }, + ] as never); + + await processAuthzedOutboxBatch("lease"); + + expect(prisma.user.findMany).toHaveBeenCalledOnce(); + expect(vi.mocked(prisma.user.findMany).mock.calls[0][0]).toMatchObject({ + where: { id: { in: ["active-one", "active-two", "gone"] } }, + }); + expect(deleteUserOrganizationRelationships).toHaveBeenCalledExactlyOnceWith("gone"); + expect(deleteUserTeamRelationships).toHaveBeenCalledExactlyOnceWith("gone"); + expect(reconcileOrganizationMemberships).toHaveBeenCalledExactlyOnceWith({ + memberships: [ + { organizationId: "org-1", userId: "active-one" }, + { organizationId: "org-2", userId: "active-two" }, + ], + }); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledExactlyOnceWith({ + teamMemberships: [{ teamId: "team-1", userId: "active-one" }], + }); + }); + + test("keeps draining while batches make progress and stops when one makes none", async () => { + const events = [event("membership", "org", "user")]; + vi.mocked(claimAuthzedOutboxEvents) + .mockResolvedValueOnce(events) + .mockResolvedValueOnce(events) + .mockResolvedValueOnce([]); + + await drainAuthzedOutbox(10); + expect(claimAuthzedOutboxEvents).toHaveBeenCalledTimes(3); + + vi.mocked(claimAuthzedOutboxEvents).mockReset().mockResolvedValue(events); + vi.mocked(reconcileOrganizationMemberships).mockResolvedValue(failed("authzed_unavailable", true)); + + await drainAuthzedOutbox(10); + expect(claimAuthzedOutboxEvents).toHaveBeenCalledOnce(); + }); + + test("records revocation propagation after successful delivery without identifier labels", async () => { + const revocation = { ...event("workspace_team", "workspace", "team"), isRevocation: true }; + vi.mocked(claimAuthzedOutboxEvents).mockResolvedValue([revocation]); + + await processAuthzedOutboxBatch("lease"); + + expect(recordAuthzedRevocationDelivery).toHaveBeenCalledOnce(); + expect(recordAuthzedRevocationDelivery).toHaveBeenCalledWith(expect.any(Number)); + }); + + test("does not touch PostgreSQL when AuthZed is disabled", async () => { + vi.mocked(isAuthzedEnabled).mockReturnValue(false); + await processAuthzedProjectionDeliveryJob(); + expect(claimAuthzedOutboxEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/authzed/outbox-processor.ts b/apps/web/lib/authzed/outbox-processor.ts new file mode 100644 index 000000000000..a58e85a4f1ea --- /dev/null +++ b/apps/web/lib/authzed/outbox-processor.ts @@ -0,0 +1,497 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { reconcileApiKeyRelationships } from "./api-key"; +import { isAuthzedEnabled } from "./config"; +import { AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES } from "./constants"; +import { AUTHZED_ERROR_CODES } from "./errors"; +import { reconcileFeedbackDirectoryRelationships } from "./feedback-directory"; +import { + recordAuthzedOutboxDelivery, + recordAuthzedOutboxStatus, + recordAuthzedRevocationDelivery, +} from "./metrics"; +import { + deleteOrganizationRelationships, + deleteUserOrganizationRelationships, + reconcileOrganizationMemberships, +} from "./organization-membership"; +import { + AUTHZED_OUTBOX_BATCH_SIZE, + claimAuthzedOutboxEvents, + createAuthzedOutboxLeaseOwner, + getAuthzedOutboxStatus, + markAuthzedOutboxEventsDelivered, + markAuthzedOutboxEventsFailed, +} from "./outbox-repository"; +import type { + TAuthzedOutboxDrainResult, + TAuthzedOutboxEvent, + TAuthzedOutboxTargetType, +} from "./outbox-types"; +import type { TAuthzedProjectionResult } from "./projection"; +import { runChunked } from "./projection-chunks"; +import { deleteUserTeamRelationships, reconcileTeamWorkspaceRelationships } from "./team-workspace"; + +const DELIVERY_ERROR_CODE = "authzed_projection_delivery_failed"; +const DISABLED_ERROR_CODE = "authzed_disabled"; +const UNSTABLE_ERROR_CODE = "authzed_projection_unstable"; + +/** + * Error codes that can plausibly be caused by one event rather than by the batch it travelled in. + * + * Only these justify splitting a failed group to find the culprit. Every other non-retryable code — + * `authzed_unauthenticated`, `authzed_internal` — describes the instance, not an event, so splitting + * would spend 2xN gRPC calls every five seconds to learn what a single call already reported. + */ +const PER_EVENT_ERROR_CODES: ReadonlySet = new Set([ + "authzed_projection_invalid_source", + AUTHZED_ERROR_CODES.INVALID_REQUEST, +]); + +/** 2^8 exceeds the claim batch size, so a split always reaches singletons before this bites. */ +const MAX_QUARANTINE_DEPTH = 8; + +type TGroupedEvents = ReadonlyMap>; + +const groupEvents = (events: ReadonlyArray): TGroupedEvents => { + const grouped = new Map(); + for (const event of events) { + const targetEvents = grouped.get(event.targetType) ?? []; + targetEvents.push(event); + grouped.set(event.targetType, targetEvents); + } + return grouped; +}; + +const byType = ( + events: ReadonlyArray, + targetType: TAuthzedOutboxTargetType +): ReadonlyArray => events.filter((event) => event.targetType === targetType); + +const secondaryTargets = ( + events: ReadonlyArray +): ReadonlyArray> => + events.flatMap((event) => + event.secondaryId ? [{ primaryId: event.primaryId, secondaryId: event.secondaryId }] : [] + ); + +const PROJECTED_WITHOUT_WORK: TAuthzedProjectionResult = { passes: 0, status: "projected" }; + +/** + * Hand targets to a reconciler in bounded chunks. + * + * A claimed batch is bounded, but the targets it expands into are not — one `user` event can carry an + * unbounded number of memberships, and every list becomes its own `OR` clause in the reconciler's + * snapshot query. `runChunked` returns `null` when every list was empty, which here means the group + * had nothing to project rather than that it failed. + */ +const runChunkedProjection = async >>>( + reconcile: (targets: TTargets) => Promise, + targets: TTargets +): Promise => (await runChunked(reconcile, targets)) ?? PROJECTED_WITHOUT_WORK; + +/** + * Run per-subject projections with a bounded fan-out, stopping at the first that does not project. + * + * Used only for deletes, which address a whole subject through a relationship filter and so cannot be + * packed into a shared write the way reconciler updates are. `runBestEffortProjection` never throws, + * so `Promise.all` cannot reject here. + */ +const runBoundedConcurrently = async ( + operations: ReadonlyArray<() => Promise> +): Promise => { + for (let start = 0; start < operations.length; start += AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES) { + const results = await Promise.all( + operations.slice(start, start + AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES).map((run) => run()) + ); + const failure = results.find((result) => result.status !== "projected"); + if (failure) return failure; + } + return PROJECTED_WITHOUT_WORK; +}; + +/** + * Reconcile every named user in one pass. + * + * One `findMany` for the whole group rather than a `findUnique` per event: the delivery job runs on a + * five-second cadence, and a claimed batch can carry two hundred user events. A user missing from the + * result is treated exactly as an inactive one — `findMany` omits rows that a per-id `findUnique` + * would have returned as `null`, and both mean there is no active user left to hold relationships. + */ +const reconcileUsers = async ( + events: ReadonlyArray +): Promise => { + const userIds = [...new Set(events.map(({ primaryId }) => primaryId))]; + if (userIds.length === 0) return PROJECTED_WITHOUT_WORK; + + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { + id: true, + isActive: true, + memberships: { select: { organizationId: true } }, + teamUsers: { select: { teamId: true } }, + }, + }); + + const active = users.filter(({ isActive }) => isActive); + const activeIds = new Set(active.map(({ id }) => id)); + + const removal = await runBoundedConcurrently( + userIds + .filter((userId) => !activeIds.has(userId)) + .flatMap((userId) => [ + () => deleteUserOrganizationRelationships(userId), + () => deleteUserTeamRelationships(userId), + ]) + ); + if (removal.status !== "projected") return removal; + + const memberships = await runChunkedProjection(reconcileOrganizationMemberships, { + memberships: active.flatMap((user) => + user.memberships.map(({ organizationId }) => ({ organizationId, userId: user.id })) + ), + }); + if (memberships.status !== "projected") return memberships; + + return runChunkedProjection(reconcileTeamWorkspaceRelationships, { + teamMemberships: active.flatMap((user) => + user.teamUsers.map(({ teamId }) => ({ teamId, userId: user.id })) + ), + }); +}; + +/** Organization events are inserts and deletes only; a row that is gone must lose its relationships. */ +const reconcileOrganizations = async ( + events: ReadonlyArray +): Promise => { + const organizationIds = [...new Set(events.map(({ primaryId }) => primaryId))]; + if (organizationIds.length === 0) return PROJECTED_WITHOUT_WORK; + + const existing = new Set( + ( + await prisma.organization.findMany({ + where: { id: { in: organizationIds } }, + select: { id: true }, + }) + ).map(({ id }) => id) + ); + + return runBoundedConcurrently( + organizationIds + .filter((organizationId) => !existing.has(organizationId)) + .map((organizationId) => () => deleteOrganizationRelationships(organizationId)) + ); +}; + +/** + * One delivery group per reconciler call. + * + * The grouping is the attribution boundary: a failure is charged to the events that were in flight + * with it and to nothing else. `run` rebuilds its targets from the events it is handed rather than + * from the surrounding batch, which is what lets a failed group be re-run over a subset. + */ +type TDeliveryGroup = Readonly<{ + events: ReadonlyArray; + run: (events: ReadonlyArray) => Promise; +}>; + +const buildDeliveryGroups = (grouped: TGroupedEvents): ReadonlyArray => { + const collect = ( + ...targetTypes: ReadonlyArray + ): ReadonlyArray => targetTypes.flatMap((type) => [...(grouped.get(type) ?? [])]); + + const groups: ReadonlyArray = [ + { + events: collect("membership"), + run: (events) => + runChunkedProjection(reconcileOrganizationMemberships, { + memberships: secondaryTargets(events).map(({ primaryId, secondaryId }) => ({ + organizationId: primaryId, + userId: secondaryId, + })), + }), + }, + { events: collect("organization"), run: reconcileOrganizations }, + { events: collect("user"), run: reconcileUsers }, + { + events: collect("team", "team_membership", "workspace", "workspace_team"), + run: (events) => + runChunkedProjection(reconcileTeamWorkspaceRelationships, { + teamIds: byType(events, "team").map(({ primaryId }) => primaryId), + teamMemberships: secondaryTargets(byType(events, "team_membership")).map( + ({ primaryId, secondaryId }) => ({ teamId: primaryId, userId: secondaryId }) + ), + workspaceIds: byType(events, "workspace").map(({ primaryId }) => primaryId), + workspaceTeamGrants: secondaryTargets(byType(events, "workspace_team")).map( + ({ primaryId, secondaryId }) => ({ teamId: secondaryId, workspaceId: primaryId }) + ), + }), + }, + { + events: collect("api_key", "api_key_workspace"), + run: (events) => + runChunkedProjection(reconcileApiKeyRelationships, { + apiKeyIds: byType(events, "api_key").map(({ primaryId }) => primaryId), + apiKeyWorkspaceGrants: secondaryTargets(byType(events, "api_key_workspace")).map( + ({ primaryId, secondaryId }) => ({ apiKeyId: primaryId, workspaceId: secondaryId }) + ), + }), + }, + { + events: collect("feedback_directory", "feedback_directory_assignment"), + run: (events) => + runChunkedProjection(reconcileFeedbackDirectoryRelationships, { + assignments: secondaryTargets(byType(events, "feedback_directory_assignment")).map( + ({ primaryId, secondaryId }) => ({ feedbackDirectoryId: primaryId, workspaceId: secondaryId }) + ), + feedbackDirectoryIds: byType(events, "feedback_directory").map(({ primaryId }) => primaryId), + }), + }, + ]; + + return groups.filter(({ events }) => events.length > 0); +}; + +const sanitizeDeliveryError = (error: unknown): string => { + if (error instanceof Error && error.message.startsWith("authzed_")) return error.message; + return DELIVERY_ERROR_CODE; +}; + +type TGroupOutcome = + | Readonly<{ status: "projected" }> + | Readonly<{ code: string; retryable: boolean; status: "failed" }>; + +const runGroup = async ( + group: TDeliveryGroup, + events: ReadonlyArray +): Promise => { + let result: TAuthzedProjectionResult; + try { + result = await group.run(events); + } catch (error) { + // Reconcilers never throw — `runBestEffortProjection` converts failures into a result — but the + // PostgreSQL reads this module makes around them can. Treat that as transient rather than as a + // fault attributable to any single event. + return { code: sanitizeDeliveryError(error), retryable: true, status: "failed" }; + } + + if (result.status === "projected") return { status: "projected" }; + // AuthZed switched off mid-batch: nothing was attempted, so nothing is anyone's fault. + if (result.status === "disabled") return { code: DISABLED_ERROR_CODE, retryable: true, status: "failed" }; + + return { + code: result.code, + // A source row that kept moving under the reconciler will settle. That is a retry, not a poison — + // and it is likeliest on exactly the hot rows the outbox carries. Overridden here rather than on + // `AuthzedProjectionUnstableError`, whose `retryable: false` is part of the projector contract. + retryable: result.retryable || result.code === UNSTABLE_ERROR_CODE, + status: "failed", + }; +}; + +type TFailure = Readonly<{ + /** + * The failure names THIS event: the attempt covered it alone, AND the code is one an event can + * actually cause. + * + * Both halves are load-bearing. A single-event group is the normal case on a five-second cadence, + * so size alone would charge a permanent failure to whichever events happened to be travelling + * alone when SpiceDB rejected the credential — the same codes this module already refuses to split + * on precisely because they describe the instance rather than an event. + */ + attributable: boolean; + code: string; + eventIds: ReadonlyArray; + retryable: boolean; +}>; + +type TDeliveryOutcome = Readonly<{ + delivered: ReadonlyArray; + failures: ReadonlyArray; + /** Set when delivery hit a transient fault: stop spending retry budget against it this tick. */ + haltCode: string | null; +}>; + +const failureOutcome = ( + events: ReadonlyArray, + outcome: Extract +): TDeliveryOutcome => ({ + delivered: [], + failures: [ + { + attributable: events.length === 1 && PER_EVENT_ERROR_CODES.has(outcome.code), + code: outcome.code, + eventIds: events.map(({ id }) => id), + retryable: outcome.retryable, + }, + ], + haltCode: outcome.retryable ? outcome.code : null, +}); + +/** + * Deliver one group, halving it to isolate the culprit when the failure could be a single event's. + * + * Without this, one cross-tenant assignment row fails every feedback-directory event in every batch + * for as long as it exists. With it, the poison event is alone by the time it is charged a permanent + * failure — which is the precondition for dead-lettering ever being attributable. + */ +const deliverGroup = async ( + group: TDeliveryGroup, + events: ReadonlyArray, + depth: number +): Promise => { + const outcome = await runGroup(group, events); + if (outcome.status === "projected") { + return { delivered: events.map(({ id }) => id), failures: [], haltCode: null }; + } + + const splittable = + events.length > 1 && + !outcome.retryable && + PER_EVENT_ERROR_CODES.has(outcome.code) && + depth < MAX_QUARANTINE_DEPTH; + if (!splittable) return failureOutcome(events, outcome); + + const middle = Math.ceil(events.length / 2); + const left = await deliverGroup(group, events.slice(0, middle), depth + 1); + if (left.haltCode) { + const untried = events.slice(middle).map(({ id }) => id); + return { + delivered: left.delivered, + failures: [ + ...left.failures, + ...(untried.length > 0 + ? [{ attributable: false, code: left.haltCode, eventIds: untried, retryable: true }] + : []), + ], + haltCode: left.haltCode, + }; + } + + const right = await deliverGroup(group, events.slice(middle), depth + 1); + return { + delivered: [...left.delivered, ...right.delivered], + failures: [...left.failures, ...right.failures], + haltCode: right.haltCode, + }; +}; + +const deliverEventGroups = async (grouped: TGroupedEvents): Promise => { + const groups = buildDeliveryGroups(grouped); + const delivered: string[] = []; + const failures: TFailure[] = []; + + for (const [index, group] of groups.entries()) { + const outcome = await deliverGroup(group, group.events, 0); + delivered.push(...outcome.delivered); + failures.push(...outcome.failures); + + if (outcome.haltCode) { + // Continuing would spend another three-attempt retry budget per remaining group against an + // instance already known to be unreachable. Release the rest untried and unblamed. + const untried = groups.slice(index + 1).flatMap(({ events }) => events.map(({ id }) => id)); + if (untried.length > 0) { + failures.push({ attributable: false, code: outcome.haltCode, eventIds: untried, retryable: true }); + } + return { delivered, failures, haltCode: outcome.haltCode }; + } + } + + return { delivered, failures, haltCode: null }; +}; + +/** One release statement per distinct verdict rather than per failure record. */ +const mergeFailures = (failures: ReadonlyArray): ReadonlyArray => { + const merged = new Map(); + for (const failure of failures) { + const key = `${failure.code}:${String(failure.attributable)}:${String(failure.retryable)}`; + const existing = merged.get(key); + if (existing) existing.eventIds.push(...failure.eventIds); + else merged.set(key, { ...failure, eventIds: [...failure.eventIds] }); + } + return [...merged.values()]; +}; + +export const processAuthzedOutboxBatch = async ( + leaseOwner = createAuthzedOutboxLeaseOwner(), + batchSize = AUTHZED_OUTBOX_BATCH_SIZE +): Promise> => { + const events = await claimAuthzedOutboxEvents(leaseOwner, batchSize); + if (events.length === 0) return { claimed: 0, deadLettered: 0, delivered: 0, failed: 0 }; + + const startedAt = performance.now(); + const outcome = await deliverEventGroups(groupEvents(events)); + const durationMs = performance.now() - startedAt; + + await markAuthzedOutboxEventsDelivered(leaseOwner, outcome.delivered); + + const deliveredAt = Date.now(); + const deliveredIds = new Set(outcome.delivered); + for (const event of events) { + if (event.isRevocation && deliveredIds.has(event.id)) { + recordAuthzedRevocationDelivery(deliveredAt - event.createdAt.getTime()); + } + } + + let deadLettered = 0; + let failed = 0; + for (const failure of mergeFailures(outcome.failures)) { + failed += failure.eventIds.length; + deadLettered += await markAuthzedOutboxEventsFailed(leaseOwner, failure.eventIds, failure.code, { + attributable: failure.attributable, + retryable: failure.retryable, + }); + } + + if (outcome.delivered.length > 0) { + recordAuthzedOutboxDelivery({ count: outcome.delivered.length, durationMs, status: "delivered" }); + } + + if (failed > 0) { + logger.warn( + { + component: "authzed", + count: failed, + deadLettered, + // Stable enumerable codes only — never an event, target, or tenant identifier. + errorCodes: [...new Set(outcome.failures.map(({ code }) => code))].sort((left, right) => + left.localeCompare(right) + ), + operation: "projection_outbox_delivery", + status: "failed", + }, + "AuthZed projection outbox delivery failed" + ); + recordAuthzedOutboxDelivery({ count: failed, durationMs, status: "failed" }); + } + + return { claimed: events.length, deadLettered, delivered: outcome.delivered.length, failed }; +}; + +export const drainAuthzedOutbox = async (maxBatches = 100): Promise => { + const totals = { claimed: 0, deadLettered: 0, delivered: 0, failed: 0 }; + const leaseOwner = createAuthzedOutboxLeaseOwner(); + + for (let batch = 0; batch < maxBatches; batch++) { + const result = await processAuthzedOutboxBatch(leaseOwner); + totals.claimed += result.claimed; + totals.deadLettered += result.deadLettered; + totals.delivered += result.delivered; + totals.failed += result.failed; + // A partial batch is the normal outcome once failures are attributed per group, so draining stops + // on no progress rather than on any failure. Delivered rows get `processedAt` and failed rows a + // future `availableAt`, so every iteration strictly shrinks the claimable set. + if (result.claimed === 0 || result.delivered === 0) break; + } + + const status = await getAuthzedOutboxStatus(); + recordAuthzedOutboxStatus(status); + return { ...totals, remaining: status.pending, status: status.pending === 0 ? "drained" : "partial" }; +}; + +export const processAuthzedProjectionDeliveryJob = async (): Promise => { + if (!isAuthzedEnabled()) return; + await drainAuthzedOutbox(10); +}; diff --git a/apps/web/lib/authzed/outbox-repository.test.ts b/apps/web/lib/authzed/outbox-repository.test.ts new file mode 100644 index 000000000000..2c5c4b80dff4 --- /dev/null +++ b/apps/web/lib/authzed/outbox-repository.test.ts @@ -0,0 +1,207 @@ +import { prisma } from "@/lib/__mocks__/database"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + AUTHZED_OUTBOX_HISTORY_DELETE_BATCH_SIZE, + AUTHZED_OUTBOX_HISTORY_MAX_DELETE_BATCHES, + claimAuthzedOutboxEvents, + getAuthzedOutboxStatus, + hasStaleAuthzedRevocation, + markAuthzedOutboxEventsDelivered, + markAuthzedOutboxEventsFailed, + pruneAuthzedOutboxHistory, + replayAuthzedOutboxDeadLetters, +} from "./outbox-repository"; + +const row = (targetType: string) => ({ + attempts: 1, + createdAt: new Date(0), + id: `${targetType}-event`, + isRevocation: true, + primaryId: "private-primary", + secondaryId: "private-secondary", + targetType, +}); + +/** + * The release statement binds exactly one boolean: "this failure is attributable to one event". + * Located by type rather than by position so reordering the SQL cannot silently invert the check. + */ +const boundPermanentFlag = (): unknown => + (vi.mocked(prisma.$queryRaw).mock.calls.at(-1) ?? []) + .slice(1) + .find((value: unknown) => typeof value === "boolean"); + +describe("AuthZed projection outbox repository", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.useRealTimers()); + + test("dead-letters an unknown target instead of leaving it leased forever", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([row("membership"), row("future_unknown_target")]); + + await expect(claimAuthzedOutboxEvents("lease-owner")).resolves.toEqual([row("membership")]); + expect(prisma.authzedProjectionOutbox.updateMany).toHaveBeenCalledWith({ + where: { + id: { in: ["future_unknown_target-event"] }, + leaseOwner: "lease-owner", + processedAt: null, + }, + data: { + deadLetteredAt: expect.any(Date), + lastErrorCode: "authzed_projection_invalid_event", + leaseExpiresAt: null, + leasedAt: null, + leaseOwner: null, + }, + }); + }); + + test("marks only events owned by the active lease as delivered", async () => { + vi.mocked(prisma.authzedProjectionOutbox.updateMany).mockResolvedValue({ count: 1 }); + + await markAuthzedOutboxEventsDelivered("lease-owner", ["event-1", "event-2"]); + + expect(prisma.authzedProjectionOutbox.updateMany).toHaveBeenCalledWith({ + where: { + id: { in: ["event-1", "event-2"] }, + leaseOwner: "lease-owner", + processedAt: null, + }, + data: { + lastErrorCode: null, + leaseExpiresAt: null, + leasedAt: null, + leaseOwner: null, + processedAt: expect.any(Date), + }, + }); + }); + + // The backoff schedule and the dead-letter threshold are computed in SQL from each row's own + // `attempts`, so they are only observable against a real PostgreSQL — see + // outbox-trigger.integration.test.ts. What matters here is the attribution rule that decides + // whether a failure is allowed to count towards dead-lettering at all. + test("charges a permanent failure only when one event failed on its own", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ dead_lettered: 1n }]); + + await expect( + markAuthzedOutboxEventsFailed("lease-owner", ["event-1"], "authzed_invalid_request", { + attributable: true, + retryable: false, + }) + ).resolves.toBe(1); + + expect(boundPermanentFlag()).toBe(true); + }); + + test("never charges a permanent failure for a retryable fault or an unattributed group", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ dead_lettered: 0n }]); + + for (const attribution of [ + { attributable: true, retryable: true }, + { attributable: false, retryable: false }, + { attributable: false, retryable: true }, + ]) { + await expect( + markAuthzedOutboxEventsFailed( + "lease-owner", + ["event-1", "event-2"], + "authzed_unavailable", + attribution + ) + ).resolves.toBe(0); + + expect(boundPermanentFlag()).toBe(false); + } + }); + + test("releases a whole failed batch in one statement", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ dead_lettered: 0n }]); + + await markAuthzedOutboxEventsFailed( + "lease-owner", + Array.from({ length: 200 }, (_unused, index) => `event-${String(index)}`), + "authzed_unavailable", + { attributable: false, retryable: true } + ); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + }); + + test("does not reach the database when nothing failed", async () => { + await expect( + markAuthzedOutboxEventsFailed("lease-owner", [], "authzed_unavailable", { + attributable: false, + retryable: true, + }) + ).resolves.toBe(0); + + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); + + test("normalizes aggregate status values without exposing source rows", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { + dead_lettered: 2n, + oldest_pending_age_seconds: 47.9, + overdue_revocations: 1n, + pending: 11n, + revocations_past_critical: 3n, + revocations_past_warning: 5n, + }, + ]); + + await expect(getAuthzedOutboxStatus()).resolves.toEqual({ + deadLettered: 2, + oldestPendingAgeSeconds: 47, + overdueRevocations: 1, + pending: 11, + revocationsPastCritical: 3, + revocationsPastWarning: 5, + }); + }); + + test("uses a boolean result for the authorization freshness guard", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([{ stale: true }]); + + await expect(hasStaleAuthzedRevocation()).resolves.toBe(true); + }); + + test("returns the delivered-history cleanup count across bounded batches", async () => { + vi.mocked(prisma.$executeRaw) + .mockResolvedValueOnce(10_000) + .mockResolvedValueOnce(10_000) + .mockResolvedValueOnce(17); + + await expect(pruneAuthzedOutboxHistory()).resolves.toBe(20_017); + expect(prisma.$executeRaw).toHaveBeenCalledTimes(3); + }); + + test("bounds one delivered-history cleanup run", async () => { + vi.mocked(prisma.$executeRaw).mockResolvedValue(AUTHZED_OUTBOX_HISTORY_DELETE_BATCH_SIZE); + + await expect(pruneAuthzedOutboxHistory()).resolves.toBe( + AUTHZED_OUTBOX_HISTORY_DELETE_BATCH_SIZE * AUTHZED_OUTBOX_HISTORY_MAX_DELETE_BATCHES + ); + expect(prisma.$executeRaw).toHaveBeenCalledTimes(AUTHZED_OUTBOX_HISTORY_MAX_DELETE_BATCHES); + }); + + test("replays only undelivered dead letters from attempt zero", async () => { + vi.mocked(prisma.authzedProjectionOutbox.updateMany).mockResolvedValue({ count: 4 }); + + await expect(replayAuthzedOutboxDeadLetters()).resolves.toBe(4); + + expect(prisma.authzedProjectionOutbox.updateMany).toHaveBeenCalledWith({ + where: { deadLetteredAt: { not: null }, processedAt: null }, + data: { + attempts: 0, + availableAt: expect.any(Date), + deadLetteredAt: null, + lastErrorCode: null, + leaseExpiresAt: null, + leasedAt: null, + leaseOwner: null, + permanentFailures: 0, + }, + }); + }); +}); diff --git a/apps/web/lib/authzed/outbox-repository.ts b/apps/web/lib/authzed/outbox-repository.ts new file mode 100644 index 000000000000..57ca28c2279a --- /dev/null +++ b/apps/web/lib/authzed/outbox-repository.ts @@ -0,0 +1,320 @@ +import "server-only"; +import { randomUUID } from "node:crypto"; +import { prisma } from "@formbricks/database"; +import { + AUTHZED_OUTBOX_TARGET_TYPES, + type TAuthzedOutboxEvent, + type TAuthzedOutboxStatus, +} from "./outbox-types"; + +/** + * Attempts after which the retry backoff stops growing. + * + * This is a bound on the exponent, not a dead-letter budget: `attempts` keeps climbing while SpiceDB + * is unreachable, and `2 ^ attempts` would overflow long before anyone noticed. + */ +export const AUTHZED_OUTBOX_MAX_BACKOFF_ATTEMPTS = 20; +export const AUTHZED_OUTBOX_MAX_RETRY_DELAY_MS = 5 * 60_000; + +/** + * Failures attributable to one event alone before it dead-letters. + * + * Deliberately separate from `attempts`. A dead-lettered revocation arms the fail-closed freshness + * guard for the whole deployment, so reaching it must mean "PostgreSQL removed access and we cannot + * make SpiceDB agree" — never "SpiceDB was unreachable, or rejected our credential, for a while". + * Only a failure that named a single event AND carried a code an event can cause increments this; + * retryable failures, group failures and instance-scoped failures all leave it untouched. + * With the backoff below that is roughly 17 minutes of solitary failure, well past the 45-second + * critical alarm. + */ +export const AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES = 10; +export const AUTHZED_OUTBOX_LEASE_MS = 60_000; +export const AUTHZED_OUTBOX_REVOCATION_MAX_AGE_MS = 60_000; +export const AUTHZED_OUTBOX_REVOCATION_CRITICAL_MS = 45_000; +export const AUTHZED_OUTBOX_REVOCATION_WARNING_MS = 15_000; +export const AUTHZED_OUTBOX_BATCH_SIZE = 200; +export const AUTHZED_OUTBOX_HISTORY_RETENTION_DAYS = 7; +export const AUTHZED_OUTBOX_HISTORY_DELETE_BATCH_SIZE = 10_000; +export const AUTHZED_OUTBOX_HISTORY_MAX_DELETE_BATCHES = 100; +const INVALID_EVENT_ERROR_CODE = "authzed_projection_invalid_event"; + +type TClaimedRow = Omit & { targetType: string }; + +const isTargetType = (value: string): value is TAuthzedOutboxEvent["targetType"] => + (AUTHZED_OUTBOX_TARGET_TYPES as readonly string[]).includes(value); + +export const createAuthzedOutboxLeaseOwner = (): string => randomUUID(); + +export const claimAuthzedOutboxEvents = async ( + leaseOwner: string, + limit = AUTHZED_OUTBOX_BATCH_SIZE +): Promise> => { + const rows = await prisma.$queryRaw` + WITH claimable AS ( + SELECT "id" + FROM "AuthzedProjectionOutbox" + WHERE "processedAt" IS NULL + AND "deadLetteredAt" IS NULL + AND "availableAt" <= NOW() + AND ("leaseExpiresAt" IS NULL OR "leaseExpiresAt" <= NOW()) + ORDER BY "isRevocation" DESC, "createdAt" ASC + LIMIT ${limit} + FOR UPDATE SKIP LOCKED + ) + UPDATE "AuthzedProjectionOutbox" AS outbox + SET "attempts" = outbox."attempts" + 1, + "lastAttemptAt" = NOW(), + "leasedAt" = NOW(), + "leaseExpiresAt" = NOW() + (${AUTHZED_OUTBOX_LEASE_MS} * INTERVAL '1 millisecond'), + "leaseOwner" = ${leaseOwner}, + "updatedAt" = NOW() + FROM claimable + WHERE outbox."id" = claimable."id" + RETURNING outbox."id", outbox."targetType", outbox."primaryId", outbox."secondaryId", + outbox."isRevocation", outbox."attempts", outbox."createdAt" + `; + + const invalidIds = rows.filter(({ targetType }) => !isTargetType(targetType)).map(({ id }) => id); + if (invalidIds.length > 0) { + // A manually inserted or corrupted event must not remain silently leased forever. Dead-letter it + // without logging its identifiers; an invalid revocation will then activate the freshness guard. + await prisma.authzedProjectionOutbox.updateMany({ + where: { id: { in: invalidIds }, leaseOwner, processedAt: null }, + data: { + deadLetteredAt: new Date(), + lastErrorCode: INVALID_EVENT_ERROR_CODE, + leaseExpiresAt: null, + leasedAt: null, + leaseOwner: null, + }, + }); + } + + return rows.flatMap((row) => + isTargetType(row.targetType) ? [{ ...row, targetType: row.targetType }] : [] + ); +}; + +export const markAuthzedOutboxEventsDelivered = async ( + leaseOwner: string, + eventIds: ReadonlyArray +): Promise => { + if (eventIds.length === 0) return; + await prisma.authzedProjectionOutbox.updateMany({ + where: { id: { in: [...eventIds] }, leaseOwner, processedAt: null }, + data: { + lastErrorCode: null, + leaseExpiresAt: null, + leasedAt: null, + leaseOwner: null, + processedAt: new Date(), + }, + }); +}; + +/** + * Release a failed lease, and dead-letter only what the failure actually attributes. + * + * `attributable` says the failure names these events: the attempt covered one event, AND the code is + * one an event can actually cause. Anything else must never spend a permanent failure. A group + * failure reports that the group did not deliver, not which member broke it. An instance-scoped code + * (a rejected credential, an unmapped internal error) reports that SpiceDB is unhappy — the event + * that happened to be travelling alone when it happened did not cause it, and charging it would + * dead-letter a bystander mid-outage. Either mistake ends the same way: a dead-lettered revocation + * is a deployment-wide authorization outage until something replays it. + * + * One statement rather than one per event: this path runs when delivery is already failing, which is + * exactly when the database is least likely to have headroom for 200 sequential round trips. The + * backoff is computed from each row's own `attempts` so nothing has to be grouped by attempt count. + */ +export const markAuthzedOutboxEventsFailed = async ( + leaseOwner: string, + eventIds: ReadonlyArray, + errorCode: string, + { attributable, retryable }: Readonly<{ attributable: boolean; retryable: boolean }> +): Promise => { + if (eventIds.length === 0) return 0; + const permanent = !retryable && attributable; + + const [row] = await prisma.$queryRaw>` + WITH released AS ( + UPDATE "AuthzedProjectionOutbox" + SET "availableAt" = NOW() + ( + LEAST( + ${AUTHZED_OUTBOX_MAX_RETRY_DELAY_MS}::double precision, + 1000 * 2 ^ (LEAST("attempts", ${AUTHZED_OUTBOX_MAX_BACKOFF_ATTEMPTS}) - 1) + ) * INTERVAL '1 millisecond' + ), + "permanentFailures" = "permanentFailures" + ${permanent ? 1 : 0}, + "deadLetteredAt" = CASE + WHEN ${permanent}::boolean + AND "permanentFailures" + 1 >= ${AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES} + THEN NOW() + ELSE NULL + END, + "lastErrorCode" = ${errorCode}, + "leaseExpiresAt" = NULL, + "leasedAt" = NULL, + "leaseOwner" = NULL, + -- Raw SQL bypasses Prisma's @updatedAt, which the previous per-event updateMany got free. + "updatedAt" = NOW() + WHERE "id" = ANY(${[...eventIds]}::text[]) + AND "leaseOwner" = ${leaseOwner} + AND "processedAt" IS NULL + AND "deadLetteredAt" IS NULL + RETURNING "deadLetteredAt" + ) + SELECT COUNT(*) FILTER (WHERE "deadLetteredAt" IS NOT NULL) AS dead_lettered FROM released + `; + + return Number(row?.dead_lettered ?? 0); +}; + +type TStatusRow = Readonly<{ + dead_lettered: bigint; + oldest_pending_age_seconds: number | null; + overdue_revocations: bigint; + pending: bigint; + revocations_past_critical: bigint; + revocations_past_warning: bigint; +}>; + +type TStaleRevocationRow = Readonly<{ stale: boolean }>; + +/** + * Indexed fail-closed check for the authorization hot path; avoid aggregating retained history. + * + * Two `EXISTS` rather than one with an `OR`, because the two halves live in different partial + * indexes: `_claim_idx` covers `deadLetteredAt IS NULL` and `_undelivered_idx` covers the complement, + * and no single index can serve both sides of that disjunction. Split, each branch is an index probe + * and the second is skipped whenever the first already answered. + */ +export const hasStaleAuthzedRevocation = async (): Promise => { + const [row] = await prisma.$queryRaw` + SELECT ( + EXISTS ( + SELECT 1 + FROM "AuthzedProjectionOutbox" + WHERE "isRevocation" = true + AND "processedAt" IS NULL + AND "deadLetteredAt" IS NULL + AND "createdAt" <= NOW() - (${AUTHZED_OUTBOX_REVOCATION_MAX_AGE_MS} * INTERVAL '1 millisecond') + ) + OR EXISTS ( + SELECT 1 + FROM "AuthzedProjectionOutbox" + WHERE "isRevocation" = true + AND "processedAt" IS NULL + AND "deadLetteredAt" IS NOT NULL + ) + ) AS stale + `; + + return row?.stale ?? false; +}; + +/** + * Aggregate outbox health. + * + * Scoped to undelivered rows by the outer `WHERE`, which is what keeps this off the seven days of + * retained history: the delivery job calls it every five seconds, so an unscoped aggregate is a + * full-table scan twelve times a minute. `dead_lettered` is unaffected by the scoping — a + * dead-lettered row always has a NULL `processedAt`, because the claim skips dead letters and + * `replayAuthzedOutboxDeadLetters` clears `deadLetteredAt` before delivery is possible again. + */ +export const getAuthzedOutboxStatus = async (): Promise => { + const [row] = await prisma.$queryRaw` + SELECT + COUNT(*) FILTER (WHERE "deadLetteredAt" IS NULL) AS pending, + COUNT(*) FILTER (WHERE "deadLetteredAt" IS NOT NULL) AS dead_lettered, + COUNT(*) FILTER ( + WHERE "isRevocation" = true + AND ( + "deadLetteredAt" IS NOT NULL + OR "createdAt" <= NOW() - (${AUTHZED_OUTBOX_REVOCATION_MAX_AGE_MS} * INTERVAL '1 millisecond') + ) + ) AS overdue_revocations, + COUNT(*) FILTER ( + WHERE "isRevocation" = true + AND ( + "deadLetteredAt" IS NOT NULL + OR "createdAt" <= NOW() - (${AUTHZED_OUTBOX_REVOCATION_CRITICAL_MS} * INTERVAL '1 millisecond') + ) + ) AS revocations_past_critical, + COUNT(*) FILTER ( + WHERE "isRevocation" = true + AND ( + "deadLetteredAt" IS NOT NULL + OR "createdAt" <= NOW() - (${AUTHZED_OUTBOX_REVOCATION_WARNING_MS} * INTERVAL '1 millisecond') + ) + ) AS revocations_past_warning, + EXTRACT(EPOCH FROM NOW() - MIN("createdAt") FILTER ( + WHERE "deadLetteredAt" IS NULL + ))::double precision AS oldest_pending_age_seconds + FROM "AuthzedProjectionOutbox" + WHERE "processedAt" IS NULL + `; + + return { + deadLettered: Number(row?.dead_lettered ?? 0), + oldestPendingAgeSeconds: + row?.oldest_pending_age_seconds === null || row?.oldest_pending_age_seconds === undefined + ? null + : Math.max(0, Math.floor(row.oldest_pending_age_seconds)), + overdueRevocations: Number(row?.overdue_revocations ?? 0), + pending: Number(row?.pending ?? 0), + revocationsPastCritical: Number(row?.revocations_past_critical ?? 0), + revocationsPastWarning: Number(row?.revocations_past_warning ?? 0), + }; +}; + +export const replayAuthzedOutboxDeadLetters = async (): Promise => { + const result = await prisma.authzedProjectionOutbox.updateMany({ + where: { deadLetteredAt: { not: null }, processedAt: null }, + data: { + attempts: 0, + availableAt: new Date(), + deadLetteredAt: null, + lastErrorCode: null, + leaseExpiresAt: null, + leasedAt: null, + leaseOwner: null, + // Reset alongside `attempts`: a replay is a decision that the cause was addressed, so the event + // gets the full permanent-failure budget again rather than dead-lettering on its next failure. + permanentFailures: 0, + }, + }); + return result.count; +}; + +const pruneAuthzedOutboxHistoryBatch = async (): Promise => + prisma.$executeRaw` + WITH expired AS ( + SELECT "id" + FROM "AuthzedProjectionOutbox" + WHERE "processedAt" < NOW() - (${AUTHZED_OUTBOX_HISTORY_RETENTION_DAYS} * INTERVAL '1 day') + ORDER BY "processedAt" ASC + LIMIT ${AUTHZED_OUTBOX_HISTORY_DELETE_BATCH_SIZE} + ) + DELETE FROM "AuthzedProjectionOutbox" AS outbox + USING expired + WHERE outbox."id" = expired."id" + `; + +/** + * Bound table growth without deleting pending or dead-letter evidence. + * + * Each statement is bounded to avoid holding locks across a large retained history. The bounded loop + * lets the six-hourly caller retire more than one batch without monopolizing a database connection. + */ +export const pruneAuthzedOutboxHistory = async (): Promise => { + let deleted = 0; + + for (let batch = 0; batch < AUTHZED_OUTBOX_HISTORY_MAX_DELETE_BATCHES; batch++) { + const count = await pruneAuthzedOutboxHistoryBatch(); + deleted += count; + if (count < AUTHZED_OUTBOX_HISTORY_DELETE_BATCH_SIZE) break; + } + + return deleted; +}; diff --git a/apps/web/lib/authzed/outbox-trigger.integration.test.ts b/apps/web/lib/authzed/outbox-trigger.integration.test.ts new file mode 100644 index 000000000000..251d391208fe --- /dev/null +++ b/apps/web/lib/authzed/outbox-trigger.integration.test.ts @@ -0,0 +1,460 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import { resetDb } from "@/integration/reset-db"; +import { + AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES, + AUTHZED_OUTBOX_MAX_RETRY_DELAY_MS, + hasStaleAuthzedRevocation, + markAuthzedOutboxEventsFailed, +} from "@/lib/authzed/outbox-repository"; + +const migration = readFileSync( + join( + dirname(fileURLToPath(import.meta.url)), + "../../../../packages/database/migration/20260818120000_add_authzed_projection_outbox/migration.sql" + ), + "utf8" +); + +/** + * The durable outbox against a real PostgreSQL (ENG-2408). + * + * Three things in this feature are implemented in SQL and are therefore invisible to a unit test: the + * trigger's grant/revocation classifier, the backoff and dead-letter arithmetic in the failure + * release, and whether the indexes are actually the partial ones the hot paths need. The + * string-matching contract test next door can see that the SQL *says* something; only this can see + * that it *does* it. + * + * The classifier is the load-bearing one. `isRevocation` has a single reader — the fail-closed + * freshness guard — and a guard armed by a pure grant denies every enforced authorization check in + * the deployment, so a mass invite acceptance would be an outage. The transition table below is the + * evidence that it is not. + */ + +type TOutboxRow = Readonly<{ + isRevocation: boolean; + primaryId: string; + secondaryId: string | null; + targetType: string; +}>; + +const outboxRows = (): Promise> => + prisma.$queryRaw` + SELECT "targetType", "primaryId", "secondaryId", "isRevocation" + FROM "AuthzedProjectionOutbox" + ORDER BY "isRevocation" DESC, "createdAt" ASC + `; + +/** Source setup fires the triggers too, so clear what it produced before the mutation under test. */ +const clearOutbox = (): Promise => prisma.$executeRawUnsafe('TRUNCATE "AuthzedProjectionOutbox";'); + +const seedUser = (email: string, isActive = true) => + prisma.user.create({ data: { email, name: email, isActive } }); + +const seedOrganization = (name: string) => prisma.organization.create({ data: { name } }); + +beforeEach(async () => { + await resetDb(); +}); + +describe("AuthZed projection outbox triggers", () => { + test("the migration converges when applied repeatedly", async () => { + await prisma.$executeRawUnsafe(migration); + await prisma.$executeRawUnsafe(migration); + + const [catalog] = await prisma.$queryRaw>` + SELECT + (SELECT COUNT(*) FROM pg_indexes + WHERE tablename = 'AuthzedProjectionOutbox' + AND indexname <> 'AuthzedProjectionOutbox_pkey') AS indexes, + (SELECT COUNT(*) FROM pg_trigger + WHERE tgname LIKE 'authzed_projection_%' + AND NOT tgisinternal) AS triggers + `; + expect(Number(catalog?.indexes)).toBe(3); + expect(Number(catalog?.triggers)).toBe(11); + }); + + test("does not classify an accepted invite as a revocation", async () => { + const [user, organization] = await Promise.all([ + seedUser("accept@integration.test"), + seedOrganization("Accept"), + ]); + await prisma.membership.create({ + data: { organizationId: organization.id, userId: user.id, accepted: false, role: "member" }, + }); + await clearOutbox(); + + await prisma.membership.update({ + where: { userId_organizationId: { organizationId: organization.id, userId: user.id } }, + data: { accepted: true }, + }); + + // The projected snapshot ignores `accepted` entirely, so this writes byte-identical relationships. + expect(await outboxRows()).toEqual([ + { + isRevocation: false, + primaryId: organization.id, + secondaryId: user.id, + targetType: "membership", + }, + ]); + }); + + test("classifies any role move as a revocation, promotions included", async () => { + const [user, organization] = await Promise.all([ + seedUser("promote@integration.test"), + seedOrganization("Promote"), + ]); + await prisma.membership.create({ + data: { organizationId: organization.id, userId: user.id, accepted: true, role: "member" }, + }); + await clearOutbox(); + + await prisma.membership.update({ + where: { userId_organizationId: { organizationId: organization.id, userId: user.id } }, + data: { role: "owner" }, + }); + + // Deny by default: a role move deletes the relation for the old role, and the permission ladder is + // deliberately not encoded in SQL where nothing would catch it drifting from authzed/schema.zed. + expect(await outboxRows()).toEqual([ + { isRevocation: true, primaryId: organization.id, secondaryId: user.id, targetType: "membership" }, + ]); + }); + + test("enqueues the abandoned pair as a revocation when a membership moves organization", async () => { + const [user, from, to] = await Promise.all([ + seedUser("move@integration.test"), + seedOrganization("Move from"), + seedOrganization("Move to"), + ]); + await prisma.membership.create({ + data: { organizationId: from.id, userId: user.id, accepted: true, role: "member" }, + }); + await clearOutbox(); + + await prisma.$executeRaw` + UPDATE "Membership" SET "organizationId" = ${to.id} + WHERE "userId" = ${user.id} AND "organizationId" = ${from.id} + `; + + expect(await outboxRows()).toEqual([ + { isRevocation: true, primaryId: from.id, secondaryId: user.id, targetType: "membership" }, + { isRevocation: false, primaryId: to.id, secondaryId: user.id, targetType: "membership" }, + ]); + }); + + test("classifies reactivation as a grant and deactivation as a revocation", async () => { + const user = await seedUser("toggle@integration.test", false); + await clearOutbox(); + + await prisma.user.update({ where: { id: user.id }, data: { isActive: true } }); + // Every relationship is deleted while a user is inactive, so the pre-state is empty by construction. + expect(await outboxRows()).toEqual([ + { isRevocation: false, primaryId: user.id, secondaryId: null, targetType: "user" }, + ]); + + await clearOutbox(); + await prisma.user.update({ where: { id: user.id }, data: { isActive: false } }); + expect(await outboxRows()).toEqual([ + { isRevocation: true, primaryId: user.id, secondaryId: null, targetType: "user" }, + ]); + }); + + test("classifies unarchiving a directory as a grant unless it also changes organization", async () => { + const [organization, other] = await Promise.all([ + seedOrganization("Directory home"), + seedOrganization("Directory elsewhere"), + ]); + const directory = await prisma.feedbackDirectory.create({ + data: { name: "Archived", organizationId: organization.id, isArchived: true }, + }); + await clearOutbox(); + + await prisma.feedbackDirectory.update({ where: { id: directory.id }, data: { isArchived: false } }); + expect(await outboxRows()).toEqual([ + { isRevocation: false, primaryId: directory.id, secondaryId: null, targetType: "feedback_directory" }, + ]); + + await prisma.feedbackDirectory.update({ where: { id: directory.id }, data: { isArchived: true } }); + // Cleared AFTER the re-archive, not before: that setup step enqueues a revocation of its own, and + // `outboxRows` sorts revocations first, so asserting on `.at(0)` would have read the setup row and + // passed even with the organizationId clause deleted from the classifier. + await clearOutbox(); + + await prisma.feedbackDirectory.update({ + where: { id: directory.id }, + data: { isArchived: false, organizationId: other.id }, + }); + + // Reconciliation clears every previous parent before restoring the current one. Treat the move as + // a revocation so the freshness guard remains active until that exact replacement is delivered. + expect(await outboxRows()).toEqual([ + { isRevocation: true, primaryId: directory.id, secondaryId: null, targetType: "feedback_directory" }, + ]); + }); + + test("classifies an unmapped enum move as a revocation", async () => { + const [user, organization] = await Promise.all([ + seedUser("team@integration.test"), + seedOrganization("Team owner"), + ]); + const team = await prisma.team.create({ data: { name: "Team", organizationId: organization.id } }); + await prisma.teamUser.create({ data: { teamId: team.id, userId: user.id, role: "contributor" } }); + await clearOutbox(); + + await prisma.teamUser.update({ + where: { teamId_userId: { teamId: team.id, userId: user.id } }, + data: { role: "admin" }, + }); + + expect(await outboxRows()).toEqual([ + { isRevocation: true, primaryId: team.id, secondaryId: user.id, targetType: "team_membership" }, + ]); + }); + + test("classifies a delete as a revocation", async () => { + const [user, organization] = await Promise.all([ + seedUser("delete@integration.test"), + seedOrganization("Delete"), + ]); + await prisma.membership.create({ + data: { organizationId: organization.id, userId: user.id, accepted: true, role: "member" }, + }); + await clearOutbox(); + + await prisma.membership.delete({ + where: { userId_organizationId: { organizationId: organization.id, userId: user.id } }, + }); + + expect(await outboxRows()).toEqual([ + { isRevocation: true, primaryId: organization.id, secondaryId: user.id, targetType: "membership" }, + ]); + }); +}); + +type TReleaseState = Readonly<{ availableAt: Date; deadLetteredAt: Date | null; permanentFailures: number }>; + +const insertClaimedEvent = async ( + id: string, + overrides: Readonly<{ + attempts?: number; + isRevocation?: boolean; + permanentFailures?: number; + processedAt?: Date; + }> = {} +): Promise => { + await prisma.$executeRaw` + INSERT INTO "AuthzedProjectionOutbox" + ("id", "targetType", "primaryId", "isRevocation", "attempts", "permanentFailures", "processedAt", + "leaseOwner", "updatedAt") + VALUES ( + ${id}, 'membership', 'organization-id', ${overrides.isRevocation ?? false}, + ${overrides.attempts ?? 1}, ${overrides.permanentFailures ?? 0}, ${overrides.processedAt ?? null}, + 'lease', NOW() + ) + `; +}; + +/** Releasing clears the lease, so the next failure has to be preceded by a fresh claim. */ +const reclaim = (id: string): Promise => + prisma.$executeRaw`UPDATE "AuthzedProjectionOutbox" SET "leaseOwner" = 'lease' WHERE "id" = ${id}`; + +const releaseState = async (id: string): Promise => { + const [row] = await prisma.$queryRaw` + SELECT "availableAt", "deadLetteredAt", "permanentFailures" + FROM "AuthzedProjectionOutbox" WHERE "id" = ${id} + `; + return row; +}; + +describe("AuthZed projection outbox failure release", () => { + test("dead-letters only after enough failures attributable to one event", async () => { + await insertClaimedEvent("solo", { permanentFailures: AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES - 2 }); + + await expect( + markAuthzedOutboxEventsFailed("lease", ["solo"], "authzed_invalid_request", { + attributable: true, + retryable: false, + }) + ).resolves.toBe(0); + expect((await releaseState("solo")).permanentFailures).toBe(AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES - 1); + + await reclaim("solo"); + await expect( + markAuthzedOutboxEventsFailed("lease", ["solo"], "authzed_invalid_request", { + attributable: true, + retryable: false, + }) + ).resolves.toBe(1); + expect((await releaseState("solo")).deadLetteredAt).toBeInstanceOf(Date); + }); + + test("never dead-letters a retryable failure, however long the outage runs", async () => { + // The reported failure mode: SpiceDB down for an hour dead-letters two hundred healthy events, and + // a dead-lettered revocation denies every enforced check until an operator replays it by hand. + await insertClaimedEvent("outage", { attempts: 60, permanentFailures: 0 }); + + for (let attempt = 0; attempt < AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES + 5; attempt++) { + await reclaim("outage"); + await expect( + markAuthzedOutboxEventsFailed("lease", ["outage"], "authzed_unavailable", { + attributable: true, + retryable: true, + }) + ).resolves.toBe(0); + } + + expect(await releaseState("outage")).toMatchObject({ deadLetteredAt: null, permanentFailures: 0 }); + }); + + test("never dead-letters an event a group failure could not attribute", async () => { + await insertClaimedEvent("bystander", { permanentFailures: AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES - 1 }); + + await expect( + markAuthzedOutboxEventsFailed("lease", ["bystander", "other"], "authzed_projection_invalid_source", { + attributable: false, + retryable: false, + }) + ).resolves.toBe(0); + + expect(await releaseState("bystander")).toMatchObject({ + deadLetteredAt: null, + permanentFailures: AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES - 1, + }); + }); + + test("never clears an existing dead letter even if a lease invariant is violated", async () => { + await insertClaimedEvent("dead-letter", { + permanentFailures: AUTHZED_OUTBOX_MAX_PERMANENT_FAILURES - 1, + }); + await markAuthzedOutboxEventsFailed("lease", ["dead-letter"], "authzed_invalid_request", { + attributable: true, + retryable: false, + }); + + await reclaim("dead-letter"); + await expect( + markAuthzedOutboxEventsFailed("lease", ["dead-letter"], "authzed_unavailable", { + attributable: false, + retryable: true, + }) + ).resolves.toBe(0); + expect((await releaseState("dead-letter")).deadLetteredAt).toBeInstanceOf(Date); + }); + + test("backs off further for a later attempt and stops growing at the ceiling", async () => { + await Promise.all([ + insertClaimedEvent("early", { attempts: 3 }), + insertClaimedEvent("late", { attempts: 40 }), + ]); + + await markAuthzedOutboxEventsFailed("lease", ["early", "late"], "authzed_unavailable", { + attributable: false, + retryable: true, + }); + + const [early, late] = await Promise.all([releaseState("early"), releaseState("late")]); + expect(early.availableAt.getTime()).toBeLessThan(late.availableAt.getTime()); + // Capped rather than overflowed: 2 ^ 40 milliseconds is thirty-five thousand years. + const cappedDelayMs = late.availableAt.getTime() - Date.now(); + expect(cappedDelayMs).toBeGreaterThanOrEqual(AUTHZED_OUTBOX_MAX_RETRY_DELAY_MS - 5_000); + expect(cappedDelayMs).toBeLessThanOrEqual(AUTHZED_OUTBOX_MAX_RETRY_DELAY_MS + 5_000); + }); + + test("leaves an event released by a lease it no longer owns untouched", async () => { + await insertClaimedEvent("stolen"); + await prisma.$executeRaw`UPDATE "AuthzedProjectionOutbox" SET "leaseOwner" = 'other' WHERE "id" = 'stolen'`; + + await expect( + markAuthzedOutboxEventsFailed("lease", ["stolen"], "authzed_internal", { + attributable: true, + retryable: false, + }) + ).resolves.toBe(0); + + expect((await releaseState("stolen")).permanentFailures).toBe(0); + }); +}); + +describe("AuthZed projection freshness guard", () => { + test("stays disarmed for a grant that has been pending far past the window", async () => { + // The whole point of the classifier: a bulk invite acceptance must not deny the deployment. + await insertClaimedEvent("aged-grant", { isRevocation: false }); + await prisma.$executeRaw`UPDATE "AuthzedProjectionOutbox" SET "createdAt" = NOW() - INTERVAL '1 hour' WHERE "id" = 'aged-grant'`; + + await expect(hasStaleAuthzedRevocation()).resolves.toBe(false); + }); + + test("stays disarmed for a revocation that was actually delivered", async () => { + // Delivered rows are retained for seven days, so a healthy deployment permanently holds thousands + // of processed revocations far older than the window. Dropping `processedAt IS NULL` from either + // EXISTS would therefore deny the whole deployment forever, and nothing else in the suite writes a + // delivered row to notice. + await insertClaimedEvent("delivered-revocation", { isRevocation: true, processedAt: new Date() }); + await prisma.$executeRaw`UPDATE "AuthzedProjectionOutbox" SET "createdAt" = NOW() - INTERVAL '1 hour' WHERE "id" = 'delivered-revocation'`; + + await expect(hasStaleAuthzedRevocation()).resolves.toBe(false); + }); + + test("arms for an overdue revocation and for a dead-lettered one of any age", async () => { + await insertClaimedEvent("aged-revocation", { isRevocation: true }); + await prisma.$executeRaw`UPDATE "AuthzedProjectionOutbox" SET "createdAt" = NOW() - INTERVAL '1 hour' WHERE "id" = 'aged-revocation'`; + await expect(hasStaleAuthzedRevocation()).resolves.toBe(true); + + await prisma.$executeRaw`TRUNCATE "AuthzedProjectionOutbox"`; + await insertClaimedEvent("fresh-dead-letter", { isRevocation: true }); + await prisma.$executeRaw`UPDATE "AuthzedProjectionOutbox" SET "deadLetteredAt" = NOW() WHERE "id" = 'fresh-dead-letter'`; + // No age bound on dead letters is deliberate: an old one is more dangerous than a fresh one. + await expect(hasStaleAuthzedRevocation()).resolves.toBe(true); + }); +}); + +describe("AuthZed projection outbox indexes", () => { + test("keeps every hot-path index off the retained delivery history", async () => { + const indexes = await prisma.$queryRaw>` + SELECT "indexname", "indexdef" FROM pg_indexes + WHERE "tablename" = 'AuthzedProjectionOutbox' AND "indexname" <> 'AuthzedProjectionOutbox_pkey' + ORDER BY "indexname" + `; + + expect(indexes.map(({ indexname }) => indexname)).toEqual([ + "AuthzedProjectionOutbox_claim_idx", + "AuthzedProjectionOutbox_processed_idx", + "AuthzedProjectionOutbox_undelivered_idx", + ]); + for (const { indexdef } of indexes) { + expect(indexdef).toMatch(/WHERE /); + } + }); + + test("serves the claim in index order rather than sorting the backlog", async () => { + // Seeded and analyzed on purpose: on an empty table the planner prefers a sequential scan whatever + // indexes exist, so asserting the plan without a backlog and real statistics measures nothing. + await prisma.$executeRaw` + INSERT INTO "AuthzedProjectionOutbox" + ("id", "targetType", "primaryId", "isRevocation", "createdAt", "updatedAt") + SELECT + 'plan-' || generated::text, 'membership', 'organization-id', generated % 2 = 0, + NOW() - (generated * INTERVAL '1 second'), NOW() + FROM generate_series(1, 2000) AS generated + `; + await prisma.$executeRawUnsafe('ANALYZE "AuthzedProjectionOutbox";'); + + const plan = await prisma.$queryRaw>` + EXPLAIN SELECT "id" FROM "AuthzedProjectionOutbox" + WHERE "processedAt" IS NULL AND "deadLetteredAt" IS NULL AND "availableAt" <= NOW() + AND ("leaseExpiresAt" IS NULL OR "leaseExpiresAt" <= NOW()) + ORDER BY "isRevocation" DESC, "createdAt" ASC + LIMIT 200 + `; + const rendered = plan.map((line) => line["QUERY PLAN"]).join("\n"); + + expect(rendered).toContain("AuthzedProjectionOutbox_claim_idx"); + expect(rendered).not.toContain("Sort"); + }); +}); diff --git a/apps/web/lib/authzed/outbox-types.ts b/apps/web/lib/authzed/outbox-types.ts new file mode 100644 index 000000000000..a0eb9bc0fdd5 --- /dev/null +++ b/apps/web/lib/authzed/outbox-types.ts @@ -0,0 +1,45 @@ +import "server-only"; + +export const AUTHZED_OUTBOX_TARGET_TYPES = [ + "api_key", + "api_key_workspace", + "feedback_directory", + "feedback_directory_assignment", + "membership", + "organization", + "team", + "team_membership", + "user", + "workspace", + "workspace_team", +] as const; + +export type TAuthzedOutboxTargetType = (typeof AUTHZED_OUTBOX_TARGET_TYPES)[number]; + +export type TAuthzedOutboxEvent = Readonly<{ + attempts: number; + createdAt: Date; + id: string; + isRevocation: boolean; + primaryId: string; + secondaryId: string | null; + targetType: TAuthzedOutboxTargetType; +}>; + +export type TAuthzedOutboxStatus = Readonly<{ + deadLettered: number; + oldestPendingAgeSeconds: number | null; + overdueRevocations: number; + pending: number; + revocationsPastCritical: number; + revocationsPastWarning: number; +}>; + +export type TAuthzedOutboxDrainResult = Readonly<{ + claimed: number; + deadLettered: number; + delivered: number; + failed: number; + remaining: number; + status: "drained" | "partial"; +}>; diff --git a/apps/web/lib/authzed/projection-boundary.ts b/apps/web/lib/authzed/projection-boundary.ts new file mode 100644 index 000000000000..de6f7d5071e9 --- /dev/null +++ b/apps/web/lib/authzed/projection-boundary.ts @@ -0,0 +1,28 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import type { TAuthzedProjectionResult } from "./projection"; + +export const runPostCommitProjection = async ( + operation: string, + projection: () => Promise +): Promise => { + try { + await projection(); + } catch (error) { + logger.error( + { + component: "authzed", + // `errorCode`, matching every other AuthZed failure log. This previously used `code`, so a + // single query could not cover both this path and the projection failures below it — and this + // is the path that fires when a projector itself has a bug, which is the one you least want to + // miss. + errorCode: "authzed_internal", + errorName: error instanceof Error ? error.name : "NonError", + operation, + retryable: false, + status: "failed", + }, + "Unexpected AuthZed projection failure after source commit" + ); + } +}; diff --git a/apps/web/lib/authzed/projection-chunks.test.ts b/apps/web/lib/authzed/projection-chunks.test.ts new file mode 100644 index 000000000000..fe9b6bd270c3 --- /dev/null +++ b/apps/web/lib/authzed/projection-chunks.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, vi } from "vitest"; +import { AUTHZED_TARGET_CHUNK_SIZE } from "./constants"; +import { runChunked } from "./projection-chunks"; + +describe("runChunked", () => { + test("aggregates reconciliation passes across every successful chunk", async () => { + const reconcile = vi + .fn() + .mockResolvedValueOnce({ passes: 2, status: "projected" }) + .mockResolvedValueOnce({ passes: 3, status: "projected" }); + + await expect( + runChunked(reconcile, { + memberships: Array.from({ length: AUTHZED_TARGET_CHUNK_SIZE + 1 }, (_unused, index) => index), + }) + ).resolves.toEqual({ passes: 5, status: "projected" }); + expect(reconcile).toHaveBeenCalledTimes(2); + }); + + test("stops at the first unsuccessful chunk", async () => { + const failure = { + attempts: 3, + code: "authzed_projection_unstable" as const, + retryable: false, + status: "failed" as const, + }; + const reconcile = vi + .fn() + .mockResolvedValueOnce({ passes: 1, status: "projected" }) + .mockResolvedValue(failure); + + await expect( + runChunked(reconcile, { + memberships: Array.from({ length: AUTHZED_TARGET_CHUNK_SIZE * 2 + 1 }, (_unused, index) => index), + }) + ).resolves.toEqual(failure); + expect(reconcile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/lib/authzed/projection-chunks.ts b/apps/web/lib/authzed/projection-chunks.ts new file mode 100644 index 000000000000..059a3d230b5c --- /dev/null +++ b/apps/web/lib/authzed/projection-chunks.ts @@ -0,0 +1,59 @@ +import "server-only"; +import { AUTHZED_TARGET_CHUNK_SIZE } from "./constants"; +import type { TAuthzedProjectionResult } from "./projection"; + +/** + * Run one reconciler over chunked targets, stopping at the first chunk that does not project. + * + * A reconciler accepts several target lists at once and reads one PostgreSQL snapshot covering all of + * them, so every list it understands is passed in a single call. Splitting them would multiply the + * snapshot reads and the verification passes for no benefit. + * + * Chunking bounds each list independently, because each becomes its own `OR` clause in the snapshot + * query. Call *i* takes chunk *i* of every list, so the number of calls is set by the longest list + * rather than by their total. + * + * Returns `null` when every list was empty, so nothing reaches a reconciler — the write facade rejects + * an empty update batch. + * + * Shared by the backfill sweep and the durable outbox: the outbox claims a bounded batch of events, + * but a batch of user events expands into an unbounded number of membership targets, so it needs the + * same bound. + */ +export const runChunked = async >>>( + reconcile: (targets: TTargets) => Promise, + targets: TTargets +): Promise => { + type TEntry = readonly [keyof TTargets & string, ReadonlyArray]; + const entries = (Object.entries(targets) as ReadonlyArray).filter(([, items]) => items.length > 0); + if (entries.length === 0) { + return null; + } + + const chunkCount = Math.max( + ...entries.map(([, items]) => Math.ceil(items.length / AUTHZED_TARGET_CHUNK_SIZE)) + ); + + let passes = 0; + for (let index = 0; index < chunkCount; index++) { + const start = index * AUTHZED_TARGET_CHUNK_SIZE; + // Built by narrowing a full target object rather than assembling a partial one and asserting the + // type. Every field of the reconcilers' target types is optional, so an assertion would silently + // keep compiling if one ever became required — and the missing list would only surface at runtime. + const chunkTargets: TTargets = { ...targets }; + for (const [key, items] of entries) { + (chunkTargets as Record>)[key] = items.slice( + start, + start + AUTHZED_TARGET_CHUNK_SIZE + ); + } + + const result = await reconcile(chunkTargets); + if (result.status !== "projected") { + return result; + } + passes += result.passes; + } + + return { passes, status: "projected" }; +}; diff --git a/apps/web/lib/authzed/projection.ts b/apps/web/lib/authzed/projection.ts new file mode 100644 index 000000000000..f91e2684c6a6 --- /dev/null +++ b/apps/web/lib/authzed/projection.ts @@ -0,0 +1,114 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { isAuthzedEnabled } from "./config"; +import { AuthzedError, type TAuthzedErrorCode, mapAuthzedError } from "./errors"; +import { recordAuthzedProjection } from "./metrics"; + +export const AUTHZED_MAX_RECONCILIATION_PASSES = 3; + +type TAuthzedProjectionErrorCode = + | TAuthzedErrorCode + | "authzed_projection_invalid_source" + | "authzed_projection_unstable"; + +export type TAuthzedProjectionResult = + | Readonly<{ status: "disabled" }> + | Readonly<{ passes: number; status: "projected" }> + | Readonly<{ + attempts: number; + code: TAuthzedProjectionErrorCode; + retryable: boolean; + status: "failed"; + }>; + +export class AuthzedProjectionUnstableError extends Error { + readonly attempts = AUTHZED_MAX_RECONCILIATION_PASSES; + readonly code = "authzed_projection_unstable"; + readonly retryable = false; +} + +export class AuthzedProjectionInvalidSourceError extends Error { + readonly attempts = 1; + readonly code = "authzed_projection_invalid_source"; + readonly retryable = false; +} + +const getProjectionError = ( + error: unknown, + operation: string +): Readonly<{ + attempts: number; + code: TAuthzedProjectionErrorCode; + retryable: boolean; +}> => { + if ( + error instanceof AuthzedProjectionUnstableError || + error instanceof AuthzedProjectionInvalidSourceError + ) { + return error; + } + + const attempts = error instanceof AuthzedError ? error.attempts : 1; + return mapAuthzedError(error, operation, attempts); +}; + +export const runBestEffortProjection = async ( + operation: string, + projectionName: string, + projection: () => Promise +): Promise => { + if (!isAuthzedEnabled()) { + // Recorded rather than skipped: a deployment that believes AuthZed is on while it is off looks + // identical to a healthy one from every other signal. + recordAuthzedProjection({ durationMs: 0, operation, projection: projectionName, status: "disabled" }); + return { status: "disabled" }; + } + + const startedAt = performance.now(); + + try { + const passes = await projection(); + const durationMs = Math.max(0, Math.round(performance.now() - startedAt)); + + logger.debug( + { + component: "authzed", + durationMs, + operation, + passes, + projection: projectionName, + status: "projected", + }, + "AuthZed relationship projection completed" + ); + recordAuthzedProjection({ durationMs, operation, projection: projectionName, status: "projected" }); + + return { passes, status: "projected" }; + } catch (error) { + const mappedError = getProjectionError(error, operation); + const durationMs = Math.max(0, Math.round(performance.now() - startedAt)); + const result = { + attempts: mappedError.attempts, + code: mappedError.code, + retryable: mappedError.retryable, + status: "failed" as const, + }; + + logger.warn( + { + attempts: result.attempts, + component: "authzed", + durationMs, + errorCode: result.code, + operation, + projection: projectionName, + retryable: result.retryable, + status: result.status, + }, + "AuthZed relationship projection failed" + ); + recordAuthzedProjection({ durationMs, operation, projection: projectionName, status: "failed" }); + + return result; + } +}; diff --git a/apps/web/lib/authzed/relationship-batches.ts b/apps/web/lib/authzed/relationship-batches.ts new file mode 100644 index 000000000000..efeaa1415222 --- /dev/null +++ b/apps/web/lib/authzed/relationship-batches.ts @@ -0,0 +1,41 @@ +import "server-only"; +import type { TAuthzedClient, TAuthzedRelationshipFilter, TAuthzedRelationshipUpdate } from "./client"; +import { AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES, AUTHZED_MAX_RELATIONSHIP_UPDATES } from "./constants"; + +export const packRelationshipUpdateGroups = ( + groups: ReadonlyArray> +): ReadonlyArray> => { + const batches: TAuthzedRelationshipUpdate[][] = []; + let batch: TAuthzedRelationshipUpdate[] = []; + + for (const group of groups) { + if (group.length > AUTHZED_MAX_RELATIONSHIP_UPDATES) { + throw new Error("AuthZed relationship update group exceeds the maximum batch size"); + } + + if (batch.length > 0 && batch.length + group.length > AUTHZED_MAX_RELATIONSHIP_UPDATES) { + batches.push(batch); + batch = []; + } + batch.push(...group); + } + + if (batch.length > 0) { + batches.push(batch); + } + + return batches; +}; + +export const deleteRelationshipsInBoundedBatches = async ( + client: TAuthzedClient, + filters: ReadonlyArray +): Promise => { + for (let start = 0; start < filters.length; start += AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES) { + await Promise.all( + filters + .slice(start, start + AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES) + .map((filter) => client.deleteRelationships(filter)) + ); + } +}; diff --git a/apps/web/lib/authzed/relationship-map.test.ts b/apps/web/lib/authzed/relationship-map.test.ts new file mode 100644 index 000000000000..32c5fd517ef6 --- /dev/null +++ b/apps/web/lib/authzed/relationship-map.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "vitest"; +import { + ApiKeyPermission, + OrganizationRole, + TeamUserRole, + WorkspaceTeamPermission, +} from "@formbricks/database/prisma"; +import { + ORGANIZATION_ACCESS_RELATIONS, + ORGANIZATION_RELATIONS, + TEAM_RELATIONS, + WORKSPACE_API_KEY_RELATIONS, + WORKSPACE_TEAM_RELATIONS, + normalizeOrganizationAccess, +} from "./relationship-map"; + +/** + * These assertions pin relation names against the deployed `authzed/schema.zed`. + * + * Renaming a relation on one side only is the failure this file exists to catch: reconciling tooling + * derives the expected relationship set from these maps, so a mismatch makes it read correct + * relationships as orphaned and, when pruning, delete real access. A change here is a schema change + * and must be paired with one. + */ +describe("relation name mappings", () => { + test("maps every organization role to its schema relation", () => { + expect(ORGANIZATION_RELATIONS).toEqual({ + [OrganizationRole.billing]: "billing", + [OrganizationRole.manager]: "manager", + [OrganizationRole.member]: "member", + [OrganizationRole.owner]: "owner", + }); + }); + + test("maps every team role to its schema relation", () => { + expect(TEAM_RELATIONS).toEqual({ + [TeamUserRole.admin]: "admin", + [TeamUserRole.contributor]: "contributor", + }); + }); + + test("maps every workspace-team permission to its team-suffixed schema relation", () => { + expect(WORKSPACE_TEAM_RELATIONS).toEqual({ + [WorkspaceTeamPermission.manage]: "manager_team", + [WorkspaceTeamPermission.read]: "reader_team", + [WorkspaceTeamPermission.readWrite]: "writer_team", + }); + }); + + test("maps every API-key permission to its bare workspace schema relation", () => { + // Deliberately unsuffixed, unlike the team grants above: the schema distinguishes an API-key + // subject from a team subject by relation name. + expect(WORKSPACE_API_KEY_RELATIONS).toEqual({ + [ApiKeyPermission.manage]: "manager", + [ApiKeyPermission.read]: "reader", + [ApiKeyPermission.write]: "writer", + }); + }); + + test("maps organization access flags to their api-key-subject schema relations", () => { + expect(ORGANIZATION_ACCESS_RELATIONS).toEqual({ + read: "api_key_reader", + write: "api_key_writer", + }); + }); + + test.each([ + ["every Prisma organization role", Object.keys(OrganizationRole), ORGANIZATION_RELATIONS], + ["every Prisma team role", Object.keys(TeamUserRole), TEAM_RELATIONS], + [ + "every Prisma workspace-team permission", + Object.keys(WorkspaceTeamPermission), + WORKSPACE_TEAM_RELATIONS, + ], + ["every Prisma API-key permission", Object.keys(ApiKeyPermission), WORKSPACE_API_KEY_RELATIONS], + ])("covers %s with a distinct relation", (_label, sourceValues, relations) => { + expect(Object.keys(relations).sort()).toEqual([...sourceValues].sort()); + // Two source values sharing a relation would make the projectors' touch-one/delete-the-alternates + // logic delete the relation it just wrote. + expect(new Set(Object.values(relations)).size).toBe(sourceValues.length); + }); +}); + +describe("normalizeOrganizationAccess", () => { + test("reads both flags independently", () => { + expect(normalizeOrganizationAccess({ accessControl: { read: true, write: true } })).toEqual({ + read: true, + write: true, + }); + expect(normalizeOrganizationAccess({ accessControl: { read: true, write: false } })).toEqual({ + read: true, + write: false, + }); + }); + + test.each([ + ["null", null], + ["undefined", undefined], + ["a string", "read"], + ["an array", []], + ["an empty object", {}], + ["a non-object accessControl", { accessControl: "read" }], + ["a null accessControl", { accessControl: null }], + ["an accessControl array", { accessControl: [] }], + ])("denies both flags for %s", (_label, value) => { + expect(normalizeOrganizationAccess(value)).toEqual({ read: false, write: false }); + }); + + test.each([ + ["a truthy string", "true"], + ["the number one", 1], + ["a truthy object", {}], + ])("denies %s rather than coercing it to a grant", (_label, flagValue) => { + expect(normalizeOrganizationAccess({ accessControl: { read: flagValue, write: flagValue } })).toEqual({ + read: false, + write: false, + }); + }); +}); diff --git a/apps/web/lib/authzed/relationship-map.ts b/apps/web/lib/authzed/relationship-map.ts new file mode 100644 index 000000000000..d833eb8f43ea --- /dev/null +++ b/apps/web/lib/authzed/relationship-map.ts @@ -0,0 +1,92 @@ +import "server-only"; +import { + ApiKeyPermission, + OrganizationRole, + TeamUserRole, + WorkspaceTeamPermission, +} from "@formbricks/database/prisma"; + +/** + * The single source of truth mapping PostgreSQL source values to SpiceDB relation names. + * + * These live here rather than privately inside each projector because reconciling tooling has to + * derive the *expected* relationship set from the same mapping the projectors write. Two copies would + * mean a renamed relation makes reconciliation classify a correct relationship as orphaned and, when + * pruning, delete it — so the `owner` mapping in particular must exist exactly once. + * + * Every map is `satisfies Record` so adding a value to a Prisma enum is a + * compile error until it is mapped, rather than a silently unprojected role. + * + * Deliberately excluded: the relationship *builders*. Those encode each projector's + * touch-the-current-value / delete-the-alternates semantics, which the expected-set derivation does + * not want. + */ + +/** `Membership.role` → `organization#@user`. Exactly one applies to a given membership. */ +export const ORGANIZATION_RELATIONS = { + [OrganizationRole.billing]: "billing", + [OrganizationRole.manager]: "manager", + [OrganizationRole.member]: "member", + [OrganizationRole.owner]: "owner", +} as const satisfies Record; + +/** `TeamUser.role` → `team#@user`. */ +export const TEAM_RELATIONS = { + [TeamUserRole.admin]: "admin", + [TeamUserRole.contributor]: "contributor", +} as const satisfies Record; + +/** `WorkspaceTeam.permission` → `workspace#@team#member`. */ +export const WORKSPACE_TEAM_RELATIONS = { + [WorkspaceTeamPermission.manage]: "manager_team", + [WorkspaceTeamPermission.read]: "reader_team", + [WorkspaceTeamPermission.readWrite]: "writer_team", +} as const satisfies Record; + +/** `ApiKeyWorkspace.permission` → `workspace#@api_key`. */ +export const WORKSPACE_API_KEY_RELATIONS = { + [ApiKeyPermission.manage]: "manager", + [ApiKeyPermission.read]: "reader", + [ApiKeyPermission.write]: "writer", +} as const satisfies Record; + +/** + * An API key's organization-level access rights. + * + * Unlike the role ladders these two flags are independent: a key may hold both, either, or neither. + */ +export type TOrganizationAccessSnapshot = Readonly<{ + read: boolean; + write: boolean; +}>; + +/** + * `ApiKey.organizationAccess.accessControl.{read,write}` → `organization#@api_key`. + * + * Note the inverted direction relative to the workspace relations: the organization is the resource + * and the API key is the subject. + */ +export const ORGANIZATION_ACCESS_RELATIONS = { + read: "api_key_reader", + write: "api_key_writer", +} as const satisfies Record; + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Read an API key's organization access out of its untyped JSON column. + * + * Strictly `=== true`, so a missing, malformed, or non-boolean value denies — matching the current + * evaluator rather than guessing at intent. + */ +export const normalizeOrganizationAccess = (value: unknown): TOrganizationAccessSnapshot => { + if (!isRecord(value) || !isRecord(value.accessControl)) { + return { read: false, write: false }; + } + + return { + read: value.accessControl.read === true, + write: value.accessControl.write === true, + }; +}; diff --git a/apps/web/lib/authzed/relationship-reads.test.ts b/apps/web/lib/authzed/relationship-reads.test.ts new file mode 100644 index 000000000000..dc1ec6daf237 --- /dev/null +++ b/apps/web/lib/authzed/relationship-reads.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TAuthzedRelationship, TAuthzedRelationshipPage } from "./client"; +import { AUTHZED_MAX_OBSERVED_RELATIONSHIPS_PER_UNIT, AUTHZED_MAX_RELATIONSHIP_READS } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { forEachRelationshipPage, readAllRelationships } from "./relationship-reads"; + +const relationship = (objectId: string): TAuthzedRelationship => ({ + relation: "owner", + resource: { objectId, objectType: "organization" }, + subject: { objectId: "user-1", objectType: "user" }, +}); + +/** A page of exactly `AUTHZED_MAX_RELATIONSHIP_READS` relationships, i.e. one that may have more behind it. */ +const fullPage = (prefix: string, cursor: string | null): TAuthzedRelationshipPage => ({ + cursor: cursor ? { token: cursor } : null, + relationships: Array.from({ length: AUTHZED_MAX_RELATIONSHIP_READS }, (_unused, index) => + relationship(`${prefix}-${index}`) + ), + snapshot: { token: "revision-1" }, +}); + +const readRelationships = vi.fn(); +const client = { readRelationships }; +const filter = { resourceType: "organization" } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("readAllRelationships", () => { + test("resolves the first page without a pinned revision and returns the one it was given", async () => { + readRelationships.mockResolvedValue({ + cursor: null, + relationships: [relationship("org-1")], + snapshot: { token: "revision-1" }, + }); + + await expect(readAllRelationships(client, filter)).resolves.toEqual({ + relationships: [relationship("org-1")], + snapshot: { token: "revision-1" }, + }); + + expect(readRelationships).toHaveBeenCalledTimes(1); + expect(readRelationships).toHaveBeenCalledWith({ + filter, + limit: AUTHZED_MAX_RELATIONSHIP_READS, + }); + }); + + test("threads the cursor and varies nothing else between pages", async () => { + readRelationships.mockResolvedValueOnce(fullPage("first", "cursor-1")).mockResolvedValueOnce({ + cursor: null, + relationships: [relationship("last")], + snapshot: { token: "revision-1" }, + }); + + const observation = await readAllRelationships(client, filter); + + expect(observation.relationships).toHaveLength(AUTHZED_MAX_RELATIONSHIP_READS + 1); + expect(observation.snapshot).toEqual({ token: "revision-1" }); + + // SpiceDB rejects a cursor presented alongside any other changed argument, so the second request + // must differ from the first by the cursor alone. + expect(readRelationships.mock.calls[0][0]).toEqual({ + filter, + limit: AUTHZED_MAX_RELATIONSHIP_READS, + }); + expect(readRelationships.mock.calls[1][0]).toEqual({ + cursor: { token: "cursor-1" }, + filter, + limit: AUTHZED_MAX_RELATIONSHIP_READS, + }); + }); + + test("abandons the read when a later page reports a different revision", async () => { + // The cursor is supposed to hold the revision steady. If it ever did not, the pages would describe + // different views and a pruning caller could delete a relationship that merely moved between them. + readRelationships.mockResolvedValueOnce(fullPage("first", "cursor-1")).mockResolvedValueOnce({ + cursor: null, + relationships: [relationship("last")], + snapshot: { token: "revision-2" }, + }); + + await expect(readAllRelationships(client, filter)).rejects.toThrow(AUTHZED_ERROR_CODES.ABORTED); + }); + + test("stops on a full page that offers no cursor", async () => { + readRelationships.mockResolvedValueOnce(fullPage("only", null)); + + await expect(readAllRelationships(client, filter)).resolves.toMatchObject({ + snapshot: { token: "revision-1" }, + }); + expect(readRelationships).toHaveBeenCalledTimes(1); + }); + + test("returns an empty observation with no revision when nothing matches", async () => { + readRelationships.mockResolvedValue({ cursor: null, relationships: [], snapshot: null }); + + await expect(readAllRelationships(client, filter)).resolves.toEqual({ + relationships: [], + snapshot: null, + }); + }); + + test("abandons the read rather than returning a partial observation past the memory bound", async () => { + // Every continued iteration consumes a full page, so the bound is also the loop bound. + const pagesToExceed = Math.ceil( + AUTHZED_MAX_OBSERVED_RELATIONSHIPS_PER_UNIT / AUTHZED_MAX_RELATIONSHIP_READS + ); + // A distinct cursor per page, because a real server advances it — and a fixture that repeats one + // cursor now trips the stall guard instead, which is a different failure than this test is about. + let page = 0; + readRelationships.mockImplementation(() => { + page += 1; + + return Promise.resolve(fullPage(`page-${page}`, `cursor-${page}`)); + }); + + await expect(readAllRelationships(client, filter)).rejects.toThrow(AUTHZED_ERROR_CODES.LIMIT_EXCEEDED); + expect(readRelationships).toHaveBeenCalledTimes(pagesToExceed + 1); + }); + + test("propagates a mid-drain failure instead of reporting what it read so far", async () => { + // Returning the pages already read would tell a pruning caller that the unread relationships do + // not exist. + readRelationships.mockResolvedValueOnce(fullPage("first", "cursor-1")).mockRejectedValueOnce( + new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, + operation: "read_relationships", + retryable: false, + }) + ); + + await expect(readAllRelationships(client, filter)).rejects.toThrow( + AUTHZED_ERROR_CODES.FAILED_PRECONDITION + ); + }); + + test("propagates a first-page failure", async () => { + readRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "read_relationships", + retryable: true, + }) + ); + + await expect(readAllRelationships(client, filter)).rejects.toThrow(AUTHZED_ERROR_CODES.UNAVAILABLE); + }); + + test("passes a narrowed filter through unchanged on every page", async () => { + const narrowed = { + relation: "reader_team", + resourceId: "ws-1", + resourceType: "workspace", + subject: { objectId: "team-1", objectType: "team", relation: "member" }, + } as const; + readRelationships + .mockResolvedValueOnce(fullPage("first", "cursor-1")) + .mockResolvedValueOnce({ cursor: null, relationships: [], snapshot: { token: "revision-1" } }); + + await readAllRelationships(client, narrowed); + + for (const call of readRelationships.mock.calls) { + expect(call[0].filter).toBe(narrowed); + } + }); +}); + +describe("forEachRelationshipPage", () => { + test("streams every page without accumulating them", async () => { + readRelationships.mockResolvedValueOnce(fullPage("first", "cursor-1")).mockResolvedValueOnce({ + cursor: null, + relationships: [relationship("last")], + snapshot: { token: "revision-1" }, + }); + const pageSizes: number[] = []; + + const snapshot = await forEachRelationshipPage(client, filter, async (relationships) => { + pageSizes.push(relationships.length); + }); + + expect(pageSizes).toEqual([AUTHZED_MAX_RELATIONSHIP_READS, 1]); + expect(snapshot).toEqual({ token: "revision-1" }); + }); + + test("aborts a bounded drain on a cursor that does not advance", async () => { + // The accumulation cap cannot stand in for this guard: a stalled cursor returning empty pages never + // grows the accumulator, so the loop would spin forever instead of tripping the bound. + const readRelationships = vi + .fn() + .mockResolvedValue({ cursor: { token: "stuck" }, relationships: [], snapshot: null }); + + await expect(readAllRelationships({ readRelationships }, { resourceType: "team" })).rejects.toThrow( + AUTHZED_ERROR_CODES.INTERNAL + ); + expect(readRelationships).toHaveBeenCalledTimes(2); + }); + + test("aborts on a cursor that does not advance rather than spinning forever", async () => { + // Termination depends on the server returning a cursor that moves. A command that hangs with no + // output and no exit code is worse for an operator than one that fails loudly. + readRelationships.mockResolvedValue(fullPage("stuck", "same-cursor")); + + await expect(forEachRelationshipPage(client, filter, async () => {})).rejects.toThrow( + AUTHZED_ERROR_CODES.INTERNAL + ); + }); + + test("propagates a callback failure so a partial stream cannot look complete", async () => { + readRelationships.mockResolvedValueOnce(fullPage("first", "cursor-1")); + + await expect( + forEachRelationshipPage(client, filter, async () => { + throw new Error("classification failed"); + }) + ).rejects.toThrow("classification failed"); + }); + + test("aborts when a later page reports a different revision", async () => { + readRelationships.mockResolvedValueOnce(fullPage("first", "cursor-1")).mockResolvedValueOnce({ + cursor: null, + relationships: [relationship("last")], + snapshot: { token: "revision-2" }, + }); + + await expect(forEachRelationshipPage(client, filter, async () => {})).rejects.toThrow( + AUTHZED_ERROR_CODES.ABORTED + ); + }); +}); diff --git a/apps/web/lib/authzed/relationship-reads.ts b/apps/web/lib/authzed/relationship-reads.ts new file mode 100644 index 000000000000..f9f82b3d832b --- /dev/null +++ b/apps/web/lib/authzed/relationship-reads.ts @@ -0,0 +1,180 @@ +import "server-only"; +import type { + TAuthzedClient, + TAuthzedReadCursor, + TAuthzedRelationship, + TAuthzedRelationshipReadFilter, + TAuthzedSnapshot, +} from "./client"; +import { AUTHZED_MAX_OBSERVED_RELATIONSHIPS_PER_UNIT, AUTHZED_MAX_RELATIONSHIP_READS } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; + +/** + * Pagination over the facade's single-page relationship read, kept outside the frozen facade for the + * same reason relationship batching is: the facade stays a transport boundary and the looping, + * bounding, and revision-pinning policy lives here where it can evolve. + * + * Reads are for reconciling SpiceDB against PostgreSQL. They must never back a permission decision — + * see the note on `TAuthzedClient.readRelationships`. + */ + +/** + * Assert the cursor advanced. + * + * Termination is not ours to guarantee — it depends on the server — so assert it rather than assume it. + * A command that hangs with no output and no exit code is the worst thing to hand an operator, and the + * accumulation bound in `readAllRelationships` cannot stand in for this: a stalled cursor returning + * empty pages never grows the accumulator, so that loop would spin forever rather than trip its cap. + */ +const assertCursorAdvanced = ( + previous: TAuthzedReadCursor | undefined, + next: TAuthzedReadCursor | null, + operation: string +): void => { + if (previous !== undefined && next?.token === previous.token) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation, + retryable: false, + }); + } +}; + +/** + * Assert every page describes the same revision. + * + * The cursor is supposed to hold the revision steady. Verifying it turns a silent torn read — the + * failure that would make a pruning caller delete live relationships — into a loud one. + */ +const assertSameRevision = ( + snapshot: TAuthzedSnapshot | null, + pageSnapshot: TAuthzedSnapshot | null, + operation: string +): void => { + if (snapshot !== null && pageSnapshot !== null && pageSnapshot.token !== snapshot.token) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.ABORTED, + operation, + retryable: true, + }); + } +}; + +/** + * A complete observation of every relationship matching a filter, at one revision. + * + * There is deliberately no "partial" or "truncated" variant. A reconciler that mistook a partial + * observation for a complete one would conclude that the relationships it failed to read do not + * exist — and, when pruning, delete live access. So an incomplete drain is not representable here: + * `readAllRelationships` either returns every matching relationship or throws. + */ +export type TAuthzedRelationshipObservation = Readonly<{ + relationships: ReadonlyArray; + /** + * The revision every page was read at, or `null` when nothing matched the filter (SpiceDB reports + * no revision for an empty result). + */ + snapshot: TAuthzedSnapshot | null; +}>; + +/** + * Stream every relationship matching `filter`, one page at a time. + * + * Use this rather than `readAllRelationships` whenever the match is unbounded — a whole resource type, + * say. Accumulating those would hold the entire store in memory and trip the per-unit bound, which for a + * deployment with more relationships than that bound would make the sweep fail outright rather than + * merely slow. + * + * Consistency behaves exactly as below: identical parameters on every page, the cursor carries the + * revision, and a page reporting a different revision aborts rather than silently mixing two views. + * `onPage` failures propagate, so a caller cannot mistake a partial stream for a complete one. + * + * Returns the revision the stream was read at, or `null` if nothing matched. + */ +export const forEachRelationshipPage = async ( + client: Pick, + filter: TAuthzedRelationshipReadFilter, + onPage: (relationships: ReadonlyArray) => Promise +): Promise => { + let snapshot: TAuthzedSnapshot | null = null; + let cursor: TAuthzedReadCursor | undefined; + + do { + const page = await client.readRelationships({ + ...(cursor ? { cursor } : {}), + filter, + limit: AUTHZED_MAX_RELATIONSHIP_READS, + }); + + assertSameRevision(snapshot, page.snapshot, "for_each_relationship_page"); + assertCursorAdvanced(cursor, page.cursor, "for_each_relationship_page"); + + snapshot = page.snapshot ?? snapshot; + cursor = page.cursor ?? undefined; + + if (page.relationships.length > 0) { + await onPage(page.relationships); + } + } while (cursor); + + return snapshot; +}; + +/** + * Read every relationship matching `filter`, at a single consistent revision. + * + * Only for filters narrow enough to hold in memory — one resource, typically. For an unbounded filter + * use `forEachRelationshipPage`. + * + * Every page is requested with identical parameters and the cursor carries the revision it was issued + * at, so the pages of one drain describe one view. SpiceDB enforces the identical-parameters rule by + * rejecting a cursor presented with any other argument changed — which is why nothing here varies the + * consistency requirement between pages. + * + * Throws rather than returning a partial result when: + * + * - the match exceeds `AUTHZED_MAX_OBSERVED_RELATIONSHIPS_PER_UNIT` (`authzed_limit_exceeded`), which + * also bounds the loop, since every continued iteration must have consumed a full page; + * - a later page reports a different revision than the first, meaning the pages do not describe one + * view and the observation is torn; + * - any page fails for the usual operational reasons. + * + * Callers are expected to treat a throw as "this unit could not be observed" and continue with other + * units, never as "this unit has no relationships". + */ +export const readAllRelationships = async ( + client: Pick, + filter: TAuthzedRelationshipReadFilter +): Promise => { + const relationships: TAuthzedRelationship[] = []; + let snapshot: TAuthzedSnapshot | null = null; + let cursor: TAuthzedReadCursor | undefined; + + do { + const page = await client.readRelationships({ + ...(cursor ? { cursor } : {}), + filter, + limit: AUTHZED_MAX_RELATIONSHIP_READS, + }); + + if (relationships.length + page.relationships.length > AUTHZED_MAX_OBSERVED_RELATIONSHIPS_PER_UNIT) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.LIMIT_EXCEEDED, + operation: "read_all_relationships", + retryable: false, + }); + } + + assertSameRevision(snapshot, page.snapshot, "read_all_relationships"); + assertCursorAdvanced(cursor, page.cursor, "read_all_relationships"); + + relationships.push(...page.relationships); + snapshot = page.snapshot ?? snapshot; + cursor = page.cursor ?? undefined; + } while (cursor); + + return { relationships, snapshot }; +}; diff --git a/apps/web/lib/authzed/retry.test.ts b/apps/web/lib/authzed/retry.test.ts new file mode 100644 index 000000000000..15268844ea33 --- /dev/null +++ b/apps/web/lib/authzed/retry.test.ts @@ -0,0 +1,127 @@ +import { loggerMocks } from "./__mocks__/logger"; +import { status } from "@grpc/grpc-js"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { calculateAuthzedRetryDelayMs, executeAuthzedOperation } from "./retry"; + +describe("AuthZed retry policy", () => { + beforeEach(() => { + loggerMocks.debug.mockReset(); + loggerMocks.warn.mockReset(); + }); + + test("returns a first-attempt success without sleeping", async () => { + const request = vi.fn().mockResolvedValue("success"); + const sleep = vi.fn(); + + await expect(executeAuthzedOperation("read_schema", request, { sleep })).resolves.toBe("success"); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + expect(loggerMocks.debug).not.toHaveBeenCalled(); + expect(loggerMocks.warn).not.toHaveBeenCalled(); + }); + + test.each([ + [0, 0, 80], + [0, 0.5, 100], + [0, 1, 120], + [1, 0, 160], + [1, 0.5, 200], + [1, 1, 240], + ])("applies bounded jitter for retry %i at random value %f", (retryIndex, randomValue, expected) => { + expect(calculateAuthzedRetryDelayMs(retryIndex, randomValue)).toBe(expected); + }); + + test("retries transient failures and succeeds on the third attempt", async () => { + const request = vi + .fn() + .mockRejectedValueOnce({ code: status.UNAVAILABLE }) + .mockRejectedValueOnce({ code: status.RESOURCE_EXHAUSTED }) + .mockResolvedValue("success"); + const sleep = vi.fn().mockResolvedValue(undefined); + const random = vi.fn().mockReturnValueOnce(0.5).mockReturnValueOnce(0.5); + + await expect( + executeAuthzedOperation("read_schema", request, { now: () => 10, random, sleep }) + ).resolves.toBe("success"); + + expect(request).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenNthCalledWith(1, 100); + expect(sleep).toHaveBeenNthCalledWith(2, 200); + expect(loggerMocks.debug).toHaveBeenCalledTimes(2); + expect(loggerMocks.warn).not.toHaveBeenCalled(); + }); + + test("uses the scheduled delay before succeeding on the second attempt", async () => { + vi.useFakeTimers(); + const request = vi.fn().mockRejectedValueOnce({ code: status.ABORTED }).mockResolvedValue("success"); + + try { + const resultPromise = executeAuthzedOperation("read_schema", request, { random: () => 0.5 }); + await vi.advanceTimersByTimeAsync(99); + expect(request).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toBe("success"); + expect(request).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + test("stops after three attempts and exposes the final stable classification", async () => { + const request = vi.fn().mockRejectedValue({ code: status.DEADLINE_EXCEEDED }); + const sleep = vi.fn().mockResolvedValue(undefined); + + const result = await executeAuthzedOperation("read_schema", request, { + now: () => 10, + random: () => 0.5, + sleep, + }).catch((error: unknown) => error); + + expect(result).toBeInstanceOf(AuthzedError); + expect(result).toMatchObject({ + attempts: 3, + code: AUTHZED_ERROR_CODES.TIMEOUT, + grpcStatus: status.DEADLINE_EXCEEDED, + retryable: true, + }); + expect(request).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + expect(loggerMocks.warn).toHaveBeenCalledTimes(1); + }); + + test("does not retry permanent errors", async () => { + const request = vi.fn().mockRejectedValue({ code: status.UNAUTHENTICATED }); + const sleep = vi.fn(); + + const result = await executeAuthzedOperation("read_schema", request, { sleep }).catch( + (error: unknown) => error + ); + + expect(result).toMatchObject({ + attempts: 1, + code: AUTHZED_ERROR_CODES.UNAUTHENTICATED, + retryable: false, + }); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); + + test("logs only sanitized retry metadata", async () => { + const token = "never-log-this-authzed-token"; + const request = vi.fn().mockRejectedValue({ + code: status.UNAUTHENTICATED, + details: `Bearer ${token}`, + metadata: { authorization: token }, + }); + + await executeAuthzedOperation("read_schema", request).catch(() => undefined); + + const serializedLogs = JSON.stringify([...loggerMocks.debug.mock.calls, ...loggerMocks.warn.mock.calls]); + expect(serializedLogs).toContain(AUTHZED_ERROR_CODES.UNAUTHENTICATED); + expect(serializedLogs).not.toContain(token); + expect(serializedLogs).not.toContain("metadata"); + expect(serializedLogs).not.toContain("Bearer"); + }); +}); diff --git a/apps/web/lib/authzed/retry.ts b/apps/web/lib/authzed/retry.ts new file mode 100644 index 000000000000..9e0f5d51d3e6 --- /dev/null +++ b/apps/web/lib/authzed/retry.ts @@ -0,0 +1,96 @@ +import "server-only"; +import { randomInt } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { logger } from "@formbricks/logger"; +import { AUTHZED_MAX_ATTEMPTS, AUTHZED_RETRY_BASE_DELAYS_MS, AUTHZED_RETRY_JITTER_RATIO } from "./constants"; +import { mapAuthzedError } from "./errors"; +import { recordAuthzedRequestFailure, recordAuthzedRequestRetry } from "./metrics"; + +type TAuthzedRetryDependencies = Readonly<{ + now: () => number; + random: () => number; + sleep: (delayMs: number) => Promise; +}>; + +const AUTHZED_RETRY_RANDOM_SCALE = 1_000_000; + +const defaultDependencies: TAuthzedRetryDependencies = { + now: () => performance.now(), + random: () => randomInt(AUTHZED_RETRY_RANDOM_SCALE + 1) / AUTHZED_RETRY_RANDOM_SCALE, + sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), +}; + +export const calculateAuthzedRetryDelayMs = (retryIndex: number, randomValue: number): number => { + const baseDelayMs = AUTHZED_RETRY_BASE_DELAYS_MS[retryIndex]; + + if (baseDelayMs === undefined) { + throw new RangeError(`Unsupported AuthZed retry index: ${retryIndex}`); + } + + const boundedRandomValue = Math.min(1, Math.max(0, randomValue)); + const jitterMultiplier = + 1 - AUTHZED_RETRY_JITTER_RATIO + boundedRandomValue * AUTHZED_RETRY_JITTER_RATIO * 2; + + return Math.round(baseDelayMs * jitterMultiplier); +}; + +export const executeAuthzedOperation = async ( + operation: string, + request: () => Promise, + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const startedAt = dependencies.now(); + + for (let attempt = 1; attempt <= AUTHZED_MAX_ATTEMPTS; attempt += 1) { + try { + return await request(); + } catch (error) { + const authzedError = mapAuthzedError(error, operation, attempt); + const durationMs = Math.max(0, Math.round(dependencies.now() - startedAt)); + const shouldRetry = authzedError.retryable && attempt < AUTHZED_MAX_ATTEMPTS; + + if (!shouldRetry) { + logger.warn( + { + attemptCount: attempt, + component: "authzed", + durationMs, + errorCode: authzedError.code, + grpcStatus: authzedError.grpcStatus, + operation, + retryable: authzedError.retryable, + }, + "AuthZed request failed" + ); + recordAuthzedRequestFailure({ + code: authzedError.code, + operation, + retryable: authzedError.retryable, + }); + throw authzedError; + } + + const retryDelayMs = calculateAuthzedRetryDelayMs(attempt - 1, dependencies.random()); + logger.debug( + { + attemptCount: attempt, + component: "authzed", + durationMs, + errorCode: authzedError.code, + grpcStatus: authzedError.grpcStatus, + operation, + retryable: authzedError.retryable, + retryDelayMs, + }, + "AuthZed request retry scheduled" + ); + // Retries that still succeed never reach the failure counter, so without this a degraded SpiceDB + // is invisible until it starts dropping writes outright. + recordAuthzedRequestRetry({ code: authzedError.code, operation }); + await dependencies.sleep(retryDelayMs); + } + } + + throw new Error("AuthZed retry loop exited unexpectedly"); +}; diff --git a/apps/web/lib/authzed/scheduled-reconciliation.test.ts b/apps/web/lib/authzed/scheduled-reconciliation.test.ts new file mode 100644 index 000000000000..bf124b53f650 --- /dev/null +++ b/apps/web/lib/authzed/scheduled-reconciliation.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { runAuthzedBackfill } from "./backfill"; +import { isAuthzedEnabled } from "./config"; +import { recordAuthzedReconciliationAudit, recordAuthzedReconciliationRepair } from "./metrics"; +import { pruneAuthzedOutboxHistory, replayAuthzedOutboxDeadLetters } from "./outbox-repository"; +import { processAuthzedScheduledReconciliationJob } from "./scheduled-reconciliation"; + +vi.mock("@formbricks/logger", () => ({ logger: { warn: vi.fn() } })); +vi.mock("./backfill", () => ({ runAuthzedBackfill: vi.fn() })); +vi.mock("./backfill-apply", () => ({ + createAuthzedBackfillApply: vi.fn(() => ({ mode: "apply" })), + createAuthzedBackfillNoopApply: vi.fn(() => ({ mode: "dry_run" })), +})); +vi.mock("./client", () => ({ getAuthzedClient: vi.fn(() => ({ client: true })) })); +vi.mock("./config", () => ({ isAuthzedEnabled: vi.fn() })); +vi.mock("./metrics", () => ({ + recordAuthzedReconciliationAudit: vi.fn(), + recordAuthzedReconciliationRepair: vi.fn(), +})); +vi.mock("./outbox-repository", () => ({ + pruneAuthzedOutboxHistory: vi.fn(), + replayAuthzedOutboxDeadLetters: vi.fn(), +})); + +const result = ( + status: "drifted" | "failed" | "reconciled", + missing = 0, + mismatchedPermissions = 0, + reconciled = 0 +) => + ({ + counters: { failed: status === "failed" ? 1 : 0, mismatchedPermissions, missing, reconciled }, + status, + }) as Awaited>; + +describe("scheduled AuthZed reconciliation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + }); + + test("does no database or AuthZed work when disabled", async () => { + vi.mocked(isAuthzedEnabled).mockReturnValue(false); + + await processAuthzedScheduledReconciliationJob(); + + expect(runAuthzedBackfill).not.toHaveBeenCalled(); + }); + + test("stops after one clean dry-run audit", async () => { + vi.mocked(runAuthzedBackfill).mockResolvedValue(result("reconciled")); + + await processAuthzedScheduledReconciliationJob(); + + expect(runAuthzedBackfill).toHaveBeenCalledOnce(); + expect(runAuthzedBackfill).toHaveBeenCalledWith( + expect.objectContaining({ mode: "dry_run", prune: false, scope: { kind: "all" } }), + expect.any(Object) + ); + expect(recordAuthzedReconciliationAudit).toHaveBeenCalledWith({ + drift: 0, + failures: 0, + status: "reconciled", + }); + expect(pruneAuthzedOutboxHistory).toHaveBeenCalledOnce(); + }); + + // A dead-lettered revocation denies every enforced authorization check with no age bound, so the + // audit is the only thing standing between one poison event and an indefinite outage. + test("returns dead letters to the delivery loop once an audit comes back clean", async () => { + vi.mocked(runAuthzedBackfill).mockResolvedValue(result("reconciled")); + + await processAuthzedScheduledReconciliationJob(); + + expect(replayAuthzedOutboxDeadLetters).toHaveBeenCalledOnce(); + }); + + test("leaves dead letters alone while PostgreSQL and SpiceDB still disagree", async () => { + for (const status of ["drifted", "failed"] as const) { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(runAuthzedBackfill).mockResolvedValue(result(status)); + + await processAuthzedScheduledReconciliationJob(); + + expect(replayAuthzedOutboxDeadLetters).not.toHaveBeenCalled(); + expect(pruneAuthzedOutboxHistory).toHaveBeenCalledOnce(); + } + }); + + test("repairs attributable drift and verifies it with a second dry run", async () => { + vi.mocked(runAuthzedBackfill) + .mockResolvedValueOnce(result("drifted", 2, 1)) + .mockResolvedValueOnce(result("drifted", 0, 0, 3)) + .mockResolvedValueOnce(result("reconciled")); + + await processAuthzedScheduledReconciliationJob(); + + expect(vi.mocked(runAuthzedBackfill).mock.calls.map(([request]) => request.mode)).toEqual([ + "dry_run", + "apply", + "dry_run", + ]); + expect(recordAuthzedReconciliationAudit).toHaveBeenCalledWith({ + drift: 3, + failures: 0, + status: "reconciled", + }); + expect(recordAuthzedReconciliationRepair).toHaveBeenCalledWith({ failed: 0, repaired: 3 }); + }); +}); diff --git a/apps/web/lib/authzed/scheduled-reconciliation.ts b/apps/web/lib/authzed/scheduled-reconciliation.ts new file mode 100644 index 000000000000..dd37b75f0a4d --- /dev/null +++ b/apps/web/lib/authzed/scheduled-reconciliation.ts @@ -0,0 +1,72 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { runAuthzedBackfill } from "./backfill"; +import { createAuthzedBackfillApply, createAuthzedBackfillNoopApply } from "./backfill-apply"; +import { getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN } from "./constants"; +import { recordAuthzedReconciliationAudit, recordAuthzedReconciliationRepair } from "./metrics"; +import { pruneAuthzedOutboxHistory, replayAuthzedOutboxDeadLetters } from "./outbox-repository"; + +/** Six-hour full audit. It repairs attributable missing/mismatched edges and never prunes unknown data. */ +export const processAuthzedScheduledReconciliationJob = async (): Promise => { + if (!isAuthzedEnabled()) return; + const client = getAuthzedClient(); + const request = { + maxPrune: AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN, + prune: false, + scope: { kind: "all" }, + } as const; + const observed = await runAuthzedBackfill( + { ...request, mode: "dry_run" }, + { apply: createAuthzedBackfillNoopApply(), client } + ); + let result = observed; + + if (observed.status === "drifted") { + const applied = await runAuthzedBackfill( + { ...request, mode: "apply" }, + { apply: createAuthzedBackfillApply(), client } + ); + recordAuthzedReconciliationRepair({ + failed: applied.counters.failed, + repaired: applied.counters.reconciled, + }); + result = await runAuthzedBackfill( + { ...request, mode: "dry_run" }, + { apply: createAuthzedBackfillNoopApply(), client } + ); + } + + recordAuthzedReconciliationAudit({ + drift: observed.counters.missing + observed.counters.mismatchedPermissions, + failures: result.counters.failed, + status: result.status, + }); + + if (result.status !== "reconciled") { + logger.warn( + { + component: "authzed", + drift: result.counters.missing + result.counters.mismatchedPermissions, + failures: result.counters.failed, + operation: "scheduled_reconciliation", + status: result.status, + }, + "Scheduled AuthZed relationship reconciliation did not finish cleanly" + ); + } + + // Runs whatever the audit concluded: it only deletes rows delivered more than a week ago, so it is + // never the thing standing between an operator and evidence. + await pruneAuthzedOutboxHistory(); + + // A clean full audit means PostgreSQL and SpiceDB already agree everywhere, so whatever a dead + // letter was trying to say has since been said by other means. Hand it back to the delivery loop + // rather than leaving the freshness guard denying every authorization check until someone runs + // `outbox replay` by hand — a dead-lettered revocation has no age bound in that guard on purpose. + // A still-poisoned event simply re-dead-letters, so this is a six-hourly retry, not a loop. The + // audit sweeps organizations, so an event for a deleted user or a cross-tenant pair may not be + // covered by `reconciled`; replaying it anyway is idempotent and strictly better than denying. + if (result.status === "reconciled") await replayAuthzedOutboxDeadLetters(); +}; diff --git a/apps/web/lib/authzed/schema-cli-command.test.ts b/apps/web/lib/authzed/schema-cli-command.test.ts new file mode 100644 index 000000000000..824576f34e07 --- /dev/null +++ b/apps/web/lib/authzed/schema-cli-command.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { parseAuthzedSchemaCliCommand } from "./schema-cli-command"; + +describe("parseAuthzedSchemaCliCommand", () => { + test("parses check and unguarded apply commands", () => { + expect(parseAuthzedSchemaCliCommand(["check"])).toEqual({ action: "check" }); + expect(parseAuthzedSchemaCliCommand(["apply"])).toEqual({ action: "apply" }); + }); + + test("parses a guarded apply command", () => { + const digest = `sha256:${"a".repeat(64)}`; + + expect(parseAuthzedSchemaCliCommand(["apply", "--expected-current-digest", digest])).toEqual({ + action: "apply", + expectedCurrentDigest: digest, + }); + }); + + test.each( + [ + [], + ["check", "extra"], + ["apply", "--unknown"], + ["apply", "--expected-current-digest"], + ["apply", "--expected-current-digest", "sha256:abcd"], + ["apply", "--expected-current-digest", `sha256:${"A".repeat(64)}`], + ].map((args) => [args] as const) + )("rejects invalid arguments: %j", (args) => { + expect(parseAuthzedSchemaCliCommand(args)).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/authzed/schema-cli-command.ts b/apps/web/lib/authzed/schema-cli-command.ts new file mode 100644 index 000000000000..41a8ea1e4b7f --- /dev/null +++ b/apps/web/lib/authzed/schema-cli-command.ts @@ -0,0 +1,32 @@ +import "server-only"; + +export type TAuthzedSchemaCliCommand = + | Readonly<{ action: "check" }> + | Readonly<{ action: "apply"; expectedCurrentDigest?: string }>; + +/** Parse schema CLI arguments without loading the AuthZed client or environment configuration. */ +export const parseAuthzedSchemaCliCommand = ( + args: ReadonlyArray +): TAuthzedSchemaCliCommand | undefined => { + if (args.length === 1 && args[0] === "check") { + return { action: "check" }; + } + + if (args[0] !== "apply") { + return undefined; + } + + if (args.length === 1) { + return { action: "apply" }; + } + + if ( + args.length === 3 && + args[1] === "--expected-current-digest" && + /^sha256:[a-f0-9]{64}$/.test(args[2] ?? "") + ) { + return { action: "apply", expectedCurrentDigest: args[2] }; + } + + return undefined; +}; diff --git a/apps/web/lib/authzed/schema-cli.test.ts b/apps/web/lib/authzed/schema-cli.test.ts new file mode 100644 index 000000000000..cea317ae1a0b --- /dev/null +++ b/apps/web/lib/authzed/schema-cli.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test, vi } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { runAuthzedSchemaCli } from "./schema-cli"; + +vi.mock("./client", () => ({ closeAuthzedClient: vi.fn() })); +vi.mock("./schema", () => ({ + applyCanonicalAuthzedSchema: vi.fn(), + checkCanonicalAuthzedSchema: vi.fn(), +})); + +const matchedResult = { + differenceCount: 0, + differenceKinds: {}, + remoteDigest: "sha256:remote", + remoteState: "present", + sourceDigest: "sha256:source", + status: "matched", +} as const; + +const driftedResult = { + differenceCount: 1, + differenceKinds: { definition_added: 1 }, + remoteDigest: "sha256:remote", + remoteState: "present", + sourceDigest: "sha256:source", + status: "drifted", +} as const; + +describe("runAuthzedSchemaCli", () => { + test.each([ + [matchedResult, 0], + [driftedResult, 2], + ] as const)("serializes a check result and returns exit code %i", async (result, exitCode) => { + const closeClient = vi.fn(); + const writeOutput = vi.fn(); + + await expect( + runAuthzedSchemaCli( + { action: "check" }, + { + checkSchema: vi.fn().mockResolvedValue(result), + closeClient, + writeOutput, + } + ) + ).resolves.toBe(exitCode); + + expect(writeOutput).toHaveBeenCalledOnce(); + expect(writeOutput).toHaveBeenCalledWith(`${JSON.stringify(result)}\n`); + expect(closeClient).toHaveBeenCalledOnce(); + }); + + test.each(["applied", "unchanged"] as const)("returns success for an %s apply", async (status) => { + const result = { + differenceCount: 0, + remoteDigest: "sha256:remote", + remoteState: "present", + sourceDigest: "sha256:source", + status, + } as const; + const applySchema = vi.fn().mockResolvedValue(result); + const closeClient = vi.fn(); + const writeOutput = vi.fn(); + + await expect( + runAuthzedSchemaCli( + { action: "apply", expectedCurrentDigest: "sha256:previous" }, + { applySchema, closeClient, writeOutput } + ) + ).resolves.toBe(0); + + expect(applySchema).toHaveBeenCalledWith("sha256:previous"); + expect(writeOutput).toHaveBeenCalledWith(`${JSON.stringify(result)}\n`); + expect(closeClient).toHaveBeenCalledOnce(); + }); + + test("prints only the stable error contract and closes the client on failure", async () => { + const secret = "never-log-this-authzed-token"; + const closeClient = vi.fn(); + const writeOutput = vi.fn(); + + await expect( + runAuthzedSchemaCli( + { action: "apply" }, + { + applySchema: vi.fn().mockRejectedValue( + new AuthzedError({ + attempts: 1, + cause: new Error(secret), + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "write_schema", + retryable: true, + }) + ), + closeClient, + writeOutput, + } + ) + ).resolves.toBe(1); + + expect(writeOutput).toHaveBeenCalledWith( + `${JSON.stringify({ + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + })}\n` + ); + expect(JSON.stringify(writeOutput.mock.calls)).not.toContain(secret); + expect(closeClient).toHaveBeenCalledOnce(); + }); + + test("sanitizes unexpected errors", async () => { + const secret = "never-log-this-schema"; + const writeOutput = vi.fn(); + + await expect( + runAuthzedSchemaCli( + { action: "check" }, + { + checkSchema: vi.fn().mockRejectedValue(new Error(secret)), + closeClient: vi.fn(), + writeOutput, + } + ) + ).resolves.toBe(1); + + expect(writeOutput).toHaveBeenCalledWith( + `${JSON.stringify({ + code: AUTHZED_ERROR_CODES.INTERNAL, + retryable: false, + status: "failed", + })}\n` + ); + expect(JSON.stringify(writeOutput.mock.calls)).not.toContain(secret); + }); + + test("preserves the schema result when closing the client fails", async () => { + const writeOutput = vi.fn(); + + await expect( + runAuthzedSchemaCli( + { action: "check" }, + { + checkSchema: vi.fn().mockResolvedValue(driftedResult), + closeClient: vi.fn(() => { + throw new Error("close failed"); + }), + writeOutput, + } + ) + ).resolves.toBe(2); + + expect(writeOutput).toHaveBeenCalledWith(`${JSON.stringify(driftedResult)}\n`); + }); +}); diff --git a/apps/web/lib/authzed/schema-cli.ts b/apps/web/lib/authzed/schema-cli.ts new file mode 100644 index 000000000000..1ba06de4e21f --- /dev/null +++ b/apps/web/lib/authzed/schema-cli.ts @@ -0,0 +1,73 @@ +import "server-only"; +import { closeAuthzedClient } from "./client"; +import { AuthzedError, type TAuthzedErrorCode, mapAuthzedError } from "./errors"; +import { + type TAuthzedSchemaApplyResult, + type TAuthzedSchemaCheckResult, + applyCanonicalAuthzedSchema, + checkCanonicalAuthzedSchema, +} from "./schema"; +import type { TAuthzedSchemaCliCommand } from "./schema-cli-command"; + +export type { TAuthzedSchemaCliCommand } from "./schema-cli-command"; + +type TAuthzedSchemaCliFailure = Readonly<{ + code: TAuthzedErrorCode; + retryable: boolean; + status: "failed"; +}>; + +type TAuthzedSchemaCliDependencies = Readonly<{ + applySchema: (expectedCurrentDigest?: string) => Promise; + checkSchema: () => Promise; + closeClient: () => void; + writeOutput: (output: string) => void; +}>; + +const defaultDependencies: TAuthzedSchemaCliDependencies = { + applySchema: applyCanonicalAuthzedSchema, + checkSchema: checkCanonicalAuthzedSchema, + closeClient: closeAuthzedClient, + writeOutput: (output) => process.stdout.write(output), +}; + +const toFailureResult = (error: unknown): TAuthzedSchemaCliFailure => { + const authzedError = error instanceof AuthzedError ? error : mapAuthzedError(error, "schema_cli", 1); + + return { + code: authzedError.code, + retryable: authzedError.retryable, + status: "failed", + }; +}; + +export const runAuthzedSchemaCli = async ( + command: TAuthzedSchemaCliCommand, + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + let result: TAuthzedSchemaApplyResult | TAuthzedSchemaCheckResult | TAuthzedSchemaCliFailure; + let exitCode: number; + + try { + if (command.action === "apply") { + result = await dependencies.applySchema(command.expectedCurrentDigest); + exitCode = 0; + } else { + result = await dependencies.checkSchema(); + exitCode = result.status === "matched" ? 0 : 2; + } + } catch (error) { + result = toFailureResult(error); + exitCode = 1; + } finally { + try { + dependencies.closeClient(); + } catch { + // Cleanup failures must not replace the schema operation's result or exit code. + } + } + + dependencies.writeOutput(`${JSON.stringify(result)}\n`); + return exitCode; +}; diff --git a/apps/web/lib/authzed/schema-source.test.ts b/apps/web/lib/authzed/schema-source.test.ts new file mode 100644 index 000000000000..54d79593764f --- /dev/null +++ b/apps/web/lib/authzed/schema-source.test.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { configureCanonicalAuthzedSchemaUrl, readCanonicalAuthzedSchema } from "./schema-source"; + +vi.mock("node:fs/promises", () => ({ readFile: vi.fn() })); + +describe("canonical AuthZed schema source", () => { + afterEach(() => { + delete (globalThis as typeof globalThis & { authzedCanonicalSchemaUrl?: URL }).authzedCanonicalSchemaUrl; + }); + + test("can point a packaged CLI at its release-matched schema asset", async () => { + vi.mocked(readFile).mockResolvedValue("definition user {}" as never); + + configureCanonicalAuthzedSchemaUrl("file:///home/nextjs/authzed-cli/index.mjs", "./schema.zed"); + + await expect(readCanonicalAuthzedSchema()).resolves.toBe("definition user {}"); + expect(readFile).toHaveBeenCalledWith(new URL("file:///home/nextjs/authzed-cli/schema.zed"), "utf8"); + }); +}); diff --git a/apps/web/lib/authzed/schema-source.ts b/apps/web/lib/authzed/schema-source.ts new file mode 100644 index 000000000000..3b345e1cb015 --- /dev/null +++ b/apps/web/lib/authzed/schema-source.ts @@ -0,0 +1,16 @@ +import "server-only"; +import { readFile } from "node:fs/promises"; + +const defaultCanonicalSchemaUrl = new URL("../../../../authzed/schema.zed", import.meta.url); + +const globalForAuthzedSchema = globalThis as typeof globalThis & { + authzedCanonicalSchemaUrl?: URL; +}; + +/** Configure the release-bundled schema without exposing schema contents through the CLI entry point. */ +export const configureCanonicalAuthzedSchemaUrl = (moduleUrl: string, relativePath: string): void => { + globalForAuthzedSchema.authzedCanonicalSchemaUrl = new URL(relativePath, moduleUrl); +}; + +export const readCanonicalAuthzedSchema = async (): Promise => + readFile(globalForAuthzedSchema.authzedCanonicalSchemaUrl ?? defaultCanonicalSchemaUrl, "utf8"); diff --git a/apps/web/lib/authzed/schema.test.ts b/apps/web/lib/authzed/schema.test.ts new file mode 100644 index 000000000000..079acaa3f452 --- /dev/null +++ b/apps/web/lib/authzed/schema.test.ts @@ -0,0 +1,186 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { applyCanonicalAuthzedSchema, checkCanonicalAuthzedSchema } from "./schema"; + +vi.mock("./client", () => ({ getAuthzedClient: vi.fn() })); + +const canonicalSchema = "definition user {}\n"; +const remoteSchema = "definition document {}\n"; +const digest = (schemaText: string): string => + `sha256:${createHash("sha256").update(schemaText).digest("hex")}`; + +const createDependencies = () => ({ + diffSchema: vi.fn(), + readCanonicalSchema: vi.fn().mockResolvedValue(canonicalSchema), + readSchema: vi.fn(), + writeSchema: vi.fn(), +}); + +describe("canonical AuthZed schema lifecycle", () => { + let dependencies: ReturnType; + + beforeEach(() => { + dependencies = createDependencies(); + }); + + const overrides = () => ({ + getClient: () => ({ + diffSchema: dependencies.diffSchema, + readSchema: dependencies.readSchema, + writeSchema: dependencies.writeSchema, + }), + readCanonicalSchema: dependencies.readCanonicalSchema, + }); + + test("reports an empty SpiceDB installation as drift without diffing", async () => { + dependencies.readSchema.mockResolvedValue({ schemaText: "" }); + + await expect(checkCanonicalAuthzedSchema(overrides())).resolves.toEqual({ + differenceCount: 1, + differenceKinds: { schema_missing: 1 }, + remoteDigest: null, + remoteState: "empty", + sourceDigest: digest(canonicalSchema), + status: "drifted", + }); + expect(dependencies.diffSchema).not.toHaveBeenCalled(); + }); + + test("uses semantic differences instead of raw text equality", async () => { + dependencies.readSchema.mockResolvedValue({ schemaText: `${canonicalSchema}\n` }); + dependencies.diffSchema.mockResolvedValue({ differenceCount: 0, differenceKinds: {} }); + + await expect(checkCanonicalAuthzedSchema(overrides())).resolves.toEqual({ + differenceCount: 0, + differenceKinds: {}, + remoteDigest: digest(`${canonicalSchema}\n`), + remoteState: "present", + sourceDigest: digest(canonicalSchema), + status: "matched", + }); + expect(dependencies.diffSchema).toHaveBeenCalledWith(canonicalSchema); + }); + + test("reports only aggregate semantic drift and content digests", async () => { + dependencies.readSchema.mockResolvedValue({ schemaText: remoteSchema }); + dependencies.diffSchema.mockResolvedValue({ + differenceCount: 2, + differenceKinds: { definition_added: 1, definition_removed: 1 }, + }); + + await expect(checkCanonicalAuthzedSchema(overrides())).resolves.toEqual({ + differenceCount: 2, + differenceKinds: { definition_added: 1, definition_removed: 1 }, + remoteDigest: digest(remoteSchema), + remoteState: "present", + sourceDigest: digest(canonicalSchema), + status: "drifted", + }); + }); + + test("rejects an empty or unreadable canonical source with a stable sanitized error", async () => { + const secretPath = "/private/never-log-this-token"; + dependencies.readCanonicalSchema.mockRejectedValue(new Error(secretPath)); + + const result = await checkCanonicalAuthzedSchema(overrides()).catch((error: unknown) => error); + + expect(result).toBeInstanceOf(AuthzedError); + expect(result).toMatchObject({ + code: AUTHZED_ERROR_CODES.INTERNAL, + message: AUTHZED_ERROR_CODES.INTERNAL, + operation: "load_canonical_schema", + retryable: false, + }); + expect((result as Error).message).not.toContain(secretPath); + + dependencies.readCanonicalSchema.mockResolvedValue(" \n"); + await expect(checkCanonicalAuthzedSchema(overrides())).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.INTERNAL, + }); + }); + + test("applies to an empty installation and verifies semantic convergence", async () => { + dependencies.readSchema + .mockResolvedValueOnce({ schemaText: "" }) + .mockResolvedValueOnce({ schemaText: canonicalSchema }); + dependencies.diffSchema.mockResolvedValue({ differenceCount: 0, differenceKinds: {} }); + + await expect(applyCanonicalAuthzedSchema(undefined, overrides())).resolves.toEqual({ + differenceCount: 0, + remoteDigest: digest(canonicalSchema), + remoteState: "present", + sourceDigest: digest(canonicalSchema), + status: "applied", + }); + expect(dependencies.writeSchema).toHaveBeenCalledOnce(); + expect(dependencies.writeSchema).toHaveBeenCalledWith(canonicalSchema); + }); + + test("returns unchanged without writing when the remote schema already matches", async () => { + dependencies.readSchema.mockResolvedValue({ schemaText: canonicalSchema }); + dependencies.diffSchema.mockResolvedValue({ differenceCount: 0, differenceKinds: {} }); + + await expect(applyCanonicalAuthzedSchema(undefined, overrides())).resolves.toEqual({ + differenceCount: 0, + remoteDigest: digest(canonicalSchema), + remoteState: "present", + sourceDigest: digest(canonicalSchema), + status: "unchanged", + }); + expect(dependencies.writeSchema).not.toHaveBeenCalled(); + }); + + test("requires the reviewed current digest before replacing non-empty drift", async () => { + dependencies.readSchema.mockResolvedValue({ schemaText: remoteSchema }); + dependencies.diffSchema.mockResolvedValue({ + differenceCount: 1, + differenceKinds: { definition_removed: 1 }, + }); + + await expect(applyCanonicalAuthzedSchema(undefined, overrides())).rejects.toMatchObject({ + attempts: 0, + code: AUTHZED_ERROR_CODES.SCHEMA_CHANGED, + operation: "write_schema_precondition", + retryable: false, + }); + await expect(applyCanonicalAuthzedSchema("sha256:incorrect", overrides())).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.SCHEMA_CHANGED, + }); + expect(dependencies.writeSchema).not.toHaveBeenCalled(); + }); + + test("replaces reviewed drift and verifies the resulting schema", async () => { + dependencies.readSchema + .mockResolvedValueOnce({ schemaText: remoteSchema }) + .mockResolvedValueOnce({ schemaText: canonicalSchema }); + dependencies.diffSchema + .mockResolvedValueOnce({ + differenceCount: 1, + differenceKinds: { definition_removed: 1 }, + }) + .mockResolvedValueOnce({ differenceCount: 0, differenceKinds: {} }); + + await expect(applyCanonicalAuthzedSchema(digest(remoteSchema), overrides())).resolves.toMatchObject({ + differenceCount: 0, + status: "applied", + }); + expect(dependencies.writeSchema).toHaveBeenCalledWith(canonicalSchema); + }); + + test("fails closed when read-back verification still detects drift", async () => { + dependencies.readSchema + .mockResolvedValueOnce({ schemaText: "" }) + .mockResolvedValueOnce({ schemaText: remoteSchema }); + dependencies.diffSchema.mockResolvedValue({ + differenceCount: 1, + differenceKinds: { definition_removed: 1 }, + }); + + await expect(applyCanonicalAuthzedSchema(undefined, overrides())).rejects.toMatchObject({ + code: AUTHZED_ERROR_CODES.SCHEMA_VERIFICATION_FAILED, + operation: "verify_written_schema", + retryable: false, + }); + }); +}); diff --git a/apps/web/lib/authzed/schema.ts b/apps/web/lib/authzed/schema.ts new file mode 100644 index 000000000000..3c93fbb50dba --- /dev/null +++ b/apps/web/lib/authzed/schema.ts @@ -0,0 +1,177 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { type TAuthzedClient, getAuthzedClient } from "./client"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { readCanonicalAuthzedSchema } from "./schema-source"; + +type TAuthzedSchemaDigest = `sha256:${string}`; + +type TAuthzedSchemaState = Readonly<{ + remoteDigest: TAuthzedSchemaDigest | null; + remoteState: "empty" | "present"; + sourceDigest: TAuthzedSchemaDigest; +}>; + +export type TAuthzedSchemaCheckResult = + | (TAuthzedSchemaState & + Readonly<{ + differenceCount: 0; + differenceKinds: Readonly>; + remoteDigest: TAuthzedSchemaDigest; + remoteState: "present"; + status: "matched"; + }>) + | (TAuthzedSchemaState & + Readonly<{ + differenceCount: number; + differenceKinds: Readonly>; + status: "drifted"; + }>); + +export type TAuthzedSchemaApplyResult = TAuthzedSchemaState & + Readonly<{ + differenceCount: 0; + status: "applied" | "unchanged"; + }>; + +type TAuthzedSchemaDependencies = Readonly<{ + getClient: () => Pick; + readCanonicalSchema: () => Promise; +}>; + +const defaultDependencies: TAuthzedSchemaDependencies = { + getClient: getAuthzedClient, + readCanonicalSchema: readCanonicalAuthzedSchema, +}; + +const createSchemaDigest = (schemaText: string): TAuthzedSchemaDigest => + `sha256:${createHash("sha256").update(schemaText).digest("hex")}`; + +const loadCanonicalSchema = async ( + dependencies: TAuthzedSchemaDependencies +): Promise> => { + try { + const schemaText = await dependencies.readCanonicalSchema(); + + if (schemaText.trim().length === 0) { + throw new Error("Canonical schema is empty"); + } + + return { + digest: createSchemaDigest(schemaText), + schemaText, + }; + } catch (error) { + throw new AuthzedError({ + attempts: 1, + cause: error, + code: AUTHZED_ERROR_CODES.INTERNAL, + operation: "load_canonical_schema", + retryable: false, + }); + } +}; + +const compareSchema = async ( + dependencies: TAuthzedSchemaDependencies, + canonicalSchema: Readonly<{ digest: TAuthzedSchemaDigest; schemaText: string }> +): Promise => { + const client = dependencies.getClient(); + const { schemaText: remoteSchemaText } = await client.readSchema(); + + if (remoteSchemaText.length === 0) { + return { + differenceCount: 1, + differenceKinds: { schema_missing: 1 }, + remoteDigest: null, + remoteState: "empty", + sourceDigest: canonicalSchema.digest, + status: "drifted", + }; + } + + const remoteDigest = createSchemaDigest(remoteSchemaText); + const differences = await client.diffSchema(canonicalSchema.schemaText); + + if (differences.differenceCount === 0) { + return { + differenceCount: 0, + differenceKinds: {}, + remoteDigest, + remoteState: "present", + sourceDigest: canonicalSchema.digest, + status: "matched", + }; + } + + return { + ...differences, + remoteDigest, + remoteState: "present", + sourceDigest: canonicalSchema.digest, + status: "drifted", + }; +}; + +export const checkCanonicalAuthzedSchema = async ( + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const canonicalSchema = await loadCanonicalSchema(dependencies); + + return compareSchema(dependencies, canonicalSchema); +}; + +export const applyCanonicalAuthzedSchema = async ( + expectedCurrentDigest?: string, + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + const canonicalSchema = await loadCanonicalSchema(dependencies); + const currentState = await compareSchema(dependencies, canonicalSchema); + + if (currentState.status === "matched") { + return { + differenceCount: 0, + remoteDigest: currentState.remoteDigest, + remoteState: currentState.remoteState, + sourceDigest: currentState.sourceDigest, + status: "unchanged", + }; + } + + const preconditionSatisfied = + currentState.remoteState === "empty" + ? expectedCurrentDigest === undefined + : expectedCurrentDigest === currentState.remoteDigest; + + if (!preconditionSatisfied) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.SCHEMA_CHANGED, + operation: "write_schema_precondition", + retryable: false, + }); + } + + await dependencies.getClient().writeSchema(canonicalSchema.schemaText); + + const verifiedState = await compareSchema(dependencies, canonicalSchema); + + if (verifiedState.status !== "matched") { + throw new AuthzedError({ + attempts: 1, + code: AUTHZED_ERROR_CODES.SCHEMA_VERIFICATION_FAILED, + operation: "verify_written_schema", + retryable: false, + }); + } + + return { + differenceCount: 0, + remoteDigest: verifiedState.remoteDigest, + remoteState: verifiedState.remoteState, + sourceDigest: verifiedState.sourceDigest, + status: "applied", + }; +}; diff --git a/apps/web/lib/authzed/team-workspace.test.ts b/apps/web/lib/authzed/team-workspace.test.ts new file mode 100644 index 000000000000..830a227f0263 --- /dev/null +++ b/apps/web/lib/authzed/team-workspace.test.ts @@ -0,0 +1,442 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { type TAuthzedRelationshipUpdate, getAuthzedClient } from "./client"; +import { isAuthzedEnabled } from "./config"; +import { AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError } from "./errors"; +import { deleteUserTeamRelationships, reconcileTeamWorkspaceRelationships } from "./team-workspace"; + +const clientMocks = { + deleteRelationships: vi.fn(), + writeRelationships: vi.fn(), +}; + +vi.mock("@formbricks/database", () => ({ + prisma: { + team: { findMany: vi.fn() }, + teamUser: { findMany: vi.fn() }, + workspace: { findMany: vi.fn() }, + workspaceTeam: { findMany: vi.fn() }, + }, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +vi.mock("./client", () => ({ + getAuthzedClient: vi.fn(), +})); + +vi.mock("./config", () => ({ + isAuthzedEnabled: vi.fn(), +})); + +const TEAM_ID = "team-private-id"; +const USER_ID = "user-private-id"; +const WORKSPACE_ID = "workspace-private-id"; +const ORGANIZATION_ID = "organization-private-id"; + +const setStableSnapshot = ({ + teamRole = "admin", + workspacePermission = "read", +}: Readonly<{ + teamRole?: "admin" | "contributor" | null; + workspacePermission?: "manage" | "read" | "readWrite" | null; +}> = {}): void => { + vi.mocked(prisma.team.findMany).mockResolvedValue([ + { id: TEAM_ID, organizationId: ORGANIZATION_ID }, + ] as never); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([ + { id: WORKSPACE_ID, organizationId: ORGANIZATION_ID }, + ] as never); + vi.mocked(prisma.teamUser.findMany).mockResolvedValue( + teamRole === null ? [] : ([{ role: teamRole, teamId: TEAM_ID, userId: USER_ID }] as never) + ); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue( + workspacePermission === null + ? [] + : ([{ permission: workspacePermission, teamId: TEAM_ID, workspaceId: WORKSPACE_ID }] as never) + ); +}; + +describe("team and workspace relationship projection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAuthzedEnabled).mockReturnValue(true); + vi.mocked(getAuthzedClient).mockReturnValue( + clientMocks as unknown as ReturnType + ); + clientMocks.deleteRelationships.mockResolvedValue(undefined); + clientMocks.writeRelationships.mockResolvedValue(undefined); + setStableSnapshot(); + }); + + test.each([ + ["admin", "admin"], + ["contributor", "contributor"], + ] as const)("projects a %s team membership and deletes its alternate role", async (role, relation) => { + setStableSnapshot({ teamRole: role }); + + await expect( + reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: TEAM_ID, userId: USER_ID }], + }) + ).resolves.toEqual({ passes: 1, status: "projected" }); + + const updates = clientMocks.writeRelationships.mock.calls.flatMap(([batch]) => batch); + const roleUpdates = updates.filter( + ({ relationship }) => + relationship.resource.objectType === "team" && relationship.subject.objectType === "user" + ); + expect(roleUpdates).toHaveLength(2); + expect(roleUpdates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ relation }), + }), + ]) + ); + expect(roleUpdates.filter(({ operation }) => operation === "delete")).toHaveLength(1); + }); + + test.each([ + ["read", "reader_team"], + ["readWrite", "writer_team"], + ["manage", "manager_team"], + ] as const)( + "projects a %s workspace grant through the team#member subject and deletes alternate grants", + async (permission, relation) => { + setStableSnapshot({ workspacePermission: permission }); + + await expect( + reconcileTeamWorkspaceRelationships({ + workspaceTeamGrants: [{ teamId: TEAM_ID, workspaceId: WORKSPACE_ID }], + }) + ).resolves.toEqual({ passes: 1, status: "projected" }); + + const updates = clientMocks.writeRelationships.mock.calls.flatMap(([batch]) => batch); + const grantUpdates = updates.filter( + ({ relationship }) => + relationship.resource.objectType === "workspace" && relationship.subject.objectType === "team" + ); + expect(grantUpdates).toHaveLength(3); + expect(grantUpdates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + operation: "touch", + relationship: expect.objectContaining({ + relation, + subject: expect.objectContaining({ relation: "member" }), + }), + }), + ]) + ); + expect(grantUpdates.filter(({ operation }) => operation === "delete")).toHaveLength(2); + } + ); + + test("projects team and workspace organization parents for pair-only targets", async () => { + await reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: TEAM_ID, userId: USER_ID }], + workspaceTeamGrants: [{ teamId: TEAM_ID, workspaceId: WORKSPACE_ID }], + }); + + const updates = clientMocks.writeRelationships.mock.calls.flatMap(([batch]) => batch); + expect(updates).toEqual( + expect.arrayContaining([ + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: TEAM_ID, objectType: "team" }, + subject: { objectId: ORGANIZATION_ID, objectType: "organization" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: ORGANIZATION_ID, objectType: "organization" }, + }, + }, + ]) + ); + }); + + test("removes every previous team and workspace parent before restoring the current parents", async () => { + await reconcileTeamWorkspaceRelationships({ + teamIds: [TEAM_ID], + workspaceIds: [WORKSPACE_ID], + }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + relation: "organization", + resourceId: TEAM_ID, + resourceType: "team", + }); + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + relation: "organization", + resourceId: WORKSPACE_ID, + resourceType: "workspace", + }); + expect(Math.max(...clientMocks.deleteRelationships.mock.invocationCallOrder)).toBeLessThan( + clientMocks.writeRelationships.mock.invocationCallOrder[0] + ); + }); + + test("projects multiple team grants independently without precomputing a user permission", async () => { + const secondTeamId = "second-team"; + vi.mocked(prisma.team.findMany).mockResolvedValue([ + { id: TEAM_ID, organizationId: ORGANIZATION_ID }, + { id: secondTeamId, organizationId: ORGANIZATION_ID }, + ] as never); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { permission: "read", teamId: TEAM_ID, workspaceId: WORKSPACE_ID }, + { permission: "manage", teamId: secondTeamId, workspaceId: WORKSPACE_ID }, + ] as never); + + await reconcileTeamWorkspaceRelationships({ + workspaceTeamGrants: [ + { teamId: TEAM_ID, workspaceId: WORKSPACE_ID }, + { teamId: secondTeamId, workspaceId: WORKSPACE_ID }, + ], + }); + + const touchedRelations = clientMocks.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter( + ({ operation, relationship }) => operation === "touch" && relationship.subject.objectType === "team" + ) + .map(({ relationship }) => relationship.relation); + expect(touchedRelations).toEqual(expect.arrayContaining(["reader_team", "manager_team"])); + }); + + test("deletes all role and grant alternatives when source rows are absent", async () => { + setStableSnapshot({ teamRole: null, workspacePermission: null }); + + await reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: TEAM_ID, userId: USER_ID }], + workspaceTeamGrants: [{ teamId: TEAM_ID, workspaceId: WORKSPACE_ID }], + }); + + const pairUpdates = clientMocks.writeRelationships.mock.calls + .flatMap(([batch]) => batch) + .filter(({ relationship }) => relationship.relation !== "organization"); + expect(pairUpdates).toHaveLength(5); + expect(pairUpdates.every(({ operation }) => operation === "delete")).toBe(true); + }); + + test("cleans a missing team resource and every workspace grant where it is the subject", async () => { + vi.mocked(prisma.team.findMany).mockResolvedValue([]); + + await reconcileTeamWorkspaceRelationships({ teamIds: [TEAM_ID] }); + + expect(clientMocks.deleteRelationships).toHaveBeenNthCalledWith(1, { + resourceId: TEAM_ID, + resourceType: "team", + }); + expect(clientMocks.deleteRelationships).toHaveBeenNthCalledWith(2, { + resourceType: "workspace", + subject: { objectId: TEAM_ID, objectType: "team", relation: "member" }, + }); + }); + + test("cleans every relationship on a missing workspace resource", async () => { + vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); + + await reconcileTeamWorkspaceRelationships({ workspaceIds: [WORKSPACE_ID] }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + resourceId: WORKSPACE_ID, + resourceType: "workspace", + }); + }); + + test("bounds parallel relationship deletion for large cascades", async () => { + const missingTeamIds = Array.from({ length: 12 }, (_, index) => `missing-team-${index}`); + let activeDeletes = 0; + let maxActiveDeletes = 0; + vi.mocked(prisma.team.findMany).mockResolvedValue([]); + clientMocks.deleteRelationships.mockImplementation(async () => { + activeDeletes++; + maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes); + await Promise.resolve(); + activeDeletes--; + }); + + await reconcileTeamWorkspaceRelationships({ teamIds: missingTeamIds }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledTimes(missingTeamIds.length * 2); + expect(maxActiveDeletes).toBe(AUTHZED_MAX_PARALLEL_RELATIONSHIP_DELETES); + }); + + test("deduplicates resource and pair targets", async () => { + await reconcileTeamWorkspaceRelationships({ + teamIds: [TEAM_ID, TEAM_ID], + teamMemberships: [ + { teamId: TEAM_ID, userId: USER_ID }, + { teamId: TEAM_ID, userId: USER_ID }, + ], + workspaceIds: [WORKSPACE_ID, WORKSPACE_ID], + workspaceTeamGrants: [ + { teamId: TEAM_ID, workspaceId: WORKSPACE_ID }, + { teamId: TEAM_ID, workspaceId: WORKSPACE_ID }, + ], + }); + + const updates = clientMocks.writeRelationships.mock.calls.flatMap(([batch]) => batch); + expect(updates).toHaveLength(7); + }); + + test("packs at most 1,000 updates without splitting a three-update grant", async () => { + const teamIds = Array.from({ length: 999 }, (_, index) => `team-${index}`); + const grantTeamId = teamIds[0]; + vi.mocked(prisma.team.findMany).mockResolvedValue( + teamIds.map((id) => ({ id, organizationId: ORGANIZATION_ID })) as never + ); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([ + { id: WORKSPACE_ID, organizationId: ORGANIZATION_ID }, + ] as never); + vi.mocked(prisma.teamUser.findMany).mockResolvedValue([]); + vi.mocked(prisma.workspaceTeam.findMany).mockResolvedValue([ + { permission: "read", teamId: grantTeamId, workspaceId: WORKSPACE_ID }, + ] as never); + + await reconcileTeamWorkspaceRelationships({ + teamIds, + workspaceTeamGrants: [{ teamId: grantTeamId, workspaceId: WORKSPACE_ID }], + }); + + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(2); + expect(clientMocks.writeRelationships.mock.calls[0][0]).toHaveLength(1_000); + expect(clientMocks.writeRelationships.mock.calls[1][0]).toHaveLength(3); + const finalBatch = clientMocks.writeRelationships.mock + .calls[1][0] as ReadonlyArray; + expect(finalBatch.every(({ relationship }) => relationship.subject.objectType === "team")).toBe(true); + }); + + test("reconciles a complete snapshot again when source state changes concurrently", async () => { + vi.mocked(prisma.teamUser.findMany) + .mockResolvedValueOnce([{ role: "admin", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "contributor", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "contributor", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "contributor", teamId: TEAM_ID, userId: USER_ID }] as never); + + await expect( + reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: TEAM_ID, userId: USER_ID }], + }) + ).resolves.toEqual({ passes: 2, status: "projected" }); + + expect(clientMocks.writeRelationships).toHaveBeenCalledTimes(2); + }); + + test("returns a stable failure after three changing snapshots", async () => { + vi.mocked(prisma.teamUser.findMany) + .mockResolvedValueOnce([{ role: "admin", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "contributor", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "admin", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "contributor", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "admin", teamId: TEAM_ID, userId: USER_ID }] as never) + .mockResolvedValueOnce([{ role: "contributor", teamId: TEAM_ID, userId: USER_ID }] as never); + + await expect( + reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: TEAM_ID, userId: USER_ID }], + }) + ).resolves.toEqual({ + attempts: 3, + code: "authzed_projection_unstable", + retryable: false, + status: "failed", + }); + }); + + test("returns disabled before reading PostgreSQL or constructing a client", async () => { + vi.mocked(isAuthzedEnabled).mockReturnValue(false); + + await expect(reconcileTeamWorkspaceRelationships({ teamIds: [TEAM_ID] })).resolves.toEqual({ + status: "disabled", + }); + + expect(prisma.team.findMany).not.toHaveBeenCalled(); + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("treats an empty target set as a zero-pass no-op without constructing a client", async () => { + await expect(reconcileTeamWorkspaceRelationships({})).resolves.toEqual({ + passes: 0, + status: "projected", + }); + + expect(prisma.team.findMany).not.toHaveBeenCalled(); + expect(getAuthzedClient).not.toHaveBeenCalled(); + }); + + test("contains operational failures with sanitized logs and results", async () => { + clientMocks.writeRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 3, + cause: new Error("raw-sdk-message-with-private-token"), + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "write_relationships", + retryable: true, + }) + ); + + await expect( + reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: TEAM_ID, userId: USER_ID }], + }) + ).resolves.toEqual({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }); + + const serializedLog = JSON.stringify(vi.mocked(logger.warn).mock.calls[0]); + expect(serializedLog).not.toContain(TEAM_ID); + expect(serializedLog).not.toContain(USER_ID); + expect(serializedLog).not.toContain("private-token"); + expect(serializedLog).not.toContain("raw-sdk-message"); + }); + + test("does not restore a parent or report success when exact parent cleanup fails", async () => { + clientMocks.deleteRelationships.mockRejectedValue( + new AuthzedError({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + operation: "delete_relationships", + retryable: true, + }) + ); + + await expect(reconcileTeamWorkspaceRelationships({ teamIds: [TEAM_ID] })).resolves.toEqual({ + attempts: 3, + code: AUTHZED_ERROR_CODES.UNAVAILABLE, + retryable: true, + status: "failed", + }); + expect(clientMocks.writeRelationships).not.toHaveBeenCalled(); + }); + + test("deletes only user-subject relationships on team resources", async () => { + await expect(deleteUserTeamRelationships(USER_ID)).resolves.toEqual({ + passes: 1, + status: "projected", + }); + + expect(clientMocks.deleteRelationships).toHaveBeenCalledWith({ + resourceType: "team", + subject: { objectId: USER_ID, objectType: "user" }, + }); + }); +}); diff --git a/apps/web/lib/authzed/team-workspace.ts b/apps/web/lib/authzed/team-workspace.ts new file mode 100644 index 000000000000..9a1cf583a9f3 --- /dev/null +++ b/apps/web/lib/authzed/team-workspace.ts @@ -0,0 +1,276 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import type { TeamUserRole, WorkspaceTeamPermission } from "@formbricks/database/prisma"; +import { + type TAuthzedClient, + type TAuthzedRelationshipFilter, + type TAuthzedRelationshipUpdate, + getAuthzedClient, +} from "./client"; +import { deleteOrganizationParentRelationships } from "./organization-parent"; +import { + AUTHZED_MAX_RECONCILIATION_PASSES, + AuthzedProjectionUnstableError, + type TAuthzedProjectionResult, + runBestEffortProjection, +} from "./projection"; +import { deleteRelationshipsInBoundedBatches, packRelationshipUpdateGroups } from "./relationship-batches"; +import { TEAM_RELATIONS, WORKSPACE_TEAM_RELATIONS } from "./relationship-map"; + +const TEAM_RELATION_NAMES = Object.values(TEAM_RELATIONS); +const WORKSPACE_TEAM_RELATION_NAMES = Object.values(WORKSPACE_TEAM_RELATIONS); + +export type TTeamMembershipProjectionTarget = Readonly<{ + teamId: string; + userId: string; +}>; + +export type TWorkspaceTeamProjectionTarget = Readonly<{ + teamId: string; + workspaceId: string; +}>; + +export type TTeamWorkspaceProjectionTargets = Readonly<{ + teamIds?: ReadonlyArray; + teamMemberships?: ReadonlyArray; + workspaceIds?: ReadonlyArray; + workspaceTeamGrants?: ReadonlyArray; +}>; + +type TNormalizedTargets = Readonly<{ + teamIds: ReadonlyArray; + teamMemberships: ReadonlyArray; + workspaceIds: ReadonlyArray; + workspaceTeamGrants: ReadonlyArray; +}>; + +type TTeamWorkspaceSnapshot = Readonly<{ + teamMemberships: ReadonlyArray>; + teams: ReadonlyArray>; + workspaceTeamGrants: ReadonlyArray< + Readonly<{ permission: WorkspaceTeamPermission; teamId: string; workspaceId: string }> + >; + workspaces: ReadonlyArray>; +}>; + +const pairKey = (first: string, second: string): string => `${first.length}:${first}${second}`; + +const deduplicatePairs = >, TKey extends string>( + pairs: ReadonlyArray, + firstKey: TKey, + secondKey: TKey +): ReadonlyArray => { + const uniquePairs = new Map(); + + for (const pair of pairs) { + uniquePairs.set(pairKey(pair[firstKey], pair[secondKey]), pair); + } + + return [...uniquePairs.values()].sort((left, right) => + pairKey(left[firstKey], left[secondKey]).localeCompare(pairKey(right[firstKey], right[secondKey])) + ); +}; + +const normalizeTargets = (targets: TTeamWorkspaceProjectionTargets): TNormalizedTargets => { + const teamMemberships = deduplicatePairs(targets.teamMemberships ?? [], "teamId", "userId"); + const workspaceTeamGrants = deduplicatePairs(targets.workspaceTeamGrants ?? [], "workspaceId", "teamId"); + const teamIds = new Set(targets.teamIds ?? []); + const workspaceIds = new Set(targets.workspaceIds ?? []); + + for (const membership of teamMemberships) { + teamIds.add(membership.teamId); + } + for (const grant of workspaceTeamGrants) { + teamIds.add(grant.teamId); + workspaceIds.add(grant.workspaceId); + } + + return { + teamIds: [...teamIds].sort((left, right) => left.localeCompare(right)), + teamMemberships, + workspaceIds: [...workspaceIds].sort((left, right) => left.localeCompare(right)), + workspaceTeamGrants, + }; +}; + +const isEmptyTargetSet = (targets: TNormalizedTargets): boolean => + targets.teamIds.length === 0 && + targets.workspaceIds.length === 0 && + targets.teamMemberships.length === 0 && + targets.workspaceTeamGrants.length === 0; + +const readSnapshot = async (targets: TNormalizedTargets): Promise => { + const [teams, workspaces, teamMemberships, workspaceTeamGrants] = await Promise.all([ + targets.teamIds.length === 0 + ? [] + : prisma.team.findMany({ + where: { id: { in: [...targets.teamIds] } }, + select: { id: true, organizationId: true }, + orderBy: { id: "asc" }, + }), + targets.workspaceIds.length === 0 + ? [] + : prisma.workspace.findMany({ + where: { id: { in: [...targets.workspaceIds] } }, + select: { id: true, organizationId: true }, + orderBy: { id: "asc" }, + }), + targets.teamMemberships.length === 0 + ? [] + : prisma.teamUser.findMany({ + where: { + OR: targets.teamMemberships.map(({ teamId, userId }) => ({ teamId, userId })), + }, + select: { role: true, teamId: true, userId: true }, + orderBy: [{ teamId: "asc" }, { userId: "asc" }], + }), + targets.workspaceTeamGrants.length === 0 + ? [] + : prisma.workspaceTeam.findMany({ + where: { + OR: targets.workspaceTeamGrants.map(({ teamId, workspaceId }) => ({ + teamId, + workspaceId, + })), + }, + select: { permission: true, teamId: true, workspaceId: true }, + orderBy: [{ workspaceId: "asc" }, { teamId: "asc" }], + }), + ]); + + return { teamMemberships, teams, workspaceTeamGrants, workspaces }; +}; + +const snapshotsMatch = (left: TTeamWorkspaceSnapshot, right: TTeamWorkspaceSnapshot): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const createParentUpdate = ( + resourceType: "team" | "workspace", + resourceId: string, + organizationId: string +): TAuthzedRelationshipUpdate => ({ + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: resourceId, objectType: resourceType }, + subject: { objectId: organizationId, objectType: "organization" }, + }, +}); + +const createTeamMembershipUpdates = ( + target: TTeamMembershipProjectionTarget, + role: TeamUserRole | null +): ReadonlyArray => + TEAM_RELATION_NAMES.map((relation) => ({ + operation: role !== null && relation === TEAM_RELATIONS[role] ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: target.teamId, objectType: "team" }, + subject: { objectId: target.userId, objectType: "user" }, + }, + })); + +const createWorkspaceTeamUpdates = ( + target: TWorkspaceTeamProjectionTarget, + permission: WorkspaceTeamPermission | null +): ReadonlyArray => + WORKSPACE_TEAM_RELATION_NAMES.map((relation) => ({ + operation: permission !== null && relation === WORKSPACE_TEAM_RELATIONS[permission] ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: target.workspaceId, objectType: "workspace" }, + subject: { objectId: target.teamId, objectType: "team", relation: "member" }, + }, + })); + +const writeSnapshot = async ( + client: TAuthzedClient, + targets: TNormalizedTargets, + snapshot: TTeamWorkspaceSnapshot +): Promise => { + const teamsById = new Map(snapshot.teams.map((team) => [team.id, team])); + const workspacesById = new Map(snapshot.workspaces.map((workspace) => [workspace.id, workspace])); + const membershipsByPair = new Map( + snapshot.teamMemberships.map((membership) => [pairKey(membership.teamId, membership.userId), membership]) + ); + const grantsByPair = new Map( + snapshot.workspaceTeamGrants.map((grant) => [pairKey(grant.workspaceId, grant.teamId), grant]) + ); + const updateGroups: TAuthzedRelationshipUpdate[][] = []; + + for (const team of snapshot.teams) { + updateGroups.push([createParentUpdate("team", team.id, team.organizationId)]); + } + for (const workspace of snapshot.workspaces) { + updateGroups.push([createParentUpdate("workspace", workspace.id, workspace.organizationId)]); + } + for (const target of targets.teamMemberships) { + const membership = membershipsByPair.get(pairKey(target.teamId, target.userId)); + updateGroups.push([...createTeamMembershipUpdates(target, membership?.role ?? null)]); + } + for (const target of targets.workspaceTeamGrants) { + const grant = grantsByPair.get(pairKey(target.workspaceId, target.teamId)); + updateGroups.push([...createWorkspaceTeamUpdates(target, grant?.permission ?? null)]); + } + + await deleteOrganizationParentRelationships(client, [ + ...snapshot.teams.map(({ id }) => ({ resourceId: id, resourceType: "team" })), + ...snapshot.workspaces.map(({ id }) => ({ resourceId: id, resourceType: "workspace" })), + ]); + + for (const batch of packRelationshipUpdateGroups(updateGroups)) { + await client.writeRelationships(batch); + } + + const deletionFilters: TAuthzedRelationshipFilter[] = []; + for (const teamId of targets.teamIds) { + if (!teamsById.has(teamId)) { + deletionFilters.push({ resourceId: teamId, resourceType: "team" }); + deletionFilters.push({ + resourceType: "workspace", + subject: { objectId: teamId, objectType: "team", relation: "member" }, + }); + } + } + for (const workspaceId of targets.workspaceIds) { + if (!workspacesById.has(workspaceId)) { + deletionFilters.push({ resourceId: workspaceId, resourceType: "workspace" }); + } + } + await deleteRelationshipsInBoundedBatches(client, deletionFilters); +}; + +export const reconcileTeamWorkspaceRelationships = async ( + targets: TTeamWorkspaceProjectionTargets +): Promise => + runBestEffortProjection("reconcile_team_workspace_relationships", "team_workspace", async () => { + const normalizedTargets = normalizeTargets(targets); + if (isEmptyTargetSet(normalizedTargets)) { + return 0; + } + + const client = getAuthzedClient(); + for (let pass = 1; pass <= AUTHZED_MAX_RECONCILIATION_PASSES; pass++) { + const sourceSnapshot = await readSnapshot(normalizedTargets); + await writeSnapshot(client, normalizedTargets, sourceSnapshot); + + const verifiedSnapshot = await readSnapshot(normalizedTargets); + if (snapshotsMatch(sourceSnapshot, verifiedSnapshot)) { + return pass; + } + } + + throw new AuthzedProjectionUnstableError(); + }); + +export const deleteUserTeamRelationships = async (userId: string): Promise => + runBestEffortProjection("delete_user_team_relationships", "team_workspace", async () => { + await getAuthzedClient().deleteRelationships({ + resourceType: "team", + subject: { + objectId: userId, + objectType: "user", + }, + }); + return 1; + }); diff --git a/apps/web/lib/authzed/upgrade-cli-command.test.ts b/apps/web/lib/authzed/upgrade-cli-command.test.ts new file mode 100644 index 000000000000..c9c2b722c1cc --- /dev/null +++ b/apps/web/lib/authzed/upgrade-cli-command.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { parseAuthzedUpgradeCliCommand } from "./upgrade-cli-command"; + +const DIGEST = `sha256:${"a".repeat(64)}`; + +describe("parseAuthzedUpgradeCliCommand", () => { + test.each([ + [["check"], { action: "check" }], + [["prepare"], { action: "prepare" }], + [["prepare", "--expected-current-digest", DIGEST], { action: "prepare", expectedCurrentDigest: DIGEST }], + ])("parses %j", (args, expected) => { + expect(parseAuthzedUpgradeCliCommand(args)).toEqual(expected); + }); + + test.each([ + { args: [] }, + { args: ["check", "extra"] }, + { args: ["prepare", "--expected-current-digest"] }, + { args: ["prepare", "--expected-current-digest", "sha256:not-a-digest"] }, + { args: ["prepare", "--unknown", DIGEST] }, + { args: ["apply"] }, + ])("rejects $args", ({ args }) => { + expect(parseAuthzedUpgradeCliCommand(args)).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/authzed/upgrade-cli-command.ts b/apps/web/lib/authzed/upgrade-cli-command.ts new file mode 100644 index 000000000000..f68366db50d0 --- /dev/null +++ b/apps/web/lib/authzed/upgrade-cli-command.ts @@ -0,0 +1,34 @@ +import "server-only"; + +export type TAuthzedUpgradeCliCommand = + | Readonly<{ action: "check" }> + | Readonly<{ action: "prepare"; expectedCurrentDigest?: string }>; + +const SCHEMA_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +/** Parse upgrade arguments before loading application configuration or constructing a client. */ +export const parseAuthzedUpgradeCliCommand = ( + args: ReadonlyArray +): TAuthzedUpgradeCliCommand | undefined => { + if (args.length === 1 && args[0] === "check") { + return { action: "check" }; + } + + if (args[0] !== "prepare") { + return undefined; + } + + if (args.length === 1) { + return { action: "prepare" }; + } + + if ( + args.length === 3 && + args[1] === "--expected-current-digest" && + SCHEMA_DIGEST_PATTERN.test(args[2] ?? "") + ) { + return { action: "prepare", expectedCurrentDigest: args[2] }; + } + + return undefined; +}; diff --git a/apps/web/lib/authzed/upgrade-cli.test.ts b/apps/web/lib/authzed/upgrade-cli.test.ts new file mode 100644 index 000000000000..df48f42714a1 --- /dev/null +++ b/apps/web/lib/authzed/upgrade-cli.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test, vi } from "vitest"; +import type { TAuthzedBackfillResult } from "./backfill"; +import { runAuthzedUpgradeCli } from "./upgrade-cli"; + +const health = { latencyMs: 4, status: "healthy" } as const; +const schema = { + differenceCount: 0, + differenceKinds: {}, + remoteDigest: `sha256:${"a".repeat(64)}` as const, + remoteState: "present", + sourceDigest: `sha256:${"a".repeat(64)}` as const, + status: "matched", +} as const; +const outbox = { + deadLettered: 0, + oldestPendingAgeSeconds: null, + overdueRevocations: 0, + pending: 0, + revocationsPastCritical: 0, + revocationsPastWarning: 0, +} as const; +const counters = { + failed: 0, + ignored: 0, + invalid: 0, + mismatchedParents: 0, + mismatchedPermissions: 0, + missing: 0, + orphaned: 0, + pruned: 0, + reconciled: 0, + scanned: 12, + skipped: 0, + unmanaged: 0, +} as const; + +const audit = (status: TAuthzedBackfillResult["status"] = "reconciled"): TAuthzedBackfillResult => ({ + completedAtSnapshot: null, + counters, + failures: [], + lastOrganizationId: "tenant_identifier_must_not_escape", + mismatchedParents: [], + mismatchedPermissions: [], + mode: "dry_run", + orphanScope: "all", + orphans: [], + scope: "all", + status, + truncated: false, + unmanaged: [], +}); + +const dependencies = (overrides: Record = {}) => { + const outputs: string[] = []; + + return { + outputs, + values: { + applySchema: vi.fn().mockResolvedValue({ ...schema, status: "unchanged" }), + audit: vi.fn().mockResolvedValue(audit()), + checkHealth: vi.fn().mockResolvedValue(health), + checkSchema: vi.fn().mockResolvedValue(schema), + closeClient: vi.fn(), + configureBulkClient: vi.fn(), + consistency: vi.fn().mockReturnValue("fully_consistent"), + drainOutbox: vi.fn().mockResolvedValue({ + claimed: 2, + deadLettered: 0, + delivered: 2, + failed: 0, + remaining: 0, + status: "drained", + }), + isEnabled: vi.fn().mockReturnValue(true), + outboxStatus: vi.fn().mockResolvedValue(outbox), + writeOutput: (output: string) => outputs.push(output), + ...overrides, + }, + }; +}; + +describe("runAuthzedUpgradeCli", () => { + test("reports a clean read-only preflight without tenant identifiers", async () => { + const deps = dependencies(); + + await expect(runAuthzedUpgradeCli({ action: "check" }, deps.values)).resolves.toBe(0); + + const result = JSON.parse(deps.outputs.join("")); + expect(result).toMatchObject({ + audit: { counters, failureCount: 0, status: "reconciled", truncated: false }, + datastoreMigrations: "ready", + health, + outbox, + schema, + status: "ready", + }); + expect(deps.outputs.join("")).not.toContain("tenant_identifier_must_not_escape"); + expect(deps.values.configureBulkClient).toHaveBeenCalledBefore(deps.values.checkHealth); + expect(deps.values.closeClient).toHaveBeenCalledOnce(); + }); + + test.each([ + [{ isEnabled: vi.fn().mockReturnValue(false) }, "authzed_disabled"], + [{ consistency: vi.fn().mockReturnValue("minimize_latency") }, "authzed_failed_precondition"], + ])("fails closed for unsafe configuration", async (override, code) => { + const deps = dependencies(override); + + await expect(runAuthzedUpgradeCli({ action: "check" }, deps.values)).resolves.toBe(1); + + expect(JSON.parse(deps.outputs.join(""))).toEqual({ code, retryable: false, status: "failed" }); + expect(deps.values.checkHealth).not.toHaveBeenCalled(); + }); + + test("prepares schema, drains delivery, reconciles, and verifies the final graph", async () => { + const deps = dependencies(); + const digest = `sha256:${"b".repeat(64)}`; + + await expect( + runAuthzedUpgradeCli({ action: "prepare", expectedCurrentDigest: digest }, deps.values) + ).resolves.toBe(0); + + expect(deps.values.applySchema).toHaveBeenCalledWith(digest); + expect(deps.values.drainOutbox).toHaveBeenCalledOnce(); + expect(deps.values.audit).toHaveBeenNthCalledWith(1, "apply", expect.any(Object)); + expect(deps.values.audit).toHaveBeenNthCalledWith(2, "dry_run", expect.any(Object)); + expect(JSON.parse(deps.outputs.join(""))).toMatchObject({ status: "prepared" }); + }); + + test("blocks when the final audit still finds drift", async () => { + const drifted = audit("drifted"); + const deps = dependencies({ + audit: vi.fn().mockResolvedValueOnce(audit()).mockResolvedValueOnce(drifted), + }); + + await expect(runAuthzedUpgradeCli({ action: "prepare" }, deps.values)).resolves.toBe(2); + + expect(JSON.parse(deps.outputs.join(""))).toMatchObject({ + audit: { status: "drifted" }, + status: "blocked", + }); + }); + + test("sanitizes unexpected failures", async () => { + const deps = dependencies({ checkHealth: vi.fn().mockRejectedValue(new Error("raw secret")) }); + + await expect(runAuthzedUpgradeCli({ action: "check" }, deps.values)).resolves.toBe(1); + + expect(JSON.parse(deps.outputs.join(""))).toEqual({ + code: "authzed_internal", + retryable: false, + status: "failed", + }); + expect(deps.outputs.join("")).not.toContain("raw secret"); + }); +}); diff --git a/apps/web/lib/authzed/upgrade-cli.ts b/apps/web/lib/authzed/upgrade-cli.ts new file mode 100644 index 000000000000..0cc25f8ab156 --- /dev/null +++ b/apps/web/lib/authzed/upgrade-cli.ts @@ -0,0 +1,218 @@ +import "server-only"; +import { env } from "@/lib/env"; +import { type TAuthzedBackfillApply, type TAuthzedBackfillResult, runAuthzedBackfill } from "./backfill"; +import { createAuthzedBackfillApply, createAuthzedBackfillNoopApply } from "./backfill-apply"; +import { closeAuthzedClient, configureAuthzedClientForBulkWork, getAuthzedClient } from "./client"; +import { AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN } from "./constants"; +import { AUTHZED_ERROR_CODES, AuthzedError, type TAuthzedErrorCode, mapAuthzedError } from "./errors"; +import { type TAuthzedHealthResult, checkAuthzedHealth } from "./health"; +import { drainAuthzedOutbox } from "./outbox-processor"; +import { getAuthzedOutboxStatus } from "./outbox-repository"; +import type { TAuthzedOutboxDrainResult, TAuthzedOutboxStatus } from "./outbox-types"; +import { + type TAuthzedSchemaApplyResult, + type TAuthzedSchemaCheckResult, + applyCanonicalAuthzedSchema, + checkCanonicalAuthzedSchema, +} from "./schema"; +import type { TAuthzedUpgradeCliCommand } from "./upgrade-cli-command"; + +type TAuthzedUpgradeAudit = Readonly<{ + counters: TAuthzedBackfillResult["counters"]; + failureCount: number; + status: TAuthzedBackfillResult["status"]; + truncated: boolean; +}>; + +type TAuthzedUpgradeResult = Readonly<{ + audit?: TAuthzedUpgradeAudit; + code?: TAuthzedErrorCode; + datastoreMigrations?: "ready"; + health?: TAuthzedHealthResult; + outbox?: TAuthzedOutboxStatus | TAuthzedOutboxDrainResult; + retryable?: boolean; + schema?: TAuthzedSchemaApplyResult | TAuthzedSchemaCheckResult; + status: "blocked" | "failed" | "prepared" | "ready"; +}>; + +type TAuthzedUpgradeCliDependencies = Readonly<{ + applySchema: (expectedCurrentDigest?: string) => Promise; + audit: (mode: "apply" | "dry_run", apply: TAuthzedBackfillApply) => Promise; + checkHealth: () => Promise; + checkSchema: () => Promise; + closeClient: () => void; + configureBulkClient: () => void; + consistency: () => string | undefined; + drainOutbox: () => Promise; + isEnabled: () => boolean; + outboxStatus: () => Promise; + writeOutput: (output: string) => void; +}>; + +const runFullAudit = (mode: "apply" | "dry_run", apply: TAuthzedBackfillApply) => + runAuthzedBackfill( + { + maxPrune: AUTHZED_MAX_PRUNED_RESOURCES_PER_RUN, + mode, + prune: false, + scope: { kind: "all" }, + }, + { apply, client: getAuthzedClient() } + ); + +const defaultDependencies: TAuthzedUpgradeCliDependencies = { + applySchema: applyCanonicalAuthzedSchema, + audit: runFullAudit, + checkHealth: checkAuthzedHealth, + checkSchema: checkCanonicalAuthzedSchema, + closeClient: closeAuthzedClient, + configureBulkClient: configureAuthzedClientForBulkWork, + consistency: () => env.AUTHZED_CONSISTENCY, + drainOutbox: drainAuthzedOutbox, + isEnabled: () => env.AUTHZED_ENABLED === "true" || env.AUTHZED_ENABLED === "1", + outboxStatus: getAuthzedOutboxStatus, + writeOutput: (output) => process.stdout.write(output), +}; + +const summarizeAudit = (result: TAuthzedBackfillResult): TAuthzedUpgradeAudit => ({ + counters: result.counters, + failureCount: result.failures.length, + status: result.status, + truncated: result.truncated, +}); + +const isOutboxClean = (status: TAuthzedOutboxStatus): boolean => + status.deadLettered === 0 && + status.overdueRevocations === 0 && + status.pending === 0 && + status.revocationsPastCritical === 0 && + status.revocationsPastWarning === 0; + +const assertUpgradeConfiguration = (dependencies: TAuthzedUpgradeCliDependencies): void => { + if (!dependencies.isEnabled()) { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.DISABLED, + operation: "upgrade_check_configuration", + retryable: false, + }); + } + + if (dependencies.consistency() !== "fully_consistent") { + throw new AuthzedError({ + attempts: 0, + code: AUTHZED_ERROR_CODES.FAILED_PRECONDITION, + operation: "upgrade_check_consistency", + retryable: false, + }); + } +}; + +const failed = (error: unknown): TAuthzedUpgradeResult => { + const mapped = error instanceof AuthzedError ? error : mapAuthzedError(error, "upgrade_cli", 1); + + return { code: mapped.code, retryable: mapped.retryable, status: "failed" }; +}; + +const runCheck = async (dependencies: TAuthzedUpgradeCliDependencies): Promise => { + const health = await dependencies.checkHealth(); + if (health.status !== "healthy") { + return { health, status: "blocked" }; + } + + const schema = await dependencies.checkSchema(); + if (schema.status !== "matched") { + return { datastoreMigrations: "ready", health, schema, status: "blocked" }; + } + + const outbox = await dependencies.outboxStatus(); + if (!isOutboxClean(outbox)) { + return { datastoreMigrations: "ready", health, outbox, schema, status: "blocked" }; + } + + const audit = summarizeAudit(await dependencies.audit("dry_run", createAuthzedBackfillNoopApply())); + return { + audit, + datastoreMigrations: "ready", + health, + outbox, + schema, + status: audit.status === "reconciled" ? "ready" : audit.status === "failed" ? "failed" : "blocked", + }; +}; + +const runPrepare = async ( + command: Extract, + dependencies: TAuthzedUpgradeCliDependencies +): Promise => { + const health = await dependencies.checkHealth(); + if (health.status !== "healthy") { + return { health, status: "blocked" }; + } + + const schema = await dependencies.applySchema(command.expectedCurrentDigest); + const drain = await dependencies.drainOutbox(); + if (drain.status !== "drained" || drain.deadLettered > 0 || drain.failed > 0) { + return { datastoreMigrations: "ready", health, outbox: drain, schema, status: "blocked" }; + } + + const reconciliation = await dependencies.audit("apply", createAuthzedBackfillApply()); + if (reconciliation.status === "failed") { + return { + audit: summarizeAudit(reconciliation), + datastoreMigrations: "ready", + health, + outbox: drain, + schema, + status: "failed", + }; + } + + const audit = summarizeAudit(await dependencies.audit("dry_run", createAuthzedBackfillNoopApply())); + const outbox = await dependencies.outboxStatus(); + return { + audit, + datastoreMigrations: "ready", + health, + outbox, + schema, + status: + audit.status === "failed" + ? "failed" + : audit.status === "reconciled" && isOutboxClean(outbox) + ? "prepared" + : "blocked", + }; +}; + +/** + * Release-matched, fail-closed v6 upgrade gate. + * + * Output intentionally contains aggregate counters only. Detailed repair output remains available from + * the explicit backfill command and is never folded into unattended upgrade logs. + */ +export const runAuthzedUpgradeCli = async ( + command: TAuthzedUpgradeCliCommand, + dependencyOverrides: Partial = {} +): Promise => { + const dependencies = { ...defaultDependencies, ...dependencyOverrides }; + let result: TAuthzedUpgradeResult; + + try { + assertUpgradeConfiguration(dependencies); + dependencies.configureBulkClient(); + result = + command.action === "prepare" ? await runPrepare(command, dependencies) : await runCheck(dependencies); + } catch (error) { + result = failed(error); + } finally { + try { + dependencies.closeClient(); + } catch { + // Cleanup failures must not replace the sanitized upgrade result. + } + } + + dependencies.writeOutput(`${JSON.stringify(result)}\n`); + return result.status === "ready" || result.status === "prepared" ? 0 : result.status === "blocked" ? 2 : 1; +}; diff --git a/apps/web/lib/env.test.ts b/apps/web/lib/env.test.ts index e007377ce88b..4a5d0d24ae36 100644 --- a/apps/web/lib/env.test.ts +++ b/apps/web/lib/env.test.ts @@ -12,6 +12,13 @@ const setTestEnv = (overrides: Record = {}) => { HUB_API_KEY: "test-hub-api-key", CUBEJS_API_URL: "https://cube.formbricks.local", CUBEJS_API_SECRET: "cube-secret", + AUTHZED_CONSISTENCY: undefined, + AUTHZED_ENABLED: undefined, + AUTHZED_ENDPOINT: undefined, + AUTHZED_INSECURE: undefined, + AUTHZED_SYSTEM_KEY: undefined, + AUTHZED_TOKEN: undefined, + MCP_OAUTH_JWKS_URL: undefined, ...overrides, }; }; @@ -88,6 +95,215 @@ describe("env", () => { expect(env.DEBUG_SHOW_RESET_LINK).toBe("1"); }); + test.each(["http://formbricks:3000/api/auth/jwks", "https://auth.example.com/internal/jwks?version=1"])( + "accepts MCP OAuth JWKS URL %s", + async (jwksUrl) => { + setTestEnv({ MCP_OAUTH_JWKS_URL: jwksUrl }); + + const { env } = await import("./env"); + + expect(env.MCP_OAUTH_JWKS_URL).toBe(jwksUrl); + } + ); + + test.each([ + "ftp://formbricks/api/auth/jwks", + "http://user:password@formbricks:3000/api/auth/jwks", + "http://formbricks:3000/api/auth/jwks#key", + ])("rejects unsafe MCP OAuth JWKS URL %s", async (jwksUrl) => { + setTestEnv({ MCP_OAUTH_JWKS_URL: jwksUrl }); + + await expect(import("./env")).rejects.toThrow("MCP_OAUTH_JWKS_URL"); + }); + + test.each(["true", "1"])("accepts enabled AuthZed boolean value %s", async (enabled) => { + setTestEnv({ + AUTHZED_CONSISTENCY: "minimize_latency", + AUTHZED_ENABLED: enabled, + AUTHZED_ENDPOINT: "localhost:50051", + AUTHZED_INSECURE: enabled, + AUTHZED_SYSTEM_KEY: "formbricks", + AUTHZED_TOKEN: "test-authzed-token", + }); + + const { env } = await import("./env"); + + expect(env.AUTHZED_ENABLED).toBe(enabled); + expect(env.AUTHZED_INSECURE).toBe(enabled); + expect(env.AUTHZED_TOKEN).toBe("test-authzed-token"); + }); + + test.each(["false", "0"])("accepts disabled AuthZed boolean value %s", async (enabled) => { + setTestEnv({ + AUTHZED_ENABLED: enabled, + AUTHZED_INSECURE: enabled, + }); + + const { env } = await import("./env"); + + expect(env.AUTHZED_ENABLED).toBe(enabled); + expect(env.AUTHZED_INSECURE).toBe(enabled); + }); + + test("allows AuthZed to be disabled without credentials", async () => { + setTestEnv(); + + const { env } = await import("./env"); + + expect(env.AUTHZED_ENABLED).toBeUndefined(); + expect(env.AUTHZED_ENDPOINT).toBeUndefined(); + expect(env.AUTHZED_TOKEN).toBeUndefined(); + expect(env.AUTHZED_SYSTEM_KEY).toBeUndefined(); + }); + + test("allows valid AuthZed credentials to be prepared while disabled", async () => { + setTestEnv({ + AUTHZED_ENABLED: "false", + AUTHZED_ENDPOINT: "spicedb:50051", + AUTHZED_SYSTEM_KEY: "formbricks", + AUTHZED_TOKEN: "prepared-token", + }); + + const { env } = await import("./env"); + + expect(env.AUTHZED_ENDPOINT).toBe("spicedb:50051"); + expect(env.AUTHZED_SYSTEM_KEY).toBe("formbricks"); + expect(env.AUTHZED_TOKEN).toBe("prepared-token"); + }); + + test.each([ + ["AUTHZED_ENDPOINT", " "], + ["AUTHZED_TOKEN", " "], + ["AUTHZED_SYSTEM_KEY", ""], + ["AUTHZED_CONSISTENCY", ""], + ])("rejects invalid supplied %s while AuthZed is disabled", async (variable, value) => { + setTestEnv({ [variable]: value }); + + await expect(import("./env")).rejects.toThrow(variable); + }); + + test.each(["AUTHZED_ENDPOINT", "AUTHZED_TOKEN", "AUTHZED_SYSTEM_KEY"])( + "requires %s when AuthZed is enabled", + async (missingVariable) => { + const authzedEnv: Record = { + AUTHZED_ENABLED: "true", + AUTHZED_ENDPOINT: "spicedb:50051", + AUTHZED_SYSTEM_KEY: "formbricks", + AUTHZED_TOKEN: "test-authzed-token", + }; + authzedEnv[missingVariable] = undefined; + setTestEnv(authzedEnv); + + await expect(import("./env")).rejects.toThrow(missingVariable); + } + ); + + test.each([ + "localhost:50051", + "spicedb:50051", + "spicedb.authzed.svc.cluster.local:50051", + "grpc.authzed.com:443", + "127.0.0.1:1", + "10.20.30.40:65535", + "example.com:80", + "[::1]:50051", + "[2001:db8::1]:443", + ])("accepts valid AuthZed endpoint %s", async (endpoint) => { + setTestEnv({ AUTHZED_ENDPOINT: endpoint }); + + const { env } = await import("./env"); + + expect(env.AUTHZED_ENDPOINT).toBe(endpoint); + }); + + test.each([ + "http://localhost:50051", + "https://grpc.authzed.com:443", + "spicedb", + "spicedb:0", + "spicedb:65536", + "spicedb:abc", + "spicedb:50051/path", + "spicedb:50051?query=true", + "spicedb:50051#fragment", + "user@spicedb:50051", + " spicedb:50051", + "spicedb:50051 ", + "::1:50051", + ])("rejects invalid AuthZed endpoint %s", async (endpoint) => { + setTestEnv({ AUTHZED_ENDPOINT: endpoint }); + + await expect(import("./env")).rejects.toThrow("AUTHZED_ENDPOINT"); + }); + + test.each(["minimize_latency", "fully_consistent"])( + "accepts AuthZed consistency value %s", + async (consistency) => { + setTestEnv({ AUTHZED_CONSISTENCY: consistency }); + + const { env } = await import("./env"); + + expect(env.AUTHZED_CONSISTENCY).toBe(consistency); + } + ); + + test("rejects an unsupported AuthZed consistency value", async () => { + setTestEnv({ AUTHZED_CONSISTENCY: "at_least_as_fresh" }); + + await expect(import("./env")).rejects.toThrow("AUTHZED_CONSISTENCY"); + }); + + test.each(["abc", "_a1", `a${"b".repeat(62)}1`])( + "accepts valid AuthZed system key %s", + async (systemKey) => { + setTestEnv({ AUTHZED_SYSTEM_KEY: systemKey }); + + const { env } = await import("./env"); + + expect(env.AUTHZED_SYSTEM_KEY).toBe(systemKey); + } + ); + + test.each([ + "ab", + `a${"b".repeat(63)}1`, + "Formbricks", + "form-bricks", + "form/bricks", + "form bricks", + "formbricks_", + "1formbricks", + " formbricks", + "formbricks ", + ])("rejects invalid AuthZed system key %s", async (systemKey) => { + setTestEnv({ AUTHZED_SYSTEM_KEY: systemKey }); + + await expect(import("./env")).rejects.toThrow("AUTHZED_SYSTEM_KEY"); + }); + + test("does not expose the AuthZed token in validation errors", async () => { + const token = "never-log-this-authzed-token"; + setTestEnv({ + AUTHZED_ENABLED: "true", + AUTHZED_ENDPOINT: "https://invalid.example.com:443", + AUTHZED_SYSTEM_KEY: "formbricks", + AUTHZED_TOKEN: token, + }); + + const error = await import("./env").catch((caughtError: unknown) => caughtError); + + expect(String(error)).toContain("AUTHZED_ENDPOINT"); + expect(String(error)).not.toContain(token); + }); + + test("rejects unsupported AuthZed boolean values", async () => { + setTestEnv({ + AUTHZED_ENABLED: "yes", + }); + + await expect(import("./env")).rejects.toThrow("AUTHZED_ENABLED"); + }); + test("allows Google Cloud AI configuration to rely on ADC credentials", async () => { setTestEnv({ AI_PROVIDER: "google", diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 9167e39959a5..f2d486e9c4b2 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -19,6 +19,19 @@ const ZOpenAICompatibleBaseUrl = z.url().refine(isHttpUrl, { message: "AI_OPENAI_COMPATIBLE_BASE_URL must be a valid http(s) URL", }); +const isValidMcpOauthJwksUrl = (value: string): boolean => { + if (!isHttpUrl(value)) { + return false; + } + + const url = new URL(value); + return url.hostname.length > 0 && url.username === "" && url.password === "" && url.hash === ""; +}; + +const ZMcpOauthJwksUrl = z.url().refine(isValidMcpOauthJwksUrl, { + message: "MCP_OAUTH_JWKS_URL must be a valid http(s) URL without credentials or a fragment", +}); + const ZAIConfigurationEnv = z.object({ AI_PROVIDER: ZActiveAIProvider.optional(), AI_MODEL: z.string().optional(), @@ -45,7 +58,9 @@ type TAIConfigurationEnv = z.infer; const isJsonObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const addEnvIssue = (ctx: z.RefinementCtx, path: keyof TAIConfigurationEnv, message: string): void => { +type TEnvironmentIssuePath = keyof TAIConfigurationEnv | keyof TAuthzedConfigurationEnv; + +const addEnvIssue = (ctx: z.RefinementCtx, path: TEnvironmentIssuePath, message: string): void => { ctx.addIssue({ code: "custom", path: [path], @@ -192,6 +207,91 @@ const ZSurveySchedulingLocalMinute = z.coerce.number().int().min(0).max(59); const emptyStringToUndefined = (value: unknown) => typeof value === "string" && value.trim() === "" ? undefined : value; const ZOptionalNonEmptyString = z.preprocess(emptyStringToUndefined, z.string().trim().min(1).optional()); +const ZAuthzedBoolean = z.enum(["true", "false", "1", "0"]); +const ZAuthzedConsistency = z.enum(["minimize_latency", "fully_consistent"]).optional(); +const ZAuthzedToken = z + .string() + .refine((value) => value.trim().length > 0, { + message: "AUTHZED_TOKEN must not be empty", + }) + .optional(); + +const isValidAuthzedEndpoint = (value: string): boolean => { + if (/\s/.test(value) || value.includes("/") || /[@?#]/.test(value)) { + return false; + } + + const portMatch = value.startsWith("[") ? /^\[[^\]]+\]:(\d+)$/.exec(value) : /^[^:]+:(\d+)$/.exec(value); + + if (!portMatch) { + return false; + } + + const port = Number(portMatch[1]); + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return false; + } + + try { + const endpoint = new URL(`http://${value}`); + + return ( + endpoint.hostname.length > 0 && + endpoint.username === "" && + endpoint.password === "" && + endpoint.pathname === "/" && + endpoint.search === "" && + endpoint.hash === "" + ); + } catch { + return false; + } +}; + +const ZAuthzedEndpoint = z + .string() + .refine(isValidAuthzedEndpoint, { + message: "AUTHZED_ENDPOINT must be a valid host:port without a URL scheme or path", + }) + .optional(); +const ZAuthzedSystemKey = z + .string() + .regex(/^(?=.{3,64}$)[a-z_](?:[a-z0-9_]*[a-z0-9])$/, { + message: "AUTHZED_SYSTEM_KEY must be a valid 3-64 character SpiceDB identifier", + }) + .optional(); + +const ZAuthzedConfigurationEnv = z.object({ + AUTHZED_CONSISTENCY: ZAuthzedConsistency, + AUTHZED_ENABLED: ZAuthzedBoolean.optional(), + AUTHZED_ENDPOINT: ZAuthzedEndpoint, + AUTHZED_INSECURE: ZAuthzedBoolean.optional(), + AUTHZED_SYSTEM_KEY: ZAuthzedSystemKey, + AUTHZED_TOKEN: ZAuthzedToken, +}); + +type TAuthzedConfigurationEnv = z.infer; + +const validateAuthzedConfiguration = (values: TAuthzedConfigurationEnv, ctx: z.RefinementCtx): void => { + const isEnabled = values.AUTHZED_ENABLED === "true" || values.AUTHZED_ENABLED === "1"; + + if (!isEnabled) { + return; + } + + if (!values.AUTHZED_ENDPOINT) { + addEnvIssue(ctx, "AUTHZED_ENDPOINT", "AUTHZED_ENDPOINT is required when AuthZed is enabled"); + } + + if (!values.AUTHZED_TOKEN) { + addEnvIssue(ctx, "AUTHZED_TOKEN", "AUTHZED_TOKEN is required when AuthZed is enabled"); + } + + if (!values.AUTHZED_SYSTEM_KEY) { + addEnvIssue(ctx, "AUTHZED_SYSTEM_KEY", "AUTHZED_SYSTEM_KEY is required when AuthZed is enabled"); + } +}; const parsedEnv = createEnv({ onValidationError: throwEnvValidationError, @@ -217,6 +317,12 @@ const parsedEnv = createEnv({ DEBUG: z.string().optional(), AUTH_DEFAULT_TEAM_ID: z.string().optional(), AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(), + AUTHZED_CONSISTENCY: ZAuthzedConsistency, + AUTHZED_ENABLED: ZAuthzedBoolean.optional(), + AUTHZED_ENDPOINT: ZAuthzedEndpoint, + AUTHZED_INSECURE: ZAuthzedBoolean.optional(), + AUTHZED_SYSTEM_KEY: ZAuthzedSystemKey, + AUTHZED_TOKEN: ZAuthzedToken, // Cloud-only: when "1", the personal-email sign-up block also applies to invited users. // Default (unset/"0") exempts invites — see isSignupEmailDomainBlocked. SIGNUP_DOMAIN_CHECK_ON_INVITES: z.enum(["1", "0"]).optional(), @@ -288,6 +394,7 @@ const parsedEnv = createEnv({ // weak secret can't silently ship (it stays optional for the pre-cutover rollout). BETTER_AUTH_SECRET: z.string().min(32).optional(), BETTER_AUTH_URL: z.url().optional(), + MCP_OAUTH_JWKS_URL: ZMcpOauthJwksUrl.optional(), MAIL_FROM_NAME: z.string().optional(), NOTION_OAUTH_CLIENT_ID: z.string().optional(), NOTION_OAUTH_CLIENT_SECRET: z.string().optional(), @@ -400,6 +507,7 @@ const parsedEnv = createEnv({ AZUREAD_TENANT_ID: process.env.AZUREAD_TENANT_ID, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, + MCP_OAUTH_JWKS_URL: process.env.MCP_OAUTH_JWKS_URL, BREVO_API_KEY: process.env.BREVO_API_KEY, BREVO_LIST_ID: process.env.BREVO_LIST_ID, CRON_SECRET: process.env.CRON_SECRET, @@ -409,6 +517,12 @@ const parsedEnv = createEnv({ DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK, AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID, AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO, + AUTHZED_CONSISTENCY: process.env.AUTHZED_CONSISTENCY, + AUTHZED_ENABLED: process.env.AUTHZED_ENABLED, + AUTHZED_ENDPOINT: process.env.AUTHZED_ENDPOINT, + AUTHZED_INSECURE: process.env.AUTHZED_INSECURE, + AUTHZED_SYSTEM_KEY: process.env.AUTHZED_SYSTEM_KEY, + AUTHZED_TOKEN: process.env.AUTHZED_TOKEN, SIGNUP_DOMAIN_CHECK_ON_INVITES: process.env.SIGNUP_DOMAIN_CHECK_ON_INVITES, BULLMQ_EXTERNAL_WORKER_ENABLED: process.env.BULLMQ_EXTERNAL_WORKER_ENABLED, BULLMQ_WORKER_CONCURRENCY: process.env.BULLMQ_WORKER_CONCURRENCY, @@ -526,6 +640,13 @@ const parsedEnv = createEnv({ }, }); -export const env = ZAIConfigurationEnv.superRefine(validateActiveAIProviderConfiguration) - .transform(() => parsedEnv) - .parse(parsedEnv); +const ZPostParseEnv = ZAIConfigurationEnv.extend(ZAuthzedConfigurationEnv.shape) + .superRefine(validateActiveAIProviderConfiguration) + .superRefine(validateAuthzedConfiguration); +const postParseResult = ZPostParseEnv.safeParse(parsedEnv); + +if (!postParseResult.success) { + throwEnvValidationError(postParseResult.error.issues); +} + +export const env = parsedEnv; diff --git a/apps/web/lib/feedback-source/access.test.ts b/apps/web/lib/feedback-source/access.test.ts new file mode 100644 index 000000000000..77db4297a803 --- /dev/null +++ b/apps/web/lib/feedback-source/access.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { assertCan } from "@/lib/authorization"; +import { assertFeedbackSourceDirectoryAccess } from "./access"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/authorization", () => ({ assertCan: vi.fn() })); + +describe("assertFeedbackSourceDirectoryAccess", () => { + beforeEach(() => { + vi.mocked(assertCan).mockReset().mockResolvedValue(undefined); + }); + + test.each([ + ["read", "feedbackDirectoryAssignment.read"], + ["write", "feedbackDirectoryAssignment.write"], + ] as const)("maps %s access to the exact dataset assignment", async (permission, action) => { + await assertFeedbackSourceDirectoryAccess("user_1", "directory_1", "workspace_1", permission); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, action, { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: "directory_1", + workspaceId: "workspace_1", + }); + }); +}); diff --git a/apps/web/lib/feedback-source/access.ts b/apps/web/lib/feedback-source/access.ts new file mode 100644 index 000000000000..b32c8f08383e --- /dev/null +++ b/apps/web/lib/feedback-source/access.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { assertCan } from "@/lib/authorization"; + +export const assertFeedbackSourceDirectoryAccess = async ( + userId: string, + feedbackDirectoryId: string, + workspaceId: string, + permission: "read" | "write" +): Promise => + assertCan({ type: "user", id: userId }, `feedbackDirectoryAssignment.${permission}`, { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId, + workspaceId, + }); diff --git a/apps/web/lib/feedback-source/actions.test.ts b/apps/web/lib/feedback-source/actions.test.ts new file mode 100644 index 000000000000..4bec0b8ac422 --- /dev/null +++ b/apps/web/lib/feedback-source/actions.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { + createFeedbackSourceWithMappingsAction, + deleteFeedbackSourceAction, + importHistoricalResponsesAction, + updateFeedbackSourceWithMappingsAction, +} from "./actions"; + +const mocks = vi.hoisted(() => { + const action = vi.fn((handler) => handler); + return { + action, + inputSchema: vi.fn(() => ({ action })), + applyRateLimit: vi.fn(), + assertCan: vi.fn(), + assertFeedbackSourceDirectoryAccess: vi.fn(), + getOrganizationIdFromFeedbackSourceId: vi.fn(), + getOrganizationIdFromWorkspaceId: vi.fn(), + getFeedbackSourceWithMappingsById: vi.fn(), + createFeedbackSourceWithMappings: vi.fn(), + updateFeedbackSourceWithMappings: vi.fn(), + deleteFeedbackSource: vi.fn(), + importHistoricalResponses: vi.fn(), + getSurvey: vi.fn(), + feedbackDirectoryFindUnique: vi.fn(), + feedbackSourceFindUnique: vi.fn(), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("@formbricks/database", () => ({ + prisma: { + feedbackDirectory: { findUnique: mocks.feedbackDirectoryFindUnique }, + feedbackSource: { findUnique: mocks.feedbackSourceFindUnique }, + }, +})); +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { inputSchema: mocks.inputSchema }, +})); +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, +})); +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromFeedbackSourceId: mocks.getOrganizationIdFromFeedbackSourceId, + getOrganizationIdFromSurveyId: vi.fn(), + getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId, + getWorkspaceIdFromSurveyId: vi.fn(), +})); +vi.mock("@/lib/survey/service", () => ({ getSurvey: mocks.getSurvey })); +vi.mock("@/lib/response/service", () => ({ getResponseCountBySurveyId: vi.fn() })); +vi.mock("@/modules/core/rate-limit/helpers", () => ({ applyRateLimit: mocks.applyRateLimit })); +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_event, _target, handler) => handler), +})); +vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ + getFeedbackDirectoriesByWorkspaceId: vi.fn(), +})); +vi.mock("@/modules/ee/unify-feedback/lib/contacts", () => ({ getContactIdsByUserIds: vi.fn() })); +vi.mock("@/modules/hub/service", () => ({ listFeedbackRecords: vi.fn() })); +vi.mock("./access", () => ({ + assertFeedbackSourceDirectoryAccess: mocks.assertFeedbackSourceDirectoryAccess, +})); +vi.mock("./import", () => ({ importHistoricalResponses: mocks.importHistoricalResponses })); +vi.mock("./mappings", () => ({ resolveFormbricksMappingsInput: vi.fn() })); +vi.mock("./service", () => ({ + createFeedbackSourceWithMappings: mocks.createFeedbackSourceWithMappings, + deleteFeedbackSource: mocks.deleteFeedbackSource, + getFeedbackSourceWithMappingsById: mocks.getFeedbackSourceWithMappingsById, + updateFeedbackSourceWithMappings: mocks.updateFeedbackSourceWithMappings, +})); + +const organizationId = "organization-1"; +const workspaceId = "workspace-1"; +const feedbackSourceId = "feedback-source-1"; +const feedbackDirectoryId = "feedback-directory-1"; +const ctx = { user: { id: "user-1" }, auditLoggingCtx: {} }; + +describe("feedback source mutation safeguards", () => { + beforeEach(() => { + vi.clearAllMocks(); + ctx.auditLoggingCtx = {}; + mocks.getOrganizationIdFromFeedbackSourceId.mockResolvedValue(organizationId); + mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue(organizationId); + mocks.feedbackDirectoryFindUnique.mockResolvedValue({ + organizationId, + workspaces: [{ workspaceId }], + }); + mocks.feedbackSourceFindUnique.mockResolvedValue({ feedbackDirectoryId, type: "csv" }); + mocks.getFeedbackSourceWithMappingsById.mockResolvedValue({ + id: feedbackSourceId, + feedbackDirectoryId, + type: "formbricks_survey", + formbricksMappings: [], + }); + mocks.createFeedbackSourceWithMappings.mockResolvedValue({ + id: feedbackSourceId, + feedbackDirectoryId, + type: "csv", + }); + mocks.updateFeedbackSourceWithMappings.mockResolvedValue({ + id: feedbackSourceId, + feedbackDirectoryId, + type: "csv", + name: "Renamed", + }); + mocks.deleteFeedbackSource.mockResolvedValue({ id: feedbackSourceId }); + mocks.getSurvey.mockResolvedValue({ id: "survey-1", workspaceId }); + mocks.importHistoricalResponses.mockResolvedValue({ successes: 1, failures: 0, skipped: 0 }); + }); + + test.each([ + [ + "create", + createFeedbackSourceWithMappingsAction, + { + workspaceId, + feedbackSourceInput: { feedbackDirectoryId, name: "Source", type: "csv" }, + }, + ], + [ + "update", + updateFeedbackSourceWithMappingsAction, + { feedbackSourceId, workspaceId, feedbackSourceInput: {} }, + ], + ["delete", deleteFeedbackSourceAction, { feedbackSourceId, workspaceId }], + ])("rate limits and audits %s", async (_name, action, parsedInput) => { + await (action as any)({ ctx, parsedInput }); + + expect(mocks.applyRateLimit).toHaveBeenCalledWith( + rateLimitConfigs.actions.feedbackSourceMutation, + "user-1" + ); + expect(ctx.auditLoggingCtx).toMatchObject({ + organizationId, + workspaceId, + feedbackSourceId, + }); + }); + + test("uses the tighter historical import rate limit and audits only summary counts", async () => { + await (importHistoricalResponsesAction as any)({ + ctx, + parsedInput: { feedbackSourceId, workspaceId, surveyId: "survey-1" }, + }); + + expect(mocks.applyRateLimit).toHaveBeenCalledWith( + rateLimitConfigs.actions.historicalResponseImport, + "user-1" + ); + expect(ctx.auditLoggingCtx).toMatchObject({ + organizationId, + workspaceId, + feedbackSourceId, + newObject: { successes: 1, failures: 0, skipped: 0 }, + }); + }); +}); diff --git a/apps/web/lib/feedback-source/actions.ts b/apps/web/lib/feedback-source/actions.ts index 50eaa7c2533a..f6871a48f508 100644 --- a/apps/web/lib/feedback-source/actions.ts +++ b/apps/web/lib/feedback-source/actions.ts @@ -5,26 +5,28 @@ import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { AuthorizationError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { - TFeedbackSourceWithMappings, ZFeedbackSourceCreateInput, ZFeedbackSourceFieldMappingCreateInput, ZFeedbackSourceUpdateInput, } from "@formbricks/types/feedback-source"; +import { assertCan } from "@/lib/authorization"; import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey } from "@/lib/survey/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getOrganizationIdFromFeedbackSourceId, - getOrganizationIdFromSurveyId, getOrganizationIdFromWorkspaceId, getWorkspaceIdFromSurveyId, } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getContactIdsByUserIds } from "@/modules/ee/unify-feedback/lib/contacts"; import { listFeedbackRecords } from "@/modules/hub/service"; import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types"; +import { assertFeedbackSourceDirectoryAccess } from "./access"; import { importHistoricalResponses } from "./import"; import { resolveFormbricksMappingsInput } from "./mappings"; import { @@ -48,32 +50,39 @@ const ZDeleteFeedbackSourceAction = z.object({ export const deleteFeedbackSourceAction = authenticatedActionClient .inputSchema(ZDeleteFeedbackSourceAction) .action( - async ({ - ctx, - parsedInput, - }: { - ctx: AuthenticatedActionClientCtx; - parsedInput: z.infer; - }) => { + withAuditLogging("deleted", "feedbackSource", async ({ ctx, parsedInput }) => { + ctx.auditLoggingCtx.feedbackSourceId = parsedInput.feedbackSourceId; + ctx.auditLoggingCtx.workspaceId = parsedInput.workspaceId; + await applyRateLimit(rateLimitConfigs.actions.feedbackSourceMutation, ctx.user.id); + const organizationId = await getOrganizationIdFromFeedbackSourceId(parsedInput.feedbackSourceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + ctx.auditLoggingCtx.organizationId = organizationId; + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); - return deleteFeedbackSource(parsedInput.feedbackSourceId, parsedInput.workspaceId); - } + const feedbackSource = await getFeedbackSourceWithMappingsById( + parsedInput.feedbackSourceId, + parsedInput.workspaceId + ); + if (!feedbackSource) { + throw new ResourceNotFoundError("FeedbackSource", parsedInput.feedbackSourceId); + } + await assertFeedbackSourceDirectoryAccess( + ctx.user.id, + feedbackSource.feedbackDirectoryId, + parsedInput.workspaceId, + "write" + ); + + const deletedFeedbackSource = await deleteFeedbackSource( + parsedInput.feedbackSourceId, + parsedInput.workspaceId + ); + ctx.auditLoggingCtx.oldObject = deletedFeedbackSource; + return deletedFeedbackSource; + }) ); const ZFormbricksSurveyMapping = z.object({ @@ -123,67 +132,71 @@ const ZCreateFeedbackSourceWithMappingsAction = z export const createFeedbackSourceWithMappingsAction = authenticatedActionClient .inputSchema(ZCreateFeedbackSourceWithMappingsAction) - .action(async ({ ctx, parsedInput }): Promise => { - const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], - }); - - // Verify the directory belongs to the same org and is actually assigned to this workspace. - // The composite FK enforces the assignment at the DB level too; these checks return the - // friendlier errors first: a generic auth error for missing/cross-org directories, a typed - // error for a same-org directory that just isn't assigned to the workspace. - const frd = await prisma.feedbackDirectory.findUnique({ - where: { id: parsedInput.feedbackSourceInput.feedbackDirectoryId }, - select: { - organizationId: true, - workspaces: { - where: { workspaceId: parsedInput.workspaceId }, - select: { workspaceId: true }, + .action( + withAuditLogging("created", "feedbackSource", async ({ ctx, parsedInput }) => { + ctx.auditLoggingCtx.workspaceId = parsedInput.workspaceId; + await applyRateLimit(rateLimitConfigs.actions.feedbackSourceMutation, ctx.user.id); + + const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); + ctx.auditLoggingCtx.organizationId = organizationId; + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, + }); + + // Verify the directory belongs to the same org and is actually assigned to this workspace. + // The composite FK enforces the assignment at the DB level too; these checks return the + // friendlier errors first: a generic auth error for missing/cross-org directories, a typed + // error for a same-org directory that just isn't assigned to the workspace. + const frd = await prisma.feedbackDirectory.findUnique({ + where: { id: parsedInput.feedbackSourceInput.feedbackDirectoryId }, + select: { + organizationId: true, + workspaces: { + where: { workspaceId: parsedInput.workspaceId }, + select: { workspaceId: true }, + }, }, - }, - }); - if (frd?.organizationId !== organizationId) { - throw new AuthorizationError("Invalid feedback directory"); - } - if (frd.workspaces.length === 0) { - throw new InvalidInputError("FEEDBACK_SOURCE_DIRECTORY_NOT_ASSIGNED_TO_WORKSPACE"); - } + }); + if (frd?.organizationId !== organizationId) { + throw new AuthorizationError("Invalid feedback directory"); + } + if (frd.workspaces.length === 0) { + throw new InvalidInputError("FEEDBACK_SOURCE_DIRECTORY_NOT_ASSIGNED_TO_WORKSPACE"); + } + await assertFeedbackSourceDirectoryAccess( + ctx.user.id, + parsedInput.feedbackSourceInput.feedbackDirectoryId, + parsedInput.workspaceId, + "write" + ); - let mappingsInput: TMappingsInput | undefined; + let mappingsInput: TMappingsInput | undefined; - const { formbricksMappings, fieldMappings } = parsedInput; + const { formbricksMappings, fieldMappings } = parsedInput; - if (formbricksMappings?.length) { - mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings, parsedInput.workspaceId); - } else if (fieldMappings?.length) { - mappingsInput = { - type: "field", - mappings: - parsedInput.feedbackSourceInput.type === "csv" - ? sanitizeAndValidateCsvFieldMappings(fieldMappings) - : fieldMappings, - }; - } + if (formbricksMappings?.length) { + mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings, parsedInput.workspaceId); + } else if (fieldMappings?.length) { + mappingsInput = { + type: "field", + mappings: + parsedInput.feedbackSourceInput.type === "csv" + ? sanitizeAndValidateCsvFieldMappings(fieldMappings) + : fieldMappings, + }; + } - return createFeedbackSourceWithMappings( - parsedInput.workspaceId, - { ...parsedInput.feedbackSourceInput, createdBy: ctx.user.id }, - mappingsInput - ); - }); + const createdFeedbackSource = await createFeedbackSourceWithMappings( + parsedInput.workspaceId, + { ...parsedInput.feedbackSourceInput, createdBy: ctx.user.id }, + mappingsInput + ); + ctx.auditLoggingCtx.feedbackSourceId = createdFeedbackSource.id; + ctx.auditLoggingCtx.newObject = createdFeedbackSource; + return createdFeedbackSource; + }) + ); const ZUpdateFeedbackSourceWithMappingsAction = z.object({ feedbackSourceId: ZId, @@ -196,28 +209,16 @@ const ZUpdateFeedbackSourceWithMappingsAction = z.object({ export const updateFeedbackSourceWithMappingsAction = authenticatedActionClient .inputSchema(ZUpdateFeedbackSourceWithMappingsAction) .action( - async ({ - ctx, - parsedInput, - }: { - ctx: AuthenticatedActionClientCtx; - parsedInput: z.infer; - }): Promise => { + withAuditLogging("updated", "feedbackSource", async ({ ctx, parsedInput }) => { + ctx.auditLoggingCtx.feedbackSourceId = parsedInput.feedbackSourceId; + ctx.auditLoggingCtx.workspaceId = parsedInput.workspaceId; + await applyRateLimit(rateLimitConfigs.actions.feedbackSourceMutation, ctx.user.id); + const organizationId = await getOrganizationIdFromFeedbackSourceId(parsedInput.feedbackSourceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + ctx.auditLoggingCtx.organizationId = organizationId; + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); // The check above proves the caller may act in `workspaceId`; it does not prove this feedback @@ -227,11 +228,17 @@ export const updateFeedbackSourceWithMappingsAction = authenticatedActionClient // fails here rather than as a Prisma error from the update. const feedbackSource = await prisma.feedbackSource.findUnique({ where: { id: parsedInput.feedbackSourceId, workspaceId: parsedInput.workspaceId }, - select: { type: true }, + select: { feedbackDirectoryId: true, type: true }, }); if (!feedbackSource) { throw new ResourceNotFoundError("FeedbackSource", parsedInput.feedbackSourceId); } + await assertFeedbackSourceDirectoryAccess( + ctx.user.id, + feedbackSource.feedbackDirectoryId, + parsedInput.workspaceId, + "write" + ); let mappingsInput: TMappingsInput | undefined; @@ -250,13 +257,16 @@ export const updateFeedbackSourceWithMappingsAction = authenticatedActionClient }; } - return updateFeedbackSourceWithMappings( + const updatedFeedbackSource = await updateFeedbackSourceWithMappings( parsedInput.feedbackSourceId, parsedInput.workspaceId, parsedInput.feedbackSourceInput, mappingsInput ); - } + ctx.auditLoggingCtx.oldObject = feedbackSource; + ctx.auditLoggingCtx.newObject = updatedFeedbackSource; + return updatedFeedbackSource; + }) ); const ZGetResponseCountAction = z.object({ @@ -274,27 +284,14 @@ export const getResponseCountAction = authenticatedActionClient ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; }): Promise => { - const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - // Authorize against the survey's own workspace, not the caller-supplied one: the workspaceTeam // check only proves team access to whatever workspace the caller names, so passing a workspace // they do have access to would otherwise return the response count for any survey in the org. const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: surveyWorkspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: surveyWorkspaceId, }); return getResponseCountBySurveyId(parsedInput.surveyId); @@ -310,28 +307,16 @@ const ZImportHistoricalResponsesAction = z.object({ export const importHistoricalResponsesAction = authenticatedActionClient .inputSchema(ZImportHistoricalResponsesAction) .action( - async ({ - ctx, - parsedInput, - }: { - ctx: AuthenticatedActionClientCtx; - parsedInput: z.infer; - }) => { + withAuditLogging("updated", "feedbackSource", async ({ ctx, parsedInput }) => { + ctx.auditLoggingCtx.feedbackSourceId = parsedInput.feedbackSourceId; + ctx.auditLoggingCtx.workspaceId = parsedInput.workspaceId; + await applyRateLimit(rateLimitConfigs.actions.historicalResponseImport, ctx.user.id); + const organizationId = await getOrganizationIdFromFeedbackSourceId(parsedInput.feedbackSourceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + ctx.auditLoggingCtx.organizationId = organizationId; + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); const feedbackSource = await getFeedbackSourceWithMappingsById( @@ -341,6 +326,12 @@ export const importHistoricalResponsesAction = authenticatedActionClient if (!feedbackSource) { throw new ResourceNotFoundError("FeedbackSource", parsedInput.feedbackSourceId); } + await assertFeedbackSourceDirectoryAccess( + ctx.user.id, + feedbackSource.feedbackDirectoryId, + parsedInput.workspaceId, + "write" + ); const survey = await getSurvey(parsedInput.surveyId); if (!survey) { @@ -357,8 +348,10 @@ export const importHistoricalResponsesAction = authenticatedActionClient throw new ResourceNotFoundError("Survey", parsedInput.surveyId); } - return importHistoricalResponses(feedbackSource, survey); - } + const importResult = await importHistoricalResponses(feedbackSource, survey); + ctx.auditLoggingCtx.newObject = importResult; + return importResult; + }) ); const ZListFeedbackRecordsAction = z.object({ @@ -384,21 +377,9 @@ export const listFeedbackRecordsAction = authenticatedActionClient ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; }): Promise => { - const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: parsedInput.workspaceId, }); // Verify FRD belongs to workspace's accessible FRDs @@ -406,6 +387,12 @@ export const listFeedbackRecordsAction = authenticatedActionClient if (!frds.some((f) => f.id === parsedInput.frdId)) { throw new Error("Feedback directory not accessible"); } + await assertFeedbackSourceDirectoryAccess( + ctx.user.id, + parsedInput.frdId, + parsedInput.workspaceId, + "read" + ); const params: FeedbackRecordListParams = { tenant_id: parsedInput.frdId, @@ -445,21 +432,9 @@ export const getFeedbackRecordContactsAction = authenticatedActionClient ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; }): Promise> => { - const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: parsedInput.workspaceId, }); return getContactIdsByUserIds(parsedInput.workspaceId, parsedInput.userIds); diff --git a/apps/web/lib/jobs/recurring-registrations.ts b/apps/web/lib/jobs/recurring-registrations.ts index 3c88cc8ddb82..b0550f4eb495 100644 --- a/apps/web/lib/jobs/recurring-registrations.ts +++ b/apps/web/lib/jobs/recurring-registrations.ts @@ -10,6 +10,8 @@ import { type TWorkflowRunJobData, recurringJobs, } from "@formbricks/jobs"; +import { processAuthzedProjectionDeliveryJob } from "@/lib/authzed/outbox-processor"; +import { processAuthzedScheduledReconciliationJob } from "@/lib/authzed/scheduled-reconciliation"; import { processWorkflowRunJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-job"; import { processWorkflowRunReconcileJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job"; import { WORKFLOW_RUN_RECONCILE_INTERVAL_MS } from "@/modules/ee/workflows/lib/runner/reconcile-constants"; @@ -56,6 +58,22 @@ interface RecurringJobRegistration { * registered at all). A test pins the pairing instead. */ export const RECURRING_JOB_REGISTRATIONS_BY_KEY: Record = { + authzedProjectionDelivery: { + handler: processAuthzedProjectionDeliveryJob, + job: recurringJobs.authzedProjectionDelivery, + schedule: { + everyMs: 5_000, + kind: "every", + }, + }, + authzedReconciliationAudit: { + handler: processAuthzedScheduledReconciliationJob, + job: recurringJobs.authzedReconciliationAudit, + schedule: { + everyMs: 6 * 60 * 60 * 1_000, + kind: "every", + }, + }, surveyArchivePurge: { handler: processSurveyArchivePurgeJob, job: recurringJobs.surveyArchivePurge, diff --git a/apps/web/lib/membership/service.test.ts b/apps/web/lib/membership/service.test.ts index 2a40e9f9326a..6ecfe2dfd3e2 100644 --- a/apps/web/lib/membership/service.test.ts +++ b/apps/web/lib/membership/service.test.ts @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, UnknownError } from "@formbricks/types/errors"; import { TMembership } from "@formbricks/types/memberships"; +import { reconcileOrganizationMembership } from "../authzed/organization-membership"; import { createMembership, getMembershipByUserIdOrganizationId } from "./service"; vi.mock("@formbricks/database", () => ({ @@ -15,6 +16,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("../authzed/organization-membership", () => ({ + reconcileOrganizationMembership: vi.fn(), +})); + describe("Membership Service", () => { afterEach(() => { vi.clearAllMocks(); @@ -127,6 +132,7 @@ describe("Membership Service", () => { role: mockMembershipData.role, }, }); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith(mockOrgId, mockUserId); }); test("returns existing membership if role matches", async () => { @@ -143,6 +149,7 @@ describe("Membership Service", () => { expect(result).toEqual(existingMembership); expect(prisma.membership.create).not.toHaveBeenCalled(); expect(prisma.membership.update).not.toHaveBeenCalled(); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith(mockOrgId, mockUserId); }); test("updates existing membership if role differs", async () => { @@ -175,6 +182,33 @@ describe("Membership Service", () => { role: "owner", }, }); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith(mockOrgId, mockUserId); + }); + + test("defers projection when the membership participates in an outer transaction", async () => { + const createdMembership = { + organizationId: mockOrgId, + userId: mockUserId, + accepted: true, + role: "member", + } as TMembership; + const transaction = { + membership: { + create: vi.fn().mockResolvedValue(createdMembership), + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn(), + }, + } as any; + + await expect( + createMembership(mockOrgId, mockUserId, mockMembershipData, { + projection: "deferred", + transaction, + }) + ).resolves.toEqual(createdMembership); + + expect(transaction.membership.create).toHaveBeenCalled(); + expect(reconcileOrganizationMembership).not.toHaveBeenCalled(); }); test("throws DatabaseError on Prisma error", async () => { diff --git a/apps/web/lib/membership/service.ts b/apps/web/lib/membership/service.ts index 3a455dc8082a..ef13108389f1 100644 --- a/apps/web/lib/membership/service.ts +++ b/apps/web/lib/membership/service.ts @@ -6,10 +6,16 @@ import { logger } from "@formbricks/logger"; import { ZString } from "@formbricks/types/common"; import { DatabaseError, UnknownError } from "@formbricks/types/errors"; import { TMembership, ZMembership } from "@formbricks/types/memberships"; +import { reconcileOrganizationMembership } from "../authzed/organization-membership"; import { validateInputs } from "../utils/validate"; type TMembershipDbClient = PrismaClient | Prisma.TransactionClient; +type TDeferredMembershipProjection = Readonly<{ + projection: "deferred"; + transaction: Prisma.TransactionClient; +}>; + const getDbClient = (tx?: Prisma.TransactionClient): TMembershipDbClient => tx ?? prisma; const getMembershipByUserIdOrganizationIdUncached = async ( @@ -62,12 +68,14 @@ export const createMembership = async ( organizationId: string, userId: string, data: Partial, - tx?: Prisma.TransactionClient + options?: TDeferredMembershipProjection ): Promise => { validateInputs([organizationId, ZString], [userId, ZString], [data, ZMembership.partial()]); + let membership: TMembership; + try { - const prismaClient = getDbClient(tx); + const prismaClient = getDbClient(options?.transaction); const existingMembership = await prismaClient.membership.findUnique({ where: { userId_organizationId: { @@ -78,11 +86,8 @@ export const createMembership = async ( }); if (existingMembership && existingMembership.role === data.role) { - return existingMembership; - } - - let membership: TMembership; - if (!existingMembership) { + membership = existingMembership; + } else if (!existingMembership) { membership = await prismaClient.membership.create({ data: { userId, @@ -105,8 +110,6 @@ export const createMembership = async ( }, }); } - - return membership; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); @@ -114,4 +117,12 @@ export const createMembership = async ( throw error; } + + // Transactional callers must project only after their outer transaction commits. Non-transactional + // callers reconcile even on an idempotent retry so a repeated source mutation can heal SpiceDB. + if (!options) { + await reconcileOrganizationMembership(organizationId, userId); + } + + return membership; }; diff --git a/apps/web/lib/organization/auth.test.ts b/apps/web/lib/organization/auth.test.ts index e5c2f5245bb1..c0acdd1846db 100644 --- a/apps/web/lib/organization/auth.test.ts +++ b/apps/web/lib/organization/auth.test.ts @@ -1,38 +1,94 @@ -import { describe, expect, test, vi } from "vitest"; -import { TOrganization } from "@formbricks/types/organizations"; -import { canUserAccessOrganization } from "./auth"; -import { getOrganizationsByUserId } from "./service"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { can } from "../authorization"; +import { validateInputs } from "../utils/validate"; +import { canUserAccessOrganization, verifyUserRoleAccess } from "./auth"; -vi.mock("./service", () => ({ - getOrganizationsByUserId: vi.fn(), +vi.mock("../authorization", () => ({ + can: vi.fn(), })); -describe("auth", () => { - describe("canUserAccessOrganization", () => { - test("returns true when user has access to organization", async () => { - const mockOrganizations: TOrganization[] = [ - { - id: "org1", - createdAt: new Date(), - updatedAt: new Date(), - name: "Org 1", - billing: { - stripeCustomerId: null, - limits: { - workspaces: 3, - monthly: { - responses: 1500, - }, - }, - usageCycleAnchor: new Date(), - }, - isAISmartToolsEnabled: false, - }, - ]; - vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations); - - const result = await canUserAccessOrganization("user1", "org1"); - expect(result).toBe(true); +vi.mock("../utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +describe("organization authorization helpers", () => { + const userId = "user1"; + const organizationId = "org1"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("canUserAccessOrganization delegates to organization.read", async () => { + vi.mocked(can).mockResolvedValue(true); + + await expect(canUserAccessOrganization(userId, organizationId)).resolves.toBe(true); + + expect(validateInputs).toHaveBeenCalledWith( + [userId, expect.anything()], + [organizationId, expect.anything()] + ); + expect(can).toHaveBeenCalledWith({ type: "user", id: userId }, "organization.read", { + type: "organization", + id: organizationId, + }); + }); + + test.each([ + { + name: "owner", + owner: true, + manager: true, + expected: { + hasCreateOrUpdateAccess: true, + hasDeleteAccess: true, + hasCreateOrUpdateMembersAccess: true, + hasDeleteMembersAccess: true, + hasBillingAccess: true, + }, + }, + { + name: "manager", + owner: false, + manager: true, + expected: { + hasCreateOrUpdateAccess: false, + hasDeleteAccess: false, + hasCreateOrUpdateMembersAccess: true, + hasDeleteMembersAccess: true, + hasBillingAccess: true, + }, + }, + { + name: "member", + owner: false, + manager: false, + expected: { + hasCreateOrUpdateAccess: false, + hasDeleteAccess: false, + hasCreateOrUpdateMembersAccess: false, + hasDeleteMembersAccess: false, + hasBillingAccess: false, + }, + }, + ])("preserves the $name role access bundle", async ({ owner, manager, expected }) => { + vi.mocked(can).mockResolvedValueOnce(owner).mockResolvedValueOnce(manager); + + await expect(verifyUserRoleAccess(organizationId, userId)).resolves.toEqual(expected); + + expect(can).toHaveBeenNthCalledWith(1, { type: "user", id: userId }, "organization.write", { + type: "organization", + id: organizationId, }); + expect(can).toHaveBeenNthCalledWith(2, { type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, + }); + }); + + test("propagates evaluator failures", async () => { + vi.mocked(can).mockRejectedValue(new Error("database unavailable")); + + await expect(verifyUserRoleAccess(organizationId, userId)).rejects.toThrow("database unavailable"); }); }); diff --git a/apps/web/lib/organization/auth.ts b/apps/web/lib/organization/auth.ts index 63dec2a0ea1d..d9e65f9dde4b 100644 --- a/apps/web/lib/organization/auth.ts +++ b/apps/web/lib/organization/auth.ts @@ -1,15 +1,36 @@ import "server-only"; import { ZId } from "@formbricks/types/common"; +import { can } from "../authorization"; import { validateInputs } from "../utils/validate"; -import { getOrganizationsByUserId } from "./service"; export const canUserAccessOrganization = async (userId: string, organizationId: string): Promise => { validateInputs([userId, ZId], [organizationId, ZId]); - try { - const userOrganizations = await getOrganizationsByUserId(userId); - return userOrganizations.some((organization) => organization.id === organizationId); - } catch (error) { - throw error; - } + return can({ type: "user", id: userId }, "organization.read", { type: "organization", id: organizationId }); +}; + +export const verifyUserRoleAccess = async ( + organizationId: string, + userId: string +): Promise<{ + hasCreateOrUpdateAccess: boolean; + hasDeleteAccess: boolean; + hasCreateOrUpdateMembersAccess: boolean; + hasDeleteMembersAccess: boolean; + hasBillingAccess: boolean; +}> => { + const actor = { type: "user", id: userId } as const; + const organization = { type: "organization", id: organizationId } as const; + const [hasOwnerAccess, hasManagerAccess] = await Promise.all([ + can(actor, "organization.write", organization), + can(actor, "organization.manage", organization), + ]); + + return { + hasCreateOrUpdateAccess: hasOwnerAccess, + hasDeleteAccess: hasOwnerAccess, + hasCreateOrUpdateMembersAccess: hasManagerAccess, + hasDeleteMembersAccess: hasManagerAccess, + hasBillingAccess: hasManagerAccess, + }; }; diff --git a/apps/web/lib/organization/service.test.ts b/apps/web/lib/organization/service.test.ts index 3462f269167a..cc9eeb7fcf7c 100644 --- a/apps/web/lib/organization/service.test.ts +++ b/apps/web/lib/organization/service.test.ts @@ -2,6 +2,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { lookupAuthorizedOrganizationIds } from "@/lib/authorization/resource-list"; +import { reconcileApiKeyRelationships } from "@/lib/authzed/api-key"; +import { reconcileFeedbackDirectoryRelationships } from "@/lib/authzed/feedback-directory"; +import { deleteOrganizationRelationships } from "@/lib/authzed/organization-membership"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { updateUser } from "@/lib/user/service"; import { getWorkspaces } from "@/lib/workspace/service"; @@ -49,6 +54,20 @@ vi.mock("@/lib/user/service", () => ({ vi.mock("@/lib/workspace/service", () => ({ getWorkspaces: vi.fn(), })); +vi.mock("@/lib/authorization/resource-list", () => ({ lookupAuthorizedOrganizationIds: vi.fn() })); + +vi.mock("@/lib/authzed/organization-membership", () => ({ + deleteOrganizationRelationships: vi.fn(), +})); +vi.mock("@/lib/authzed/api-key", () => ({ + reconcileApiKeyRelationships: vi.fn(), +})); +vi.mock("@/lib/authzed/feedback-directory", () => ({ + reconcileFeedbackDirectoryRelationships: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({ ensureCloudStripeSetupForOrganization: vi.fn().mockResolvedValue(undefined), @@ -65,6 +84,7 @@ vi.mock("@/modules/hub/service", () => ({ describe("Organization Service", () => { beforeEach(() => { vi.mocked(ensureCloudStripeSetupForOrganization).mockResolvedValue(undefined); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue(["org1"]); }); afterEach(() => { @@ -154,11 +174,7 @@ describe("Organization Service", () => { expect(result).toEqual(mockOrganizations); expect(prisma.organization.findMany).toHaveBeenCalledWith({ where: { - memberships: { - some: { - userId: "user1", - }, - }, + id: { in: ["org1"] }, }, select: expect.any(Object), }); @@ -395,11 +411,18 @@ describe("Organization Service", () => { billing: { stripeCustomerId: "cus_123" }, memberships: [], workspaces: [], + teams: [], + apiKeys: [{ id: "api-key-1" }], feedbackDirectories: [], } as any); await deleteOrganization("org1"); + expect(deleteOrganizationRelationships).toHaveBeenCalledWith("org1"); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamIds: [], workspaceIds: [] }); + expect(reconcileApiKeyRelationships).toHaveBeenCalledWith({ + apiKeyIds: ["api-key-1"], + }); if (IS_FORMBRICKS_CLOUD) { expect(cleanupStripeCustomer).toHaveBeenCalledWith("cus_123"); } @@ -412,8 +435,13 @@ describe("Organization Service", () => { name: "Test Org", billing: null, memberships: [], - workspaces: [], - feedbackDirectories: [{ id: "frd_1" }, { id: "frd_2" }], + workspaces: [{ id: "workspace-1" }], + teams: [{ id: "team-1" }], + apiKeys: [{ id: "api-key-1" }, { id: "api-key-2" }], + feedbackDirectories: [ + { id: "frd_1", workspaces: [{ workspaceId: "workspace-1" }] }, + { id: "frd_2", workspaces: [] }, + ], } as any); await deleteOrganization("org1"); @@ -421,6 +449,17 @@ describe("Organization Service", () => { expect(deleteHubTenantData).toHaveBeenCalledTimes(2); expect(deleteHubTenantData).toHaveBeenCalledWith("frd_1"); expect(deleteHubTenantData).toHaveBeenCalledWith("frd_2"); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamIds: ["team-1"], + workspaceIds: ["workspace-1"], + }); + expect(reconcileApiKeyRelationships).toHaveBeenCalledWith({ + apiKeyIds: ["api-key-1", "api-key-2"], + }); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledWith({ + assignments: [{ feedbackDirectoryId: "frd_1", workspaceId: "workspace-1" }], + feedbackDirectoryIds: ["frd_1", "frd_2"], + }); }); }); diff --git a/apps/web/lib/organization/service.ts b/apps/web/lib/organization/service.ts index b531dcc496f0..f3599a6edaf1 100644 --- a/apps/web/lib/organization/service.ts +++ b/apps/web/lib/organization/service.ts @@ -14,6 +14,12 @@ import { ZOrganizationCreateInput, } from "@formbricks/types/organizations"; import { TUserNotificationSettings } from "@formbricks/types/user"; +import { lookupAuthorizedOrganizationIds } from "@/lib/authorization/resource-list"; +import { reconcileApiKeyRelationships } from "@/lib/authzed/api-key"; +import { reconcileFeedbackDirectoryRelationships } from "@/lib/authzed/feedback-directory"; +import { deleteOrganizationRelationships } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants"; import { updateUser } from "@/lib/user/service"; import { getBillingUsageCycleWindow } from "@/lib/utils/billing"; @@ -89,13 +95,12 @@ export const getOrganizationsByUserId = reactCache( validateInputs([userId, ZString], [page, ZOptionalNumber]); try { + const organizationIds = await lookupAuthorizedOrganizationIds({ type: "user", id: userId }); + if (organizationIds.length === 0) return []; + const organizations = await prisma.organization.findMany({ where: { - memberships: { - some: { - userId, - }, - }, + id: { in: [...organizationIds] }, }, select, take: page ? ITEMS_PER_PAGE : undefined, @@ -297,14 +302,51 @@ export const deleteOrganization = async (organizationId: string) => { id: true, }, }, + teams: { + select: { + id: true, + }, + }, + apiKeys: { + select: { + id: true, + }, + }, feedbackDirectories: { select: { id: true, + workspaces: { select: { workspaceId: true } }, }, }, }, }); + await runPostCommitProjection("organization_delete_relationship_cleanup", () => + deleteOrganizationRelationships(organizationId) + ); + await runPostCommitProjection("organization_delete_team_workspace_cleanup", () => + reconcileTeamWorkspaceRelationships({ + teamIds: deletedOrganization.teams.map(({ id }) => id), + workspaceIds: deletedOrganization.workspaces.map(({ id }) => id), + }) + ); + await runPostCommitProjection("organization_delete_api_key_cleanup", () => + reconcileApiKeyRelationships({ + apiKeyIds: deletedOrganization.apiKeys.map(({ id }) => id), + }) + ); + await runPostCommitProjection("organization_delete_feedback_directory_cleanup", () => + reconcileFeedbackDirectoryRelationships({ + assignments: deletedOrganization.feedbackDirectories.flatMap((directory) => + directory.workspaces.map(({ workspaceId }) => ({ + feedbackDirectoryId: directory.id, + workspaceId, + })) + ), + feedbackDirectoryIds: deletedOrganization.feedbackDirectories.map(({ id }) => id), + }) + ); + const stripeCustomerId = deletedOrganization.billing?.stripeCustomerId; if (IS_FORMBRICKS_CLOUD && stripeCustomerId) { await cleanupStripeCustomer(stripeCustomerId); diff --git a/apps/web/lib/turbo-build-schema-input.test.ts b/apps/web/lib/turbo-build-schema-input.test.ts new file mode 100644 index 000000000000..60b72a43cc70 --- /dev/null +++ b/apps/web/lib/turbo-build-schema-input.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +// Guards the coupling between `vite.authzed-cli.config.mts` and `turbo.json` (ENG-2340), the same +// shape as the next.config.mjs guard in `turbo-build-env.test.ts`. +// +// The web build reads the canonical authorization schema from the repo root at build time and emits +// it into the packaged operator CLI, so `formbricks-authzed schema check` / `apply` ship whatever +// that build captured. But `authzed/schema.zed` lives outside `apps/web`, and the `build` task +// declares no `inputs`, so Turbo hashes only files inside the package: without an explicit +// declaration the file that defines the shipped authorization semantics is hashed by nothing, and a +// warm cache is free to restore a CLI carrying the previous schema. +// +// This bit, concretely: a schema-only change (exactly what ENG-2340 is) touches no file under +// `apps/web`, so it is the case most likely to hit a cache that has no reason to miss. + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "..", "..", ".."); +const turboJsonPath = path.join(repoRoot, "turbo.json"); +const cliConfigPath = path.join(repoRoot, "apps", "web", "vite.authzed-cli.config.mts"); + +const CANONICAL_SCHEMA = "authzed/schema.zed"; + +describe("turbo hashes the canonical AuthZed schema into the web build", () => { + const turboJson = JSON.parse(fs.readFileSync(turboJsonPath, "utf-8")) as { + globalDependencies?: string[]; + tasks: Record; + }; + + test("the CLI build still bundles the schema, so this guard still has a subject", () => { + const cliConfig = fs.readFileSync(cliConfigPath, "utf-8"); + // If this ever stops being true the coupling is gone and the assertion below can be dropped — + // but it should be dropped deliberately, not left asserting something that no longer exists. + expect(cliConfig).toContain(CANONICAL_SCHEMA); + }); + + test("a schema-only change invalidates the cached web build", () => { + // Either mechanism is fine; what matters is that the file is hashed somewhere that reaches + // `@formbricks/web#build`. `globalDependencies` is the simpler of the two because package-scoped + // task configs replace rather than merge, so an `inputs` override would have to restate the + // whole build task. + const globalDependencies = turboJson.globalDependencies ?? []; + const buildInputs = turboJson.tasks["@formbricks/web#build"]?.inputs ?? []; + + const hashed = + globalDependencies.includes(CANONICAL_SCHEMA) || + buildInputs.some((input) => input.endsWith(CANONICAL_SCHEMA)); + + expect( + hashed, + `${CANONICAL_SCHEMA} is not hashed into @formbricks/web#build. Add it to turbo.json's ` + + "`globalDependencies` (or to that task's `inputs`), or a cached build can ship a stale " + + "authorization schema inside the packaged formbricks-authzed CLI (ENG-2340)." + ).toBe(true); + }); +}); diff --git a/apps/web/lib/user/service.test.ts b/apps/web/lib/user/service.test.ts index 92b5deafaeac..c7c4f5222c09 100644 --- a/apps/web/lib/user/service.test.ts +++ b/apps/web/lib/user/service.test.ts @@ -5,6 +5,8 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TOrganization } from "@formbricks/types/organizations"; import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user"; +import { deleteUserOrganizationRelationships } from "@/lib/authzed/organization-membership"; +import { deleteUserTeamRelationships } from "@/lib/authzed/team-workspace"; import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { publicUserSelect } from "./public-user"; import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service"; @@ -29,6 +31,13 @@ vi.mock("@/lib/organization/service", () => ({ deleteOrganization: vi.fn(), })); +vi.mock("@/lib/authzed/organization-membership", () => ({ + deleteUserOrganizationRelationships: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + deleteUserTeamRelationships: vi.fn(), +})); + describe("User Service", () => { afterEach(() => { vi.clearAllMocks(); @@ -212,6 +221,8 @@ describe("User Service", () => { where: { id: "user1" }, select: publicUserSelect, }); + expect(deleteUserOrganizationRelationships).toHaveBeenCalledWith("user1"); + expect(deleteUserTeamRelationships).toHaveBeenCalledWith("user1"); }); // Regression for ENG-1057: Invite.creatorId has no onDelete rule, so any diff --git a/apps/web/lib/user/service.ts b/apps/web/lib/user/service.ts index 9d125bacb9cf..2499e904534a 100644 --- a/apps/web/lib/user/service.ts +++ b/apps/web/lib/user/service.ts @@ -7,6 +7,9 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user"; +import { deleteUserOrganizationRelationships } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { deleteUserTeamRelationships } from "@/lib/authzed/team-workspace"; import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo"; import { validateInputs } from "../utils/validate"; @@ -117,6 +120,10 @@ export const deleteUser = async (id: string): Promise => { await prisma.invite.deleteMany({ where: { creatorId: id } }); const deletedUser = await deleteUserById(id); + await runPostCommitProjection("user_delete_organization_cleanup", () => + deleteUserOrganizationRelationships(id) + ); + await runPostCommitProjection("user_delete_team_cleanup", () => deleteUserTeamRelationships(id)); await deleteBrevoCustomerByEmail({ email: deletedUser.email }); return deletedUser; diff --git a/apps/web/lib/utils/action-client/action-client-middleware.test.ts b/apps/web/lib/utils/action-client/action-client-middleware.test.ts deleted file mode 100644 index a0ff3dbb674d..000000000000 --- a/apps/web/lib/utils/action-client/action-client-middleware.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { cleanup } from "@testing-library/react"; -import { returnValidationErrors } from "next-safe-action"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { ZodIssue, z } from "zod"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { getMembershipRole } from "@/lib/membership/hooks/actions"; -import { getTeamRoleByTeamIdUserId, getWorkspacePermissionByUserId } from "@/modules/ee/teams/lib/roles"; -import { checkAuthorizationUpdated, formatErrors } from "./action-client-middleware"; - -vi.mock("@/lib/membership/hooks/actions", () => ({ - getMembershipRole: vi.fn(), -})); - -vi.mock("@/modules/ee/teams/lib/roles", () => ({ - getWorkspacePermissionByUserId: vi.fn(), - getTeamRoleByTeamIdUserId: vi.fn(), -})); - -vi.mock("next-safe-action", () => ({ - returnValidationErrors: vi.fn(), -})); - -describe("action-client-middleware", () => { - const userId = "user-1"; - const organizationId = "org-1"; - const workspaceId = "workspace-1"; - const teamId = "team-1"; - - afterEach(() => { - cleanup(); - vi.resetAllMocks(); - }); - - describe("formatErrors", () => { - // We need to access the private function for testing - // Using any to access the function directly - - test("formats simple path ZodIssue", () => { - const issues = [ - { - code: "custom", - path: ["name"], - message: "Name is required", - }, - ] as ZodIssue[]; - - const result = formatErrors(issues); - expect(result).toEqual({ - name: { - _errors: ["Name is required"], - }, - }); - }); - - test("formats nested path ZodIssue", () => { - const issues = [ - { - code: "custom", - path: ["user", "address", "street"], - message: "Street is required", - }, - ] as ZodIssue[]; - - const result = formatErrors(issues); - expect(result).toEqual({ - "user.address.street": { - _errors: ["Street is required"], - }, - }); - }); - - test("formats multiple ZodIssues", () => { - const issues = [ - { - code: "custom", - path: ["name"], - message: "Name is required", - }, - { - code: "custom", - path: ["email"], - message: "Invalid email", - }, - ] as ZodIssue[]; - - const result = formatErrors(issues); - expect(result).toEqual({ - name: { - _errors: ["Name is required"], - }, - email: { - _errors: ["Invalid email"], - }, - }); - }); - }); - - describe("checkAuthorizationUpdated", () => { - test("returns validation errors when schema validation fails", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("owner"); - - const mockSchema = z.object({ - name: z.string(), - }); - - const mockData = { name: 123 }; // Type error to trigger validation failure - - vi.mocked(returnValidationErrors).mockReturnValue("validation-error" as unknown as never); - - const access = [ - { - type: "organization" as const, - schema: mockSchema, - data: mockData as any, - roles: ["owner" as const], - }, - ]; - - const result = await checkAuthorizationUpdated({ - userId, - organizationId, - access, - }); - - expect(returnValidationErrors).toHaveBeenCalledWith(expect.any(Object), expect.any(Object)); - expect(result).toBe("validation-error"); - }); - - test("returns true when organization access matches role", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("owner"); - - const access = [ - { - type: "organization" as const, - roles: ["owner" as const], - }, - ]; - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - }); - - test("continues checking other access items when organization role doesn't match", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "organization" as const, - roles: ["owner" as const], - }, - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "read" as const, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("readWrite"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - expect(getWorkspacePermissionByUserId).toHaveBeenCalledWith(userId, workspaceId); - }); - - test("returns true when workspaceTeam access matches permission", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "read" as const, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("readWrite"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - expect(getWorkspacePermissionByUserId).toHaveBeenCalledWith(userId, workspaceId); - }); - - test("continues checking other access items when workspaceTeam permission is insufficient", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "manage" as const, - }, - { - type: "team" as const, - teamId, - minPermission: "contributor" as const, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - expect(getWorkspacePermissionByUserId).toHaveBeenCalledWith(userId, workspaceId); - expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId); - }); - - test("returns true when team access matches role", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "team" as const, - teamId, - minPermission: "contributor" as const, - }, - ]; - - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("admin"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId); - }); - - test("continues checking other access items when team role is insufficient", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "team" as const, - teamId, - minPermission: "admin" as const, - }, - { - type: "organization" as const, - roles: ["member" as const], - }, - ]; - - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - expect(getTeamRoleByTeamIdUserId).toHaveBeenCalledWith(teamId, userId); - }); - - test("throws AuthorizationError when no access matches", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "organization" as const, - roles: ["owner" as const], - }, - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "manage" as const, - }, - { - type: "team" as const, - teamId, - minPermission: "admin" as const, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); - - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - AuthorizationError - ); - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - "Not authorized" - ); - }); - - test("continues to check when workspacePermission is null", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "read" as const, - }, - { - type: "organization" as const, - roles: ["member" as const], - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - }); - - test("continues to check when teamRole is null", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "team" as const, - teamId, - minPermission: "contributor" as const, - }, - { - type: "organization" as const, - roles: ["member" as const], - }, - ]; - - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue(null); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - }); - - test("returns true when schema validation passes", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("owner"); - - const mockSchema = z.object({ - name: z.string(), - }); - - const mockData = { name: "test" }; - - const access = [ - { - type: "organization" as const, - schema: mockSchema, - data: mockData, - roles: ["owner" as const], - }, - ]; - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - }); - - test("handles workspaceTeam access without minPermission specified", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - }); - - test("refuses workspaceTeam access when minPermission is an unrecognized value", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - // "write" is the feedback-records gateway spelling, not a TTeamPermission ("readWrite"). - // An unrecognized minimum must not admit a read-only member. - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "write" as any, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); - - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - AuthorizationError - ); - }); - - test("refuses workspaceTeam access when the granted permission is an unrecognized value", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - minPermission: "readWrite" as const, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("write" as any); - - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - AuthorizationError - ); - }); - - test("refuses team access when minPermission is an unrecognized value", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "team" as const, - teamId, - minPermission: "manage" as any, - }, - ]; - - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); - - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - AuthorizationError - ); - }); - - test("handles team access without minPermission specified", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "team" as const, - teamId, - }, - ]; - - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("contributor"); - - const result = await checkAuthorizationUpdated({ userId, organizationId, access }); - - expect(result).toBe(true); - }); - - // Omitting minPermission asks for "any grant on this workspace/team", not "no check at all" — an - // unrecognized grant is still refused. Not reachable through the current callers, whose grants come - // from Postgres enums, but the helper must not fail open for one that isn't enum-backed. - test("refuses workspaceTeam access when the granted permission is unrecognized and no minPermission is set", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "workspaceTeam" as const, - workspaceId, - }, - ]; - - vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("write" as any); - - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - AuthorizationError - ); - }); - - test("refuses team access when the granted role is unrecognized and no minPermission is set", async () => { - vi.mocked(getMembershipRole).mockResolvedValue("member"); - - const access = [ - { - type: "team" as const, - teamId, - }, - ]; - - vi.mocked(getTeamRoleByTeamIdUserId).mockResolvedValue("owner" as any); - - await expect(checkAuthorizationUpdated({ userId, organizationId, access })).rejects.toThrow( - AuthorizationError - ); - }); - }); -}); diff --git a/apps/web/lib/utils/action-client/action-client-middleware.ts b/apps/web/lib/utils/action-client/action-client-middleware.ts deleted file mode 100644 index bc18033bd338..000000000000 --- a/apps/web/lib/utils/action-client/action-client-middleware.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { returnValidationErrors } from "next-safe-action"; -import { ZodIssue, z } from "zod"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { type TOrganizationRole } from "@formbricks/types/memberships"; -import { getMembershipRole } from "@/lib/membership/hooks/actions"; -import { getTeamRoleByTeamIdUserId, getWorkspacePermissionByUserId } from "@/modules/ee/teams/lib/roles"; -import { type TTeamRole } from "@/modules/ee/teams/team-list/types/team"; -import { type TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; - -export const formatErrors = (issues: ZodIssue[]): Record => { - return { - ...issues.reduce>((acc, issue) => { - acc[issue.path.join(".")] = { - _errors: [issue.message], - }; - return acc; - }, {}), - }; -}; - -export type TAccess = - | { - type: "organization"; - schema?: z.ZodObject; - data?: z.ZodObject["_output"]; - roles: TOrganizationRole[]; - } - | { - type: "workspaceTeam"; - minPermission?: TTeamPermission; - workspaceId: string; - } - | { - type: "team"; - minPermission?: TTeamRole; - teamId: string; - }; - -const teamPermissionWeight = { - read: 1, - readWrite: 2, - manage: 3, -}; - -const teamRoleWeight = { - contributor: 1, - admin: 2, -}; - -const checkOrganizationAccess = ( - accessItem: TAccess, - role: TOrganizationRole -) => { - if (accessItem.type !== "organization") return false; - if (accessItem.schema) { - const resultSchema = accessItem.schema.strict(); - const parsedResult = resultSchema.safeParse(accessItem.data); - if (!parsedResult.success) { - // @ts-expect-error -- match dynamic next-safe-action types - return returnValidationErrors(resultSchema, formatErrors(parsedResult.error.issues)); - } - } - return accessItem.roles.includes(role); -}; - -/** - * Compares a granted role/permission against the minimum required one. - * - * Both sides are looked up explicitly and an unrecognized value on either side is refused. Comparing - * the weights directly would fail open: an unknown value resolves to `undefined`, `number < undefined` - * is `false`, so the insufficient-permission guard would not fire and every member would be admitted. - * - * `granted` is validated before the no-minimum case, so an unrecognized grant is refused even when the - * caller asks for no minimum. Both current callers read `granted` from a native Postgres enum, so this - * is unreachable today; it is here so the helper stays fail-closed for a future caller whose grant is - * not enum-backed. - */ -const meetsMinimumWeight = ( - weights: Record, - granted: string, - minimum: string | undefined -): boolean => { - const grantedWeight = weights[granted]; - if (grantedWeight === undefined) return false; - - if (minimum === undefined) return true; - - const minimumWeight = weights[minimum]; - if (minimumWeight === undefined) return false; - - return grantedWeight >= minimumWeight; -}; - -/** - * The `workspaceTeam` and `team` variants of `TAccess` carry no schema, so neither depends on `T` — - * hence the concrete `z.ZodRawShape` here rather than threading the generic through these two helpers. - * `checkAuthorizationUpdated` narrows on `type` before calling either, so each receives exactly its - * own variant and `workspaceId` / `teamId` / `minPermission` are checked at compile time. - */ -type TWorkspaceTeamAccess = Extract, { type: "workspaceTeam" }>; -type TTeamAccess = Extract, { type: "team" }>; - -const checkWorkspaceTeamAccess = async (accessItem: TWorkspaceTeamAccess, userId: string) => { - const workspacePermission = await getWorkspacePermissionByUserId(userId, accessItem.workspaceId); - if (!workspacePermission) return false; - return meetsMinimumWeight(teamPermissionWeight, workspacePermission, accessItem.minPermission); -}; - -const checkTeamAccess = async (accessItem: TTeamAccess, userId: string) => { - const teamRole = await getTeamRoleByTeamIdUserId(accessItem.teamId, userId); - if (!teamRole) return false; - return meetsMinimumWeight(teamRoleWeight, teamRole, accessItem.minPermission); -}; - -export const checkAuthorizationUpdated = async ({ - userId, - organizationId, - access, -}: { - userId: string; - organizationId: string; - access: TAccess[]; -}) => { - const role = await getMembershipRole(userId, organizationId); - - for (const accessItem of access) { - if (accessItem.type === "organization") { - const orgResult = checkOrganizationAccess(accessItem, role); - if (orgResult === true) return true; - if (orgResult) return orgResult; // validation error - } - - if (accessItem.type === "workspaceTeam" && (await checkWorkspaceTeamAccess(accessItem, userId))) { - return true; - } - - if (accessItem.type === "team" && (await checkTeamAccess(accessItem, userId))) { - return true; - } - } - - throw new AuthorizationError("Not authorized"); -}; diff --git a/apps/web/lib/utils/action-client/index.ts b/apps/web/lib/utils/action-client/index.ts index e71096ac58c5..d1e31613cd23 100644 --- a/apps/web/lib/utils/action-client/index.ts +++ b/apps/web/lib/utils/action-client/index.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_ERROR_MESSAGE, createSafeActionClient } from "next-safe- import { v4 as uuidv4 } from "uuid"; import { logger } from "@formbricks/logger"; import { AuthenticationError, AuthorizationError, isExpectedError } from "@formbricks/types/errors"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants"; import { getUser } from "@/lib/user/service"; import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; @@ -59,5 +60,5 @@ export const authenticatedActionClient = actionClient.use(async ({ ctx, next }) throw new AuthorizationError("User not found"); } - return next({ ctx: { ...ctx, user } }); + return withAuthorizationSurface("server_action", () => next({ ctx: { ...ctx, user } })); }); diff --git a/apps/web/lib/utils/action-client/types/context.ts b/apps/web/lib/utils/action-client/types/context.ts index 1b50a05db5c0..d3a6a65e0da3 100644 --- a/apps/web/lib/utils/action-client/types/context.ts +++ b/apps/web/lib/utils/action-client/types/context.ts @@ -38,6 +38,7 @@ export type AuditLoggingCtx = { dashboardWidgetId?: string; feedbackDirectoryId?: string; feedbackRecordId?: string; + feedbackSourceId?: string; }; export type ActionClientCtx = { diff --git a/apps/web/lib/workspace/auth.test.ts b/apps/web/lib/workspace/auth.test.ts index 135066f84645..6818c30d7fd7 100644 --- a/apps/web/lib/workspace/auth.test.ts +++ b/apps/web/lib/workspace/auth.test.ts @@ -1,115 +1,125 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { hasUserWorkspaceAccessForAction } from "./auth"; - -const mocks = vi.hoisted(() => ({ - membershipFindFirst: vi.fn(), - workspaceTeamFindMany: vi.fn(), -})); - -vi.mock("@formbricks/database", () => ({ - prisma: { - membership: { - findFirst: mocks.membershipFindFirst, - }, - workspaceTeam: { - findMany: mocks.workspaceTeamFindMany, - }, - }, +import { can } from "@/lib/authorization"; +import { validateInputs } from "../utils/validate"; +import { + canUserNavigateWorkspace, + canUserReadWorkspaceIntegrations, + canUserWriteWorkspaceIntegrations, +} from "./auth"; + +vi.mock("@/lib/authorization", () => ({ + can: vi.fn(), })); vi.mock("../utils/validate", () => ({ validateInputs: vi.fn(), })); -describe("hasUserWorkspaceAccessForAction", () => { +describe("workspace integration authorization", () => { const userId = "00000000-0000-0000-0000-000000000001"; const workspaceId = "00000000-0000-0000-0000-000000000002"; beforeEach(() => { vi.clearAllMocks(); - mocks.workspaceTeamFindMany.mockResolvedValue([]); + vi.mocked(can).mockResolvedValue(true); }); - test("returns false when the user has no organization membership for the workspace", async () => { - mocks.membershipFindFirst.mockResolvedValue(null); - - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "GET")).toBe(false); - expect(mocks.workspaceTeamFindMany).not.toHaveBeenCalled(); + test.each([ + ["read", canUserReadWorkspaceIntegrations, "workspace.read"], + ["write", canUserWriteWorkspaceIntegrations, "workspace.write"], + ] as const)("uses the semantic workspace %s action", async (_name, authorize, action) => { + await expect(authorize(userId, workspaceId)).resolves.toBe(true); + + expect(validateInputs).toHaveBeenCalledWith( + [userId, expect.anything()], + [workspaceId, expect.anything()] + ); + expect(can).toHaveBeenCalledWith({ type: "user", id: userId }, action, { + type: "workspace", + id: workspaceId, + }); }); - test.each(["GET", "POST", "PUT", "PATCH", "DELETE"] as const)( - "returns false for billing role on %s", - async (action) => { - mocks.membershipFindFirst.mockResolvedValue({ role: "billing" }); + test("returns a central authorization denial", async () => { + vi.mocked(can).mockResolvedValue(false); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, action)).toBe(false); - expect(mocks.workspaceTeamFindMany).not.toHaveBeenCalled(); - } - ); + await expect(canUserReadWorkspaceIntegrations(userId, workspaceId)).resolves.toBe(false); + }); - test.each(["owner", "manager"] as const)( - "returns true for %s role on any action without consulting team permissions", - async (role) => { - mocks.membershipFindFirst.mockResolvedValue({ role }); + test("propagates central evaluator failures", async () => { + vi.mocked(can).mockRejectedValue(new Error("database unavailable")); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "DELETE")).toBe(true); - expect(mocks.workspaceTeamFindMany).not.toHaveBeenCalled(); - } - ); + await expect(canUserReadWorkspaceIntegrations(userId, workspaceId)).rejects.toThrow( + "database unavailable" + ); + }); +}); - test("returns false for member role when no team grants workspace access", async () => { - mocks.membershipFindFirst.mockResolvedValue({ role: "member" }); - mocks.workspaceTeamFindMany.mockResolvedValue([]); +describe("canUserNavigateWorkspace", () => { + const userId = "00000000-0000-0000-0000-000000000001"; + const workspace = { + id: "00000000-0000-0000-0000-000000000002", + organizationId: "00000000-0000-0000-0000-000000000003", + } as const; + const actor = { type: "user", id: userId } as const; - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "GET")).toBe(false); + beforeEach(() => { + vi.clearAllMocks(); }); - test("member with read team permission can GET but cannot POST or DELETE", async () => { - mocks.membershipFindFirst.mockResolvedValue({ role: "member" }); - mocks.workspaceTeamFindMany.mockResolvedValue([{ permission: "read" }]); + test("admits anyone who can read the workspace, without asking about billing", async () => { + vi.mocked(can).mockResolvedValue(true); + + await expect(canUserNavigateWorkspace(userId, workspace)).resolves.toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "GET")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "POST")).toBe(false); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "DELETE")).toBe(false); + expect(can).toHaveBeenCalledTimes(2); + expect(can).toHaveBeenLastCalledWith(actor, "workspace.read", { + type: "workspace", + id: workspace.id, + }); }); - test("member with readWrite team permission can GET/POST/PUT/PATCH but cannot DELETE", async () => { - mocks.membershipFindFirst.mockResolvedValue({ role: "member" }); - mocks.workspaceTeamFindMany.mockResolvedValue([{ permission: "readWrite" }]); + test("admits the billing role, which cannot read the workspace", async () => { + vi.mocked(can).mockImplementation( + async (_actor, action) => action === "organization.read" || action === "organization.manage_billing" + ); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "GET")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "POST")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "PUT")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "PATCH")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "DELETE")).toBe(false); + await expect(canUserNavigateWorkspace(userId, workspace)).resolves.toBe(true); + + expect(can).toHaveBeenLastCalledWith(actor, "organization.manage_billing", { + type: "organization", + id: workspace.organizationId, + }); }); - test("member with manage team permission can perform any action", async () => { - mocks.membershipFindFirst.mockResolvedValue({ role: "member" }); - mocks.workspaceTeamFindMany.mockResolvedValue([{ permission: "manage" }]); + test("refuses an organization member with no grant for this workspace", async () => { + vi.mocked(can).mockImplementation(async (_actor, action) => action === "organization.read"); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "GET")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "POST")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "DELETE")).toBe(true); - }); + await expect(canUserNavigateWorkspace(userId, workspace)).resolves.toBe(false); - test("member in multiple teams uses the highest permission across them", async () => { - mocks.membershipFindFirst.mockResolvedValue({ role: "member" }); - mocks.workspaceTeamFindMany.mockResolvedValue([ - { permission: "read" }, - { permission: "manage" }, - { permission: "readWrite" }, - ]); + expect(can).toHaveBeenCalledTimes(3); + }); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "DELETE")).toBe(true); + // Pins the composition, not a live gap: today's legacy evaluator already refuses a non-member + // inside `workspace.read`, so this case is unreachable through it. It matters for the SpiceDB + // definition, where `reader_team` is a projected edge carrying no membership requirement — see + // the note on canUserNavigateWorkspace. The real-database matrix in + // navigation-access.integration.test.ts is what proves the legacy behaviour is unchanged. + test("refuses a non-member even when a team grant would satisfy workspace.read", async () => { + vi.mocked(can).mockImplementation(async (_actor, action) => action === "workspace.read"); + + await expect(canUserNavigateWorkspace(userId, workspace)).resolves.toBe(false); + + expect(can).toHaveBeenCalledTimes(1); + expect(can).toHaveBeenCalledWith(actor, "organization.read", { + type: "organization", + id: workspace.organizationId, + }); }); - test("member in multiple teams none of which grant sufficient permission is denied", async () => { - mocks.membershipFindFirst.mockResolvedValue({ role: "member" }); - mocks.workspaceTeamFindMany.mockResolvedValue([{ permission: "read" }, { permission: "readWrite" }]); + test("propagates central evaluator failures instead of denying", async () => { + vi.mocked(can).mockRejectedValue(new Error("database unavailable")); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "GET")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "POST")).toBe(true); - expect(await hasUserWorkspaceAccessForAction(userId, workspaceId, "DELETE")).toBe(false); + await expect(canUserNavigateWorkspace(userId, workspace)).rejects.toThrow("database unavailable"); }); }); diff --git a/apps/web/lib/workspace/auth.ts b/apps/web/lib/workspace/auth.ts index 7aab4a46de2a..5855822422bc 100644 --- a/apps/web/lib/workspace/auth.ts +++ b/apps/web/lib/workspace/auth.ts @@ -1,170 +1,83 @@ -import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; +import "server-only"; import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; import { validateInputs } from "../utils/validate"; -export type WorkspaceAction = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - -type WorkspacePermissionLevel = "read" | "readWrite" | "manage"; - -const ACTION_REQUIRED_PERMISSION: Record = { - GET: "read", - POST: "readWrite", - PUT: "readWrite", - PATCH: "readWrite", - DELETE: "manage", -}; - -const PERMISSION_RANK: Record = { - read: 0, - readWrite: 1, - manage: 2, -}; - -const teamPermissionSatisfies = ( - teamPermission: WorkspacePermissionLevel, - required: WorkspacePermissionLevel -): boolean => PERMISSION_RANK[teamPermission] >= PERMISSION_RANK[required]; - -/** - * Action-aware workspace access check for session-authenticated users. - * - * - Billing role: never authorized — billing users are excluded from product data surfaces. - * - Owner / manager: always authorized. - * - Member: authorized only when a WorkspaceTeam grants a permission level that - * meets or exceeds the action's required level (read for GET, readWrite for - * POST/PUT/PATCH, manage for DELETE). - * - * The broader {@link hasUserWorkspaceAccess} helper does not gate by action and - * should not be used for routes that mutate or expose workspace data. - */ -export const hasUserWorkspaceAccessForAction = async ( - userId: string, - workspaceId: string, - action: WorkspaceAction -): Promise => { - validateInputs([userId, ZId], [workspaceId, ZId]); - - try { - const orgMembership = await prisma.membership.findFirst({ - where: { - userId, - organization: { - workspaces: { - some: { id: workspaceId }, - }, - }, - }, - }); - - if (!orgMembership) return false; - if (orgMembership.role === "billing") return false; - if (orgMembership.role === "owner" || orgMembership.role === "manager") return true; - - const workspaceTeams = await prisma.workspaceTeam.findMany({ - where: { - workspaceId, - team: { - teamUsers: { - some: { userId }, - }, - }, - }, - select: { permission: true }, - }); - - if (workspaceTeams.length === 0) return false; - - // A user can belong to multiple teams that each grant access to the same - // workspace at different permission levels (the WorkspaceTeam unique key - // is [workspaceId, teamId], not [workspaceId, userId]). Pick the highest - // level so a `read` team membership doesn't shadow a `manage` one. - const highestPermission = workspaceTeams.reduce( - (max, wt) => (PERMISSION_RANK[wt.permission] > PERMISSION_RANK[max] ? wt.permission : max), - workspaceTeams[0].permission - ); - - return teamPermissionSatisfies(highestPermission, ACTION_REQUIRED_PERMISSION[action]); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } -}; - /** * Authorization for the integration OAuth routes (Notion / Airtable / Slack / Google Sheets). * * These are credential *mutations* delivered over GET: completing the flow writes the workspace's * third-party credentials, after which survey responses are forwarded to whichever account was * connected. They must therefore be gated on readWrite, not on mere workspace access — - * {@link hasUserWorkspaceAccess} returns true for the `billing` role (which is otherwise excluded from - * all product data) and for a `read`-only team member, either of whom could otherwise bind their own - * account as the workspace integration and start receiving another team's responses, or overwrite the - * credentials an admin configured. + * {@link canUserNavigateWorkspace} admits the `billing` role (which is otherwise excluded from all + * product data) and `workspace.read` admits a `read`-only team member, either of whom could otherwise + * bind their own account as the workspace integration and start receiving another team's responses, or + * overwrite the credentials an admin configured. */ export const canUserWriteWorkspaceIntegrations = async ( userId: string, workspaceId: string -): Promise => hasUserWorkspaceAccessForAction(userId, workspaceId, "POST"); +): Promise => { + validateInputs([userId, ZId], [workspaceId, ZId]); + return can({ type: "user", id: userId }, "workspace.write", { type: "workspace", id: workspaceId }); +}; /** * Read-only counterpart for routes that only surface a connected integration's data. Unlike - * {@link hasUserWorkspaceAccess} this still excludes the `billing` role. + * {@link canUserNavigateWorkspace} this still excludes the `billing` role. */ export const canUserReadWorkspaceIntegrations = async ( userId: string, workspaceId: string -): Promise => hasUserWorkspaceAccessForAction(userId, workspaceId, "GET"); - -export const hasUserWorkspaceAccess = async (userId: string, workspaceId: string) => { +): Promise => { validateInputs([userId, ZId], [workspaceId, ZId]); + return can({ type: "user", id: userId }, "workspace.read", { type: "workspace", id: workspaceId }); +}; - try { - const orgMembership = await prisma.membership.findFirst({ - where: { - userId, - organization: { - workspaces: { - some: { - id: workspaceId, - }, - }, - }, - }, - }); - - if (!orgMembership) return false; +/** + * Whether a user may land on a workspace URL at all — the navigation/layout gate, + * as opposed to a gate on any of the workspace's data. + * + * Reaching a workspace is deliberately broader than reading it: the `billing` role + * is excluded from all product data but must still be able to follow a workspace + * link, because that is how it arrives at the billing screens the layout redirects + * it to. Expressed in the central vocabulary that is exactly: + * + * workspace.read OR organization.manage_billing + * + * `workspace.read` covers owners and managers (through `organization#manage`) and + * any team member holding a `WorkspaceTeam` grant. `organization.manage_billing` + * is `owner + manager + billing`, so the second check only ever adds the billing + * role — an organization `member` with no grant for this workspace is in neither, + * and is still refused. It is ordered second so the common case costs one check. + * + * Membership in the owning organization is asked for first so that this + * composition does not depend on a projected team edge carrying current organization membership. + * The SpiceDB definition is `reader + reader_team + + * write`, where `reader_team` is a projected `team#member` edge that carries no + * membership requirement of its own. `TeamUser` has no foreign key to `Membership` + * (it cascades from `Team` and `User` only), so "removed from the organization" and + * "still has a team row" are separable states in the schema; `deleteMembership` + * closes both in one serializable transaction and reconciles the projection, so a + * stale edge should not exist. This states the precondition rather than inheriting + * it, which is what keeps the two evaluators answering alike here. + * + * Callers pass the resolved workspace rather than an id: every one of them has + * already loaded it, and requiring the owning organization here keeps this helper + * from hiding a lookup behind an authorization decision. + */ +export const canUserNavigateWorkspace = async ( + userId: string, + workspace: Readonly<{ id: string; organizationId: string }> +): Promise => { + validateInputs([userId, ZId], [workspace.id, ZId], [workspace.organizationId, ZId]); - if ( - orgMembership.role === "owner" || - orgMembership.role === "manager" || - orgMembership.role === "billing" - ) - return true; + const actor = { type: "user", id: userId } as const; + const organization = { type: "organization", id: workspace.organizationId } as const; - const teamMembership = await prisma.teamUser.findFirst({ - where: { - userId, - team: { - workspaceTeams: { - some: { - workspaceId, - }, - }, - }, - }, - }); + if (!(await can(actor, "organization.read", organization))) return false; - if (teamMembership) return true; + if (await can(actor, "workspace.read", { type: "workspace", id: workspace.id })) return true; - return false; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } + return can(actor, "organization.manage_billing", organization); }; diff --git a/apps/web/lib/workspace/navigation-access.integration.test.ts b/apps/web/lib/workspace/navigation-access.integration.test.ts new file mode 100644 index 000000000000..c351f959eff1 --- /dev/null +++ b/apps/web/lib/workspace/navigation-access.integration.test.ts @@ -0,0 +1,158 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import type { TOrganizationRole } from "@formbricks/types/memberships"; +import { synchronizeAuthzedIntegrationFixture } from "@/integration/authzed"; +import { resetDb } from "@/integration/reset-db"; +import { canUserNavigateWorkspace } from "@/lib/workspace/auth"; + +/** + * ENG-1737: proof that replacing `hasUserWorkspaceAccess` did not move the boundary. + * + * The claim the migration rests on is an equivalence over the whole role/grant space, and a mocked + * `can()` cannot test it — mocking the decision is assuming the answer. So this drives the real + * `canUserNavigateWorkspace` (real `can()`, real SpiceDB evaluator, real Prisma) against a projected + * PostgreSQL fixture, and compares every case to the deleted helper's own logic, replayed below + * against the same rows. + * + * `expectedByOldHelper` is not a restatement of what the new code does; it is a transcription of the + * query the old one ran (`git show origin/epic/authzed:apps/web/lib/workspace/auth.ts`): + * + * 1. a Membership for this user in the organization that owns the workspace, else false + * 2. role owner | manager | billing → true + * 3. otherwise a TeamUser row on a team holding any WorkspaceTeam grant for this workspace + */ +const replayDeletedHelper = async (userId: string, workspaceId: string): Promise => { + const orgMembership = await prisma.membership.findFirst({ + where: { userId, organization: { workspaces: { some: { id: workspaceId } } } }, + }); + + if (!orgMembership) return false; + if (["owner", "manager", "billing"].includes(orgMembership.role)) return true; + + const teamMembership = await prisma.teamUser.findFirst({ + where: { userId, team: { workspaceTeams: { some: { workspaceId } } } }, + }); + + return teamMembership !== null; +}; + +type TCase = Readonly<{ expected: boolean; name: string; userId: string }>; + +const scenario: { + cases: TCase[]; + otherWorkspaceId: string; + workspace: { id: string; organizationId: string }; +} = { cases: [], otherWorkspaceId: "", workspace: { id: "", organizationId: "" } }; + +const createMember = async ( + email: string, + organizationId: string, + role: TOrganizationRole | null +): Promise => { + const user = await prisma.user.create({ data: { name: email, email } }); + if (role) { + await prisma.membership.create({ data: { userId: user.id, organizationId, role, accepted: true } }); + } + return user.id; +}; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ data: { name: "Nav Org" } }); + const otherOrganization = await prisma.organization.create({ data: { name: "Other Org" } }); + + const workspace = await prisma.workspace.create({ + data: { name: "Nav Workspace", organizationId: organization.id }, + }); + const otherWorkspace = await prisma.workspace.create({ + data: { name: "Ungranted Workspace", organizationId: organization.id }, + }); + + // A team holding a read grant on `workspace` only. + const grantedTeam = await prisma.team.create({ + data: { name: "Granted", organizationId: organization.id }, + }); + await prisma.workspaceTeam.create({ + data: { teamId: grantedTeam.id, workspaceId: workspace.id, permission: "read" }, + }); + // A team with no workspace grant at all. + const ungrantedTeam = await prisma.team.create({ + data: { name: "Ungranted", organizationId: organization.id }, + }); + + const owner = await createMember("owner@nav.test", organization.id, "owner"); + const manager = await createMember("manager@nav.test", organization.id, "manager"); + const billing = await createMember("billing@nav.test", organization.id, "billing"); + const grantedMember = await createMember("granted@nav.test", organization.id, "member"); + const ungrantedMember = await createMember("ungranted@nav.test", organization.id, "member"); + const teamlessMember = await createMember("teamless@nav.test", organization.id, "member"); + const stranger = await createMember("stranger@nav.test", organization.id, null); + const otherOrgOwner = await createMember("other-owner@nav.test", otherOrganization.id, "owner"); + // A team row that outlived its membership — `TeamUser` cascades from `Team` and `User`, never + // from `Membership`. Both implementations refuse it, and for the same reason: the legacy + // evaluator behind `workspace.read` opens with the same membership query the deleted helper did. + // So this row does NOT exercise the explicit precondition in canUserNavigateWorkspace (the matrix + // passes with that line removed); it pins that the state is refused at all. + const removedMember = await createMember("removed@nav.test", organization.id, null); + + await prisma.teamUser.createMany({ + data: [ + { teamId: grantedTeam.id, userId: grantedMember, role: "contributor" }, + { teamId: ungrantedTeam.id, userId: ungrantedMember, role: "contributor" }, + { teamId: grantedTeam.id, userId: removedMember, role: "contributor" }, + ], + }); + + scenario.workspace = { id: workspace.id, organizationId: organization.id }; + scenario.otherWorkspaceId = otherWorkspace.id; + scenario.cases = [ + { expected: true, name: "owner, no team grant", userId: owner }, + { expected: true, name: "manager, no team grant", userId: manager }, + { expected: true, name: "billing, no team grant", userId: billing }, + { expected: true, name: "member with a team grant on this workspace", userId: grantedMember }, + { expected: false, name: "member whose team holds no grant here", userId: ungrantedMember }, + { expected: false, name: "member on no team at all", userId: teamlessMember }, + { expected: false, name: "user with no membership anywhere", userId: stranger }, + { expected: false, name: "owner of a different organization", userId: otherOrgOwner }, + { expected: false, name: "removed member whose team row survived", userId: removedMember }, + ]; + await synchronizeAuthzedIntegrationFixture(); +}, 120_000); + +describe("canUserNavigateWorkspace against a real database", () => { + test("the role/grant matrix is decided the same way the deleted helper decided it", async () => { + const rows = await Promise.all( + scenario.cases.map(async (testCase) => ({ + name: testCase.name, + expected: testCase.expected, + old: await replayDeletedHelper(testCase.userId, scenario.workspace.id), + current: await canUserNavigateWorkspace(testCase.userId, scenario.workspace), + })) + ); + + // One assertion over the whole matrix so a failure prints every disagreeing row at once. + expect(rows).toEqual( + scenario.cases.map((testCase) => ({ + name: testCase.name, + expected: testCase.expected, + old: testCase.expected, + current: testCase.expected, + })) + ); + }); + + test("a grant on one workspace does not carry to a sibling workspace", async () => { + const grantedMember = scenario.cases.find((testCase) => + testCase.name.startsWith("member with a team grant") + ); + + const sibling = { + id: scenario.otherWorkspaceId, + organizationId: scenario.workspace.organizationId, + }; + + expect(await canUserNavigateWorkspace(grantedMember!.userId, sibling)).toBe(false); + expect(await replayDeletedHelper(grantedMember!.userId, sibling.id)).toBe(false); + }); +}); diff --git a/apps/web/lib/workspace/service.test.ts b/apps/web/lib/workspace/service.test.ts index 7d26d5528175..20a013df368e 100644 --- a/apps/web/lib/workspace/service.test.ts +++ b/apps/web/lib/workspace/service.test.ts @@ -1,10 +1,15 @@ import { createId } from "@paralleldrive/cuid2"; import { afterEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { OrganizationRole, Prisma, WidgetPlacement, Workspace } from "@formbricks/database/prisma"; +import { Prisma, WidgetPlacement, Workspace } from "@formbricks/database/prisma"; import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import { + lookupAuthorizedOrganizationIds, + lookupAuthorizedWorkspaceIds, +} from "@/lib/authorization/resource-list"; import { ITEMS_PER_PAGE } from "../constants"; import { + getOrganizationScopedWorkspacesByIdsForUser, getUserWorkspaces, getUserWorkspacesByOrganizationIds, getWorkspace, @@ -12,6 +17,7 @@ import { getWorkspaceMemberEmails, getWorkspaceMembers, getWorkspaces, + getWorkspacesByIds, } from "./service"; vi.mock("@formbricks/database", () => ({ @@ -27,6 +33,10 @@ vi.mock("@formbricks/database", () => ({ }, }, })); +vi.mock("@/lib/authorization/resource-list", () => ({ + lookupAuthorizedOrganizationIds: vi.fn(), + lookupAuthorizedWorkspaceIds: vi.fn(), +})); describe("Workspace Service", () => { afterEach(() => { @@ -144,12 +154,8 @@ describe("Workspace Service", () => { }, ]; - vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - userId, - organizationId, - role: OrganizationRole.owner, - accepted: true, - }); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue([organizationId]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(mockWorkspaces.map(({ id }) => id)); vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as unknown as Workspace[]); @@ -158,6 +164,7 @@ describe("Workspace Service", () => { expect(result).toEqual(mockWorkspaces); expect(prisma.workspace.findMany).toHaveBeenCalledWith({ where: { + id: { in: mockWorkspaces.map(({ id }) => id) }, organizationId, }, select: expect.any(Object), @@ -196,12 +203,8 @@ describe("Workspace Service", () => { }, ]; - vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - userId, - organizationId, - role: OrganizationRole.member, - accepted: true, - }); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue([organizationId]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(mockWorkspaces.map(({ id }) => id)); vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as unknown as Workspace[]); @@ -210,18 +213,8 @@ describe("Workspace Service", () => { expect(result).toEqual(mockWorkspaces); expect(prisma.workspace.findMany).toHaveBeenCalledWith({ where: { + id: { in: mockWorkspaces.map(({ id }) => id) }, organizationId, - workspaceTeams: { - some: { - team: { - teamUsers: { - some: { - userId, - }, - }, - }, - }, - }, }, select: expect.any(Object), take: undefined, @@ -229,55 +222,40 @@ describe("Workspace Service", () => { }); }); - test("getUserWorkspacesByOrganizationIds team-scopes non-owner/manager roles (owner/manager get all)", async () => { + test("getUserWorkspacesByOrganizationIds resolves only authoritative workspace ids in the organizations", async () => { const userId = createId(); const orgManager = createId(); const orgBilling = createId(); - vi.mocked(prisma.membership.findMany).mockResolvedValue([ - { userId, organizationId: orgManager, role: OrganizationRole.manager, accepted: true }, - { userId, organizationId: orgBilling, role: OrganizationRole.billing, accepted: true }, - ]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(["workspace-1"]); vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); await getUserWorkspacesByOrganizationIds([orgManager, orgBilling], userId); - const teamScope = { some: { team: { teamUsers: { some: { userId } } } } }; expect(prisma.workspace.findMany).toHaveBeenCalledWith({ where: { - OR: [ - // manager: all of the org's workspaces (no team filter) - { organizationId: orgManager }, - // billing: team-scoped only (regression: previously unscoped → every workspace) - { organizationId: orgBilling, workspaceTeams: teamScope }, - ], + id: { in: ["workspace-1"] }, + organizationId: { in: [orgManager, orgBilling] }, }, select: { id: true }, }); }); - test("getUserWorkspaces should team-scope a billing user (not return every workspace)", async () => { + test("getUserWorkspaces does not widen a billing user's empty SpiceDB workspace list", async () => { const userId = createId(); const organizationId = createId(); - vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - userId, - organizationId, - role: OrganizationRole.billing, - accepted: true, - }); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue([organizationId]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([]); vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); await getUserWorkspaces(userId, organizationId); - // Billing is not owner/manager, so it must be scoped to team-accessible workspaces — never the - // whole org (regression: `role === "member"` previously leaked all workspaces to billing users). + // SpiceDB's workspace.read excludes billing, so no role-name SQL branch can accidentally widen it. expect(prisma.workspace.findMany).toHaveBeenCalledWith({ where: { + id: { in: [] }, organizationId, - workspaceTeams: { - some: { team: { teamUsers: { some: { userId } } } }, - }, }, select: expect.any(Object), take: undefined, @@ -289,7 +267,8 @@ describe("Workspace Service", () => { const userId = createId(); const organizationId = createId(); - vi.mocked(prisma.membership.findFirst).mockResolvedValue(null); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue([]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([]); await expect(getUserWorkspaces(userId, organizationId)).rejects.toThrow(ValidationError); }); @@ -324,12 +303,8 @@ describe("Workspace Service", () => { }, ]; - vi.mocked(prisma.membership.findFirst).mockResolvedValue({ - userId, - organizationId, - role: OrganizationRole.owner, - accepted: true, - }); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValue([organizationId]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(mockWorkspaces.map(({ id }) => id)); vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as unknown as Workspace[]); @@ -339,6 +314,7 @@ describe("Workspace Service", () => { expect(result).toEqual(mockWorkspaces); expect(prisma.workspace.findMany).toHaveBeenCalledWith({ where: { + id: { in: mockWorkspaces.map(({ id }) => id) }, organizationId, }, select: expect.any(Object), @@ -471,6 +447,50 @@ describe("Workspace Service", () => { await expect(getWorkspaces(organizationId)).rejects.toThrow(DatabaseError); }); + test("getWorkspacesByIds scopes the workspace read to the organization", async () => { + const organizationId = createId(); + const workspaceIds = [createId(), createId()]; + const mockWorkspaces = workspaceIds.map((id) => ({ id, organizationId })); + vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as unknown as Workspace[]); + + await expect(getWorkspacesByIds(organizationId, workspaceIds)).resolves.toEqual(mockWorkspaces); + expect(prisma.workspace.findMany).toHaveBeenCalledExactlyOnceWith({ + where: { + id: { in: workspaceIds }, + organizationId, + }, + select: expect.any(Object), + }); + }); + + test("getWorkspacesByIds skips the database for an empty workspace list", async () => { + await expect(getWorkspacesByIds(createId(), [])).resolves.toEqual([]); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); + }); + + test("getOrganizationScopedWorkspacesByIdsForUser verifies lookup results against current tenant membership", async () => { + const userId = createId(); + const workspaceIds = [createId(), createId()]; + const mockWorkspaces = workspaceIds.map((id) => ({ id, organizationId: createId() })); + vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as unknown as Workspace[]); + + await expect(getOrganizationScopedWorkspacesByIdsForUser(userId, workspaceIds)).resolves.toEqual( + mockWorkspaces + ); + expect(prisma.workspace.findMany).toHaveBeenCalledExactlyOnceWith({ + where: { + id: { in: workspaceIds }, + organization: { memberships: { some: { userId } } }, + }, + select: expect.any(Object), + }); + }); + + test("getOrganizationScopedWorkspacesByIdsForUser skips PostgreSQL for an empty authoritative list", async () => { + await expect(getOrganizationScopedWorkspacesByIdsForUser(createId(), [])).resolves.toEqual([]); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); + }); + describe("getWorkspaceLegacyStoragePrefixes", () => { test("returns both the workspace id and its legacyEnvironmentId when set", async () => { const workspaceId = createId(); @@ -524,7 +544,7 @@ describe("Workspace Service", () => { test("selects the members who can access the workspace: org owner/manager, or a team linked to it", async () => { // The filter is the behavior here — it decides who may receive a workspace's response data, so - // it is asserted directly. It mirrors `checkAuthorizationUpdated`'s workspace access: an + // it is asserted directly. It mirrors the central `workspace.read` permission: an // owner/manager reaches every workspace in the org, everyone else only through a linked team // (any `WorkspaceTeam` permission, since `read` already grants access). The organization comes // from the workspace itself, never from a caller-supplied id. diff --git a/apps/web/lib/workspace/service.ts b/apps/web/lib/workspace/service.ts index 7bb5fdd5bd81..dea604579d63 100644 --- a/apps/web/lib/workspace/service.ts +++ b/apps/web/lib/workspace/service.ts @@ -5,6 +5,10 @@ import { Prisma } from "@formbricks/database/prisma"; import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import type { TWorkspace } from "@formbricks/types/workspace"; +import { + lookupAuthorizedOrganizationIds, + lookupAuthorizedWorkspaceIds, +} from "@/lib/authorization/resource-list"; import { ITEMS_PER_PAGE } from "../constants"; import { normalizeEmailForComparison } from "../utils/email"; import { validateInputs } from "../utils/validate"; @@ -34,44 +38,21 @@ export const getUserWorkspaces = reactCache( async (userId: string, organizationId: string, page?: number): Promise => { validateInputs([userId, ZString], [organizationId, ZId], [page, ZOptionalNumber]); - const orgMembership = await prisma.membership.findFirst({ - where: { - userId, - organizationId, - }, - }); + const actor = { type: "user", id: userId } as const; + const [authorizedOrganizationIds, authorizedWorkspaceIds] = await Promise.all([ + lookupAuthorizedOrganizationIds(actor), + lookupAuthorizedWorkspaceIds(actor), + ]); - if (!orgMembership) { + if (!authorizedOrganizationIds.includes(organizationId)) { throw new ValidationError("User is not a member of this organization"); } - let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; - - // Only org owners/managers get every workspace. Every other role (member, billing, …) is scoped to - // the workspaces whose teams they belong to — mirroring the per-workspace v3 authorization - // (`requireV3WorkspaceAccess`: org owner/manager OR workspace-team membership). Special-casing only - // `member` here previously leaked all workspaces to `billing` members, who cannot access them. - if (orgMembership.role !== "owner" && orgMembership.role !== "manager") { - workspaceWhereClause = { - workspaceTeams: { - some: { - team: { - teamUsers: { - some: { - userId, - }, - }, - }, - }, - }, - }; - } - try { const workspaces = await prisma.workspace.findMany({ where: { + id: { in: [...authorizedWorkspaceIds] }, organizationId, - ...workspaceWhereClause, }, select: selectWorkspace, take: page ? ITEMS_PER_PAGE : undefined, @@ -112,6 +93,64 @@ export const getWorkspaces = reactCache( } ); +/** + * Return only the requested workspaces that belong to the supplied organization. + * + * Authorization-sensitive callers must scope the database read itself instead of fetching by ID and + * discarding foreign-organization rows afterwards. + */ +export const getWorkspacesByIds = reactCache( + async (organizationId: string, workspaceIds: string[]): Promise => { + validateInputs([organizationId, ZId], [workspaceIds, ZId.array()]); + + if (workspaceIds.length === 0) return []; + + try { + return await prisma.workspace.findMany({ + where: { + id: { in: workspaceIds }, + organizationId, + }, + select: selectWorkspace, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + +/** + * Resolve authoritative workspace lookup results for a user through their current organization + * memberships. The ID allowlist comes from SpiceDB; this query only verifies existence and tenant + * membership before returning application data, and never re-evaluates roles or team grants. + */ +export const getOrganizationScopedWorkspacesByIdsForUser = reactCache( + async (userId: string, workspaceIds: string[]): Promise => { + validateInputs([userId, ZId], [workspaceIds, ZId.array()]); + if (workspaceIds.length === 0) return []; + + try { + return await prisma.workspace.findMany({ + where: { + id: { in: workspaceIds }, + organization: { memberships: { some: { userId } } }, + }, + select: selectWorkspace, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + } +); + export const getWorkspace = reactCache(async (workspaceId: string): Promise => { let workspacePrisma; try { @@ -170,8 +209,8 @@ export interface TWorkspaceMember { * recipient picker offers options from, so the authoring UI cannot offer an address that the * enable-time gate and the runner backstop would then reject (ENG-2186). * - * "Can access" mirrors the authorization the real request path enforces (`checkAuthorizationUpdated` - * via `requireSessionWorkspaceAccess`): an organization owner/manager reaches every workspace in the + * "Can access" mirrors the central `workspace.read` authorization enforced by + * `requireSessionWorkspaceAccess`: an organization owner/manager reaches every workspace in the * organization, and every other role reaches a workspace only through a team linked to it — at any * `WorkspaceTeam` permission, since `read` already grants access. * @@ -245,49 +284,13 @@ export const getUserWorkspacesByOrganizationIds = reactCache( return []; } - const memberships = await prisma.membership.findMany({ - where: { - userId, - organizationId: { - in: organizationIds, - }, - }, - }); - - if (memberships.length === 0) { - return []; - } - - const whereConditions: Prisma.WorkspaceWhereInput[] = memberships.map((membership) => { - let workspaceWhereClause: Prisma.WorkspaceWhereInput = { - organizationId: membership.organizationId, - }; - - // Same scoping as getUserWorkspaces: only owner/manager see all of an org's workspaces; every - // other role (member, billing, …) is limited to their team-scoped workspaces. - if (membership.role !== "owner" && membership.role !== "manager") { - workspaceWhereClause = { - ...workspaceWhereClause, - workspaceTeams: { - some: { - team: { - teamUsers: { - some: { - userId, - }, - }, - }, - }, - }, - }; - } - - return workspaceWhereClause; - }); + const workspaceIds = await lookupAuthorizedWorkspaceIds({ type: "user", id: userId }); + if (workspaceIds.length === 0) return []; const workspaces = await prisma.workspace.findMany({ where: { - OR: whereConditions, + id: { in: [...workspaceIds] }, + organizationId: { in: organizationIds }, }, select: { id: true }, }); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index e621f8490e48..dbf68cadc3db 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Bereits im Dashboard", "and_filter_logic": "UND", "apply_changes": "Änderungen übernehmen", + "area_display": "Anzeigen als", + "area_display_filled": "Gefüllte Fläche", + "area_display_line": "Linie", "bar_direction": "Balkenrichtung", "chart": "Diagramm", "chart_added_to_dashboard": "Diagramm zum Dashboard hinzugefügt!", @@ -1663,10 +1666,9 @@ "chart_preview": "Diagrammvorschau", "chart_render_error": "Beim Rendern dieses Diagramms ist etwas schiefgelaufen.", "chart_saved_successfully": "Diagramm erfolgreich gespeichert!", - "chart_type_area": "Flächendiagramm", + "chart_type_area": "Flächen- / Liniendiagramm", "chart_type_bar": "Balkendiagramm", "chart_type_big_number": "Große Zahl", - "chart_type_line": "Liniendiagramm", "chart_type_not_supported": "Diagrammtyp \"{chartType}\" wird noch nicht unterstützt", "chart_type_pie": "Kreisdiagramm", "chart_updated_successfully": "Diagramm erfolgreich aktualisiert!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Diagramm konnte nicht gespeichert werden", "field": "Feld", + "field_description_count": "Gesamtanzahl der Feedback-Einträge", + "field_description_unique_respondents": "Eindeutig identifizierte Personen, die Feedback gegeben haben, dedupliziert nach Person – ein Befragter, der 3 Fragen beantwortet, zählt einmal. Anonymes Feedback (ohne identifizierten Befragten) wird hier nicht gezählt, auch wenn es als Feedback-Datensatz zählt.", + "field_description_unique_responses": "Einzelne Umfrage-Einsendungen, dedupliziert nach Einsendung — eine Person, die zweimal antwortet, zählt zweimal", "field_description_value_option": "Empfohlen für Einfach- und Mehrfachauswahl-Antworten: Die stabile Options-ID hält eine Option über Sprachen hinweg, nach einer Bearbeitung des Labels und für Freitext-'Sonstige'-Antworten in einem Bucket. Diagramme zeigen das Label der Option, nicht die ID.", "field_description_value_text": "Textantwortwert (offener Text oder das Label einer Multiple-Choice-/kategorialen Antwort). Gruppiert nach dem exakten Text, sodass ein übersetztes Label, ein bearbeitetes Label oder eine Freitext-'Sonstige'-Antwort jeweils einen eigenen Bucket bildet – bei Auswahlfragen bevorzuge Wert (Option). Kombiniere dies mit einem fieldType-Filter, um die Typen konsistent zu halten.", "field_label_anger_count": "Emotion: Ärger", "field_label_ces_average": "CES: Durchschnitt", - "field_label_ces_count": "CES: Antworten", + "field_label_ces_count": "CES: Datensätze", "field_label_collected_at": "Erfasst am", - "field_label_count": "Antworten", + "field_label_count": "Feedback-Einträge", "field_label_created_at": "Erstellt am", "field_label_csat_average": "CSAT: Durchschnitt", - "field_label_csat_count": "CSAT: Antworten", + "field_label_csat_count": "CSAT: Datensätze", "field_label_csat_dissatisfied_count": "CSAT: Unzufrieden", "field_label_csat_neutral_count": "CSAT: Neutral", "field_label_csat_satisfied_count": "CSAT: Zufrieden", @@ -1752,7 +1757,7 @@ "field_label_question": "Frage", "field_label_question_group": "Fragengruppe", "field_label_rating_average": "Bewertung: Durchschnitt", - "field_label_rating_count": "Bewertung: Antworten", + "field_label_rating_count": "Bewertung: Datensätze", "field_label_response_id": "Antwort-ID", "field_label_sadness_count": "Emotion: Traurigkeit", "field_label_sentiment": "Stimmung", @@ -1849,7 +1854,9 @@ "start_date": "Startdatum", "time_dimension": "Zeitdimension", "time_dimension_title": "Zeitbasierte Gruppierung hinzufügen", + "time_dimension_title_range_only": "Datumsbereichsfilter hinzufügen", "time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.", + "time_dimension_toggle_description_range_only": "Begrenze dieses Diagramm auf einen Datumsbereich, ohne nach Zeit zu gruppieren.", "vertical_bars": "Vertikale Balken" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Befragten-Identifikation", "comparison_row_responses": "Antworten / Monat", "comparison_row_single_use_links": "Einmalige Links", - "comparison_row_spam": "Spam-Schutz (reCAPTCHA)", "comparison_row_teams_roles": "Teams & Zugriffsrollen", "comparison_row_topic_labeling": "Themen- & Unterthemen-Kennzeichnung (KI)", "comparison_row_two_factor_auth": "Zwei-Faktor-Authentifizierung", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Kontingent-Verwaltung", "plan_scale_feature_rbac": "Teams & Zugriffsrollen", "plan_scale_feature_responses": "5.000 Antworten / Monat mit dynamischer Preisgestaltung", - "plan_scale_feature_security": "2FA & Spam-Schutz", + "plan_scale_feature_security": "Zwei-Faktor-Authentifizierung (2FA)", "plan_scale_feature_semantic_analysis": "Semantische Analyse (KI)", "plan_scale_feature_workflow_runs": "1.000 Workflow-Ausführungen / Monat mit dynamischer Preisgestaltung", "plan_scale_feature_workspaces": "5 Workspaces", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 98974c44d83f..bd40fd7d34c7 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Already on dashboard", "and_filter_logic": "AND", "apply_changes": "Apply Changes", + "area_display": "Display as", + "area_display_filled": "Filled area", + "area_display_line": "Line", "bar_direction": "Bar direction", "chart": "Chart", "chart_added_to_dashboard": "Chart added to dashboard!", @@ -1663,10 +1666,9 @@ "chart_preview": "Chart Preview", "chart_render_error": "Something went wrong while rendering this chart.", "chart_saved_successfully": "Chart saved successfully!", - "chart_type_area": "Area Chart", + "chart_type_area": "Area / Line Chart", "chart_type_bar": "Bar Chart", "chart_type_big_number": "Big Number", - "chart_type_line": "Line Chart", "chart_type_not_supported": "Chart type \"{chartType}\" not yet supported", "chart_type_pie": "Pie Chart", "chart_updated_successfully": "Chart updated successfully!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Failed to save chart", "field": "Field", + "field_description_count": "Total number of feedback records", + "field_description_unique_respondents": "Unique identified people who gave feedback, deduplicated by person — one respondent answering 3 questions counts once. Anonymous feedback (no identified respondent) isn't counted here, even though it counts as a Feedback Record.", + "field_description_unique_responses": "Unique survey submissions, deduplicated by submission — one respondent submitting twice counts twice", "field_description_value_option": "Recommended for single-select and multi-select answers: the stable option id keeps one option in one bucket across languages, after a label edit, and for free-text 'other' answers. Charts show the option's label, not the id.", "field_description_value_text": "Text answer value (open text, or the label of a multiple-choice/categorical answer). Buckets by the exact text, so a translated label, an edited label or a free-text 'other' answer each becomes its own bucket — for choice questions prefer Value (Option). Pair with a fieldType filter to keep types consistent.", "field_label_anger_count": "Emotion: Anger", "field_label_ces_average": "CES: Average", - "field_label_ces_count": "CES: Responses", + "field_label_ces_count": "CES: Records", "field_label_collected_at": "Collected At", - "field_label_count": "Responses", + "field_label_count": "Feedback Records", "field_label_created_at": "Created At", "field_label_csat_average": "CSAT: Average", - "field_label_csat_count": "CSAT: Responses", + "field_label_csat_count": "CSAT: Records", "field_label_csat_dissatisfied_count": "CSAT: Dissatisfied", "field_label_csat_neutral_count": "CSAT: Neutral", "field_label_csat_satisfied_count": "CSAT: Satisfied", @@ -1752,7 +1757,7 @@ "field_label_question": "Question", "field_label_question_group": "Question Group", "field_label_rating_average": "Rating: Average", - "field_label_rating_count": "Rating: Responses", + "field_label_rating_count": "Rating: Records", "field_label_response_id": "Response ID", "field_label_sadness_count": "Emotion: Sadness", "field_label_sentiment": "Sentiment", @@ -1849,7 +1854,9 @@ "start_date": "Start date", "time_dimension": "Time Dimension", "time_dimension_title": "Add time-based grouping", + "time_dimension_title_range_only": "Add a date range filter", "time_dimension_toggle_description": "Monitor trends over time.", + "time_dimension_toggle_description_range_only": "Scope this chart to a date range, without grouping by time.", "vertical_bars": "Vertical bars" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Respondent identification", "comparison_row_responses": "Responses / month", "comparison_row_single_use_links": "Single-use links", - "comparison_row_spam": "Spam protection (reCAPTCHA)", "comparison_row_teams_roles": "Teams & access roles", "comparison_row_topic_labeling": "Topic & subtopic labeling (AI)", "comparison_row_two_factor_auth": "Two-factor authentication", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Quota management", "plan_scale_feature_rbac": "Teams & access roles", "plan_scale_feature_responses": "5,000 responses / month with dynamic pricing", - "plan_scale_feature_security": "2FA & spam protection", + "plan_scale_feature_security": "Two-factor authentication (2FA)", "plan_scale_feature_semantic_analysis": "Semantic Analysis (AI)", "plan_scale_feature_workflow_runs": "1,000 workflow runs / month with dynamic pricing", "plan_scale_feature_workspaces": "5 workspaces", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index ece1166811fa..c72f9c803c69 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Ya está en el panel", "and_filter_logic": "Y", "apply_changes": "Aplicar cambios", + "area_display": "Mostrar como", + "area_display_filled": "Área rellena", + "area_display_line": "Línea", "bar_direction": "Dirección de las barras", "chart": "Gráfico", "chart_added_to_dashboard": "¡Gráfico añadido al panel de control!", @@ -1663,10 +1666,9 @@ "chart_preview": "Vista previa del gráfico", "chart_render_error": "Algo salió mal al renderizar este gráfico.", "chart_saved_successfully": "¡Gráfico guardado correctamente!", - "chart_type_area": "Gráfico de área", + "chart_type_area": "Gráfico de área / línea", "chart_type_bar": "Gráfico de barras", "chart_type_big_number": "Número grande", - "chart_type_line": "Gráfico de líneas", "chart_type_not_supported": "El tipo de gráfico \"{chartType}\" aún no está soportado", "chart_type_pie": "Gráfico circular", "chart_updated_successfully": "¡Gráfico actualizado correctamente!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Error al guardar el gráfico", "field": "Campo", + "field_description_count": "Número total de registros de comentarios", + "field_description_unique_respondents": "Personas identificadas únicas que dieron feedback, deduplicadas por persona: un encuestado que responde 3 preguntas cuenta una vez. El feedback anónimo (sin encuestado identificado) no se cuenta aquí, aunque sí cuenta como Registro de Feedback.", + "field_description_unique_responses": "Envíos de encuesta únicos, deduplicados por envío: un encuestado que envía dos veces cuenta dos veces", "field_description_value_option": "Recomendado para respuestas de selección única y múltiple: el id de opción estable mantiene una opción en un mismo grupo en todos los idiomas, después de editar una etiqueta y para respuestas de texto libre en 'otro'. Los gráficos muestran la etiqueta de la opción, no el id.", "field_description_value_text": "Valor de respuesta de texto (texto abierto o la etiqueta de una respuesta de opción múltiple/categórica). Agrupa por el texto exacto, por lo que una etiqueta traducida, una etiqueta editada o una respuesta de texto libre en 'otro' se convierten cada una en su propio grupo — para preguntas de elección, prefiere Valor (Opción). Combínalo con un filtro de tipo de campo para mantener la consistencia de tipos.", "field_label_anger_count": "Emoción: Enfado", "field_label_ces_average": "CES: Promedio", - "field_label_ces_count": "CES: Respuestas", + "field_label_ces_count": "CES: Registros", "field_label_collected_at": "Recopilado el", - "field_label_count": "Respuestas", + "field_label_count": "Registros de comentarios", "field_label_created_at": "Fecha de creación", "field_label_csat_average": "CSAT: Promedio", - "field_label_csat_count": "CSAT: Respuestas", + "field_label_csat_count": "CSAT: Registros", "field_label_csat_dissatisfied_count": "CSAT: Insatisfechos", "field_label_csat_neutral_count": "CSAT: Neutros", "field_label_csat_satisfied_count": "CSAT: Satisfechos", @@ -1752,7 +1757,7 @@ "field_label_question": "Pregunta", "field_label_question_group": "Grupo de preguntas", "field_label_rating_average": "Valoración: Promedio", - "field_label_rating_count": "Valoración: Respuestas", + "field_label_rating_count": "Valoración: Registros", "field_label_response_id": "ID de respuesta", "field_label_sadness_count": "Emoción: Tristeza", "field_label_sentiment": "Sentimiento", @@ -1849,7 +1854,9 @@ "start_date": "Fecha de inicio", "time_dimension": "Dimensión temporal", "time_dimension_title": "Añadir agrupación temporal", + "time_dimension_title_range_only": "Añadir un filtro de rango de fechas", "time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.", + "time_dimension_toggle_description_range_only": "Limita este gráfico a un rango de fechas, sin agrupar por tiempo.", "vertical_bars": "Barras verticales" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Identificación de encuestados", "comparison_row_responses": "Respuestas / mes", "comparison_row_single_use_links": "Enlaces de un solo uso", - "comparison_row_spam": "Protección contra spam (reCAPTCHA)", "comparison_row_teams_roles": "Equipos y roles de acceso", "comparison_row_topic_labeling": "Etiquetado de temas y subtemas (IA)", "comparison_row_two_factor_auth": "Autenticación de dos factores", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Gestión de cuotas", "plan_scale_feature_rbac": "Equipos y roles de acceso", "plan_scale_feature_responses": "5.000 respuestas al mes con precios dinámicos", - "plan_scale_feature_security": "2FA y protección antispam", + "plan_scale_feature_security": "Autenticación de dos factores (2FA)", "plan_scale_feature_semantic_analysis": "Análisis semántico (IA)", "plan_scale_feature_workflow_runs": "1.000 ejecuciones de flujo de trabajo al mes con precios dinámicos", "plan_scale_feature_workspaces": "5 espacios de trabajo", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 9e551412d141..34e105c12522 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Déjà sur le tableau de bord", "and_filter_logic": "ET", "apply_changes": "Appliquer les modifications", + "area_display": "Afficher en tant que", + "area_display_filled": "Zone remplie", + "area_display_line": "Ligne", "bar_direction": "Direction des barres", "chart": "Graphique", "chart_added_to_dashboard": "Graphique ajouté au tableau de bord !", @@ -1663,10 +1666,9 @@ "chart_preview": "Aperçu du graphique", "chart_render_error": "Une erreur s'est produite lors du rendu de ce graphique.", "chart_saved_successfully": "Graphique enregistré avec succès !", - "chart_type_area": "Graphique en aires", + "chart_type_area": "Graphique en aires / Graphique linéaire", "chart_type_bar": "Graphique à barres", "chart_type_big_number": "Grand nombre", - "chart_type_line": "Graphique linéaire", "chart_type_not_supported": "Le type de graphique \"{chartType}\" n'est pas encore pris en charge", "chart_type_pie": "Graphique circulaire", "chart_updated_successfully": "Graphique mis à jour avec succès !", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Échec de l'enregistrement du graphique", "field": "Champ", + "field_description_count": "Nombre total d'enregistrements de feedback", + "field_description_unique_respondents": "Personnes identifiées uniques ayant donné leur avis, dédupliquées par personne — un répondant ayant répondu à 3 questions compte pour un. Les retours anonymes (sans répondant identifié) ne sont pas comptés ici, même s'ils comptent comme un enregistrement de retour.", + "field_description_unique_responses": "Soumissions de sondage uniques, dédupliquées par soumission — un répondant qui soumet deux fois compte pour deux", "field_description_value_option": "Recommandé pour les réponses à choix unique et à choix multiples : l'identifiant d'option stable maintient une option dans un même groupe à travers les langues, après une modification de libellé, et pour les réponses « autre » en texte libre. Les graphiques affichent le libellé de l'option, pas l'identifiant.", "field_description_value_text": "Valeur de réponse textuelle (texte libre, ou libellé d'une réponse à choix multiples/catégorielle). Regroupe par texte exact, donc un libellé traduit, un libellé modifié ou une réponse « autre » en texte libre devient chacun son propre groupe — pour les questions à choix, privilégie Valeur (Option). Associe avec un filtre fieldType pour maintenir la cohérence des types.", "field_label_anger_count": "Émotion : Colère", "field_label_ces_average": "CES : Moyenne", - "field_label_ces_count": "CES : Réponses", + "field_label_ces_count": "CES : Enregistrements", "field_label_collected_at": "Collecté le", - "field_label_count": "Réponses", + "field_label_count": "Enregistrements de commentaires", "field_label_created_at": "Créé le", "field_label_csat_average": "CSAT : Moyenne", - "field_label_csat_count": "CSAT : Réponses", + "field_label_csat_count": "CSAT : Enregistrements", "field_label_csat_dissatisfied_count": "CSAT : Insatisfaits", "field_label_csat_neutral_count": "CSAT : Neutres", "field_label_csat_satisfied_count": "CSAT : Satisfaits", @@ -1752,7 +1757,7 @@ "field_label_question": "Question", "field_label_question_group": "Groupe de questions", "field_label_rating_average": "Note : Moyenne", - "field_label_rating_count": "Note : Réponses", + "field_label_rating_count": "Note : Enregistrements", "field_label_response_id": "ID de réponse", "field_label_sadness_count": "Émotion : Tristesse", "field_label_sentiment": "Sentiment", @@ -1849,7 +1854,9 @@ "start_date": "Date de début", "time_dimension": "Dimension temporelle", "time_dimension_title": "Ajouter un groupement temporel", + "time_dimension_title_range_only": "Ajouter un filtre de plage de dates", "time_dimension_toggle_description": "Surveille les tendances dans le temps.", + "time_dimension_toggle_description_range_only": "Limiter ce graphique à une plage de dates, sans regroupement par période.", "vertical_bars": "Barres verticales" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Identification des répondants", "comparison_row_responses": "Réponses / mois", "comparison_row_single_use_links": "Liens à usage unique", - "comparison_row_spam": "Protection anti-spam (reCAPTCHA)", "comparison_row_teams_roles": "Équipes et rôles d'accès", "comparison_row_topic_labeling": "Étiquetage de sujets et sous-sujets (IA)", "comparison_row_two_factor_auth": "Authentification à deux facteurs", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Gestion des quotas", "plan_scale_feature_rbac": "Équipes et rôles d'accès", "plan_scale_feature_responses": "5 000 réponses / mois avec tarification dynamique", - "plan_scale_feature_security": "2FA et protection anti-spam", + "plan_scale_feature_security": "Authentification à deux facteurs (2FA)", "plan_scale_feature_semantic_analysis": "Analyse sémantique (IA)", "plan_scale_feature_workflow_runs": "1 000 exécutions de workflow / mois avec tarification dynamique", "plan_scale_feature_workspaces": "5 espaces de travail", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 55b36878fcf2..473b80f0c005 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Már a vezérlőpulton van", "and_filter_logic": "ÉS", "apply_changes": "Változtatások alkalmazása", + "area_display": "Megjelenítés módja", + "area_display_filled": "Kitöltött terület", + "area_display_line": "Vonal", "bar_direction": "Oszlopok iránya", "chart": "Diagram", "chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!", @@ -1663,10 +1666,9 @@ "chart_preview": "Diagram előnézete", "chart_render_error": "Valami probléma történt a diagram megjelenítése során.", "chart_saved_successfully": "A diagram sikeresen elmentve!", - "chart_type_area": "Területdiagram", + "chart_type_area": "Terület- / Vonaldiagram", "chart_type_bar": "Oszlopdiagram", "chart_type_big_number": "Nagy szám", - "chart_type_line": "Vonaldiagram", "chart_type_not_supported": "A(z) „{chartType}” diagramtípus még nem támogatott", "chart_type_pie": "Tortadiagram", "chart_updated_successfully": "A diagram sikeresen frissítve!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Nem sikerült betölteni a vezérlőpultokat", "failed_to_save_chart": "Nem sikerült elmenteni a diagramot", "field": "Mező", + "field_description_count": "A visszajelzési rekordok összesített száma", + "field_description_unique_respondents": "Egyedileg azonosított személyek, akik visszajelzést adtak, személy szerint deduplikálva — egy válaszadó, aki 3 kérdésre válaszol, egyszer számít. Az anonim visszajelzések (ahol nincs azonosított válaszadó) itt nem kerülnek beszámításra, még akkor sem, ha visszajelzési rekordként szerepelnek.", + "field_description_unique_responses": "Egyedi felmérésbeküldések száma, beküldésenként deduplikálva — egy válaszadó, aki kétszer küld be választ, kétszer számít", "field_description_value_option": "Ajánlott egyválasztásos és többválasztásos válaszokhoz: a stabil opcióazonosító egy opciót egy kategóriában tart a különböző nyelveken, címkeszerkesztés után, valamint szabad szöveges „egyéb“ válaszok esetén. A diagramok az opció címkéjét jelenítik meg, nem az azonosítót.", "field_description_value_text": "Szöveges válaszérték (nyílt szöveg vagy többválasztásos/kategorikus válasz címkéje). A pontos szöveg alapján csoportosít, így egy lefordított címke, egy szerkesztett címke vagy egy szabad szöveges „egyéb“ válasz külön kategóriát alkot — választási kérdéseknél inkább az Érték (Opció) használata javasolt. Párosítsa fieldType szűrővel a típusok következetességének megőrzése érdekében.", "field_label_anger_count": "Érzelem: Harag", "field_label_ces_average": "CES: Átlag", - "field_label_ces_count": "CES: Válaszok", + "field_label_ces_count": "CES: Rekordok", "field_label_collected_at": "Begyűjtve ekkor", - "field_label_count": "Válaszok", + "field_label_count": "Visszajelzési rekordok", "field_label_created_at": "Létrehozva ekkor", "field_label_csat_average": "CSAT: Átlag", - "field_label_csat_count": "CSAT: Válaszok", + "field_label_csat_count": "CSAT: Rekordok", "field_label_csat_dissatisfied_count": "CSAT: Elégedetlenek", "field_label_csat_neutral_count": "CSAT: Semlegesek", "field_label_csat_satisfied_count": "CSAT: Elégedettek", @@ -1752,7 +1757,7 @@ "field_label_question": "Kérdés", "field_label_question_group": "Kérdéscsoport", "field_label_rating_average": "Értékelés: Átlag", - "field_label_rating_count": "Értékelés: Válaszok", + "field_label_rating_count": "Értékelés: Rekordok", "field_label_response_id": "Válaszazonosító", "field_label_sadness_count": "Érzelem: Szomorúság", "field_label_sentiment": "Hangulat", @@ -1849,7 +1854,9 @@ "start_date": "Kezdési dátum", "time_dimension": "Idődimenzió", "time_dimension_title": "Időalapú csoportosítás hozzáadása", + "time_dimension_title_range_only": "Dátumtartomány-szűrő hozzáadása", "time_dimension_toggle_description": "Időbeni trendek megfigyelése.", + "time_dimension_toggle_description_range_only": "A diagram korlátozása egy dátumtartományra, idő szerinti csoportosítás nélkül.", "vertical_bars": "Függőleges oszlopok" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Válaszadó azonosítása", "comparison_row_responses": "Válaszok / hónap", "comparison_row_single_use_links": "Egyszeri használatú hivatkozások", - "comparison_row_spam": "Spam védelem (reCAPTCHA)", "comparison_row_teams_roles": "Csapatok és hozzáférési szerepkörök", "comparison_row_topic_labeling": "Téma és altéma címkézés (AI)", "comparison_row_two_factor_auth": "Kétfaktoros hitelesítés", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Kvótakezelés", "plan_scale_feature_rbac": "Csapatok és hozzáférési szerepkörök", "plan_scale_feature_responses": "5000 válasz / hónap dinamikus árképzéssel", - "plan_scale_feature_security": "2FA és spam védelem", + "plan_scale_feature_security": "Kétfaktoros hitelesítés (2FA)", "plan_scale_feature_semantic_analysis": "Szemantikai elemzés (AI)", "plan_scale_feature_workflow_runs": "1000 munkafolyamat-futtatás / hónap dinamikus árképzéssel", "plan_scale_feature_workspaces": "5 munkaterület", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index c1f46101283a..6e791af2b05e 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "すでにダッシュボードに追加済み", "and_filter_logic": "AND", "apply_changes": "変更を適用", + "area_display": "表示形式", + "area_display_filled": "塗りつぶしエリア", + "area_display_line": "折れ線", "bar_direction": "棒の向き", "chart": "チャート", "chart_added_to_dashboard": "チャートをダッシュボードに追加しました!", @@ -1663,10 +1666,9 @@ "chart_preview": "チャートプレビュー", "chart_render_error": "このチャートの表示中に問題が発生しました。", "chart_saved_successfully": "チャートを保存しました!", - "chart_type_area": "エリアチャート", + "chart_type_area": "エリア/折れ線グラフ", "chart_type_bar": "棒グラフ", "chart_type_big_number": "大きな数値", - "chart_type_line": "折れ線グラフ", "chart_type_not_supported": "チャートタイプ「{chartType}」はまだサポートされていません", "chart_type_pie": "円グラフ", "chart_updated_successfully": "チャートを更新しました!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "チャートの保存に失敗しました", "field": "フィールド", + "field_description_count": "フィードバック記録の総数", + "field_description_unique_respondents": "フィードバックを提供した一意の特定済みユーザー数(個人単位で重複排除)— 1人の回答者が3つの質問に回答した場合も1回としてカウントされます。匿名フィードバック(回答者が特定されていないもの)は、フィードバックレコードとしてはカウントされますが、ここには含まれません。", + "field_description_unique_responses": "ユニークな調査送信数。送信で重複排除 — 1人の回答者が2回送信した場合は2回とカウント", "field_description_value_option": "単一選択および複数選択の回答に推奨:安定したオプションIDにより、言語間、ラベル編集後、および自由記述の「その他」回答において、1つのオプションを1つのバケットに保ちます。グラフにはIDではなく、オプションのラベルが表示されます。", "field_description_value_text": "テキスト回答の値(自由記述、または選択式/カテゴリ式回答のラベル)。正確なテキストでバケット化されるため、翻訳されたラベル、編集されたラベル、または自由記述の「その他」回答はそれぞれ独自のバケットになります。選択式の質問には「値(オプション)」を優先してください。タイプの一貫性を保つため、fieldTypeフィルターと組み合わせて使用してください。", "field_label_anger_count": "感情:怒り", "field_label_ces_average": "CES:平均", - "field_label_ces_count": "CES:回答数", + "field_label_ces_count": "CES: レコード数", "field_label_collected_at": "収集日時", - "field_label_count": "回答数", + "field_label_count": "フィードバック件数", "field_label_created_at": "作成日時", "field_label_csat_average": "CSAT:平均", - "field_label_csat_count": "CSAT:回答数", + "field_label_csat_count": "CSAT: レコード数", "field_label_csat_dissatisfied_count": "CSAT:不満足", "field_label_csat_neutral_count": "CSAT:どちらでもない", "field_label_csat_satisfied_count": "CSAT:満足", @@ -1752,7 +1757,7 @@ "field_label_question": "質問", "field_label_question_group": "質問グループ", "field_label_rating_average": "評価:平均", - "field_label_rating_count": "評価:回答数", + "field_label_rating_count": "評価: レコード数", "field_label_response_id": "回答ID", "field_label_sadness_count": "感情:悲しみ", "field_label_sentiment": "センチメント", @@ -1849,7 +1854,9 @@ "start_date": "開始日", "time_dimension": "時間ディメンション", "time_dimension_title": "時間ベースのグループ化を追加", + "time_dimension_title_range_only": "日付範囲フィルターを追加", "time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。", + "time_dimension_toggle_description_range_only": "時間軸でグループ化せず、このチャートを日付範囲に絞り込みます。", "vertical_bars": "縦棒" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "回答者識別", "comparison_row_responses": "回答数 / 月", "comparison_row_single_use_links": "シングルユースリンク", - "comparison_row_spam": "スパム保護(reCAPTCHA)", "comparison_row_teams_roles": "チームとアクセスロール", "comparison_row_topic_labeling": "トピックとサブトピックのラベリング(AI)", "comparison_row_two_factor_auth": "二段階認証", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "クォータ管理", "plan_scale_feature_rbac": "チーム&アクセスロール", "plan_scale_feature_responses": "月間5,000件のレスポンス(動的価格設定)", - "plan_scale_feature_security": "2FA&スパム保護", + "plan_scale_feature_security": "二要素認証(2FA)", "plan_scale_feature_semantic_analysis": "セマンティック分析(AI)", "plan_scale_feature_workflow_runs": "月間1,000回のワークフロー実行、従量課金制", "plan_scale_feature_workspaces": "5つのワークスペース", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 8b3cf6f6a5c7..11d98980b8e3 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Al op dashboard", "and_filter_logic": "EN", "apply_changes": "Wijzigingen toepassen", + "area_display": "Weergeven als", + "area_display_filled": "Gevuld vlak", + "area_display_line": "Lijn", "bar_direction": "Staafrichting", "chart": "Grafiek", "chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!", @@ -1663,10 +1666,9 @@ "chart_preview": "Grafiekvoorbeeld", "chart_render_error": "Er is iets misgegaan bij het weergeven van deze grafiek.", "chart_saved_successfully": "Grafiek succesvol opgeslagen!", - "chart_type_area": "Vlakdiagram", + "chart_type_area": "Vlak- / Lijngrafiek", "chart_type_bar": "Staafdiagram", "chart_type_big_number": "Groot getal", - "chart_type_line": "Lijndiagram", "chart_type_not_supported": "Grafiektype \"{chartType}\" wordt nog niet ondersteund", "chart_type_pie": "Cirkeldiagram", "chart_updated_successfully": "Grafiek succesvol bijgewerkt!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Opslaan van diagram mislukt", "field": "Veld", + "field_description_count": "Totaal aantal feedbackrecords", + "field_description_unique_respondents": "Unieke geïdentificeerde personen die feedback hebben gegeven, gedupliceerd per persoon — één respondent die 3 vragen beantwoordt, telt als één. Anonieme feedback (zonder geïdentificeerde respondent) wordt hier niet meegeteld, ook al telt het wel als een feedbackrecord.", + "field_description_unique_responses": "Unieke enquête-inzendingen, gededupliceerd per inzending — één respondent die twee keer indient telt als twee", "field_description_value_option": "Aanbevolen voor single-select en multi-select antwoorden: de stabiele optie-id houdt één optie in één bucket over verschillende talen, na een labelwijziging en voor vrije-tekst 'andere' antwoorden. Grafieken tonen het label van de optie, niet de id.", "field_description_value_text": "Tekstantwoordwaarde (open tekst, of het label van een meerkeuzevraag/categorisch antwoord). Groepeert op exacte tekst, dus een vertaald label, een bewerkt label of een vrije-tekst 'andere' antwoord wordt elk een eigen bucket — bij keuzevragen gaat de voorkeur uit naar Waarde (Optie). Combineer met een fieldType-filter om types consistent te houden.", "field_label_anger_count": "Emotie: Woede", "field_label_ces_average": "CES: Gemiddelde", - "field_label_ces_count": "CES: Reacties", + "field_label_ces_count": "CES: Records", "field_label_collected_at": "Verzameld op", - "field_label_count": "Reacties", + "field_label_count": "Feedbackrecords", "field_label_created_at": "Aangemaakt op", "field_label_csat_average": "CSAT: Gemiddelde", - "field_label_csat_count": "CSAT: Reacties", + "field_label_csat_count": "CSAT: Records", "field_label_csat_dissatisfied_count": "CSAT: Ontevreden", "field_label_csat_neutral_count": "CSAT: Neutraal", "field_label_csat_satisfied_count": "CSAT: Tevreden", @@ -1752,7 +1757,7 @@ "field_label_question": "Vraag", "field_label_question_group": "Vraaggroep", "field_label_rating_average": "Beoordeling: Gemiddelde", - "field_label_rating_count": "Beoordeling: Reacties", + "field_label_rating_count": "Beoordeling: Records", "field_label_response_id": "Antwoord-ID", "field_label_sadness_count": "Emotie: Verdriet", "field_label_sentiment": "Sentiment", @@ -1849,7 +1854,9 @@ "start_date": "Startdatum", "time_dimension": "Tijdsdimensie", "time_dimension_title": "Tijdgebaseerde groepering toevoegen", + "time_dimension_title_range_only": "Voeg een datumbereikfilter toe", "time_dimension_toggle_description": "Volg trends over tijd.", + "time_dimension_toggle_description_range_only": "Beperk deze grafiek tot een datumbereik, zonder te groeperen op tijd.", "vertical_bars": "Verticale staven" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Identificatie van respondenten", "comparison_row_responses": "Reacties / maand", "comparison_row_single_use_links": "Eenmalige links", - "comparison_row_spam": "Spambescherming (reCAPTCHA)", "comparison_row_teams_roles": "Teams & toegangsrollen", "comparison_row_topic_labeling": "Onderwerp & subonderwerp labeling (AI)", "comparison_row_two_factor_auth": "Tweefactorauthenticatie", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Quotabeheer", "plan_scale_feature_rbac": "Teams & toegangsrollen", "plan_scale_feature_responses": "5.000 reacties / maand met dynamische prijzen", - "plan_scale_feature_security": "2FA & spambescherming", + "plan_scale_feature_security": "Tweefactorauthenticatie (2FA)", "plan_scale_feature_semantic_analysis": "Semantische analyse (AI)", "plan_scale_feature_workflow_runs": "1.000 workflowuitvoeringen / maand met dynamische prijzen", "plan_scale_feature_workspaces": "5 werkruimtes", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index a2c4c1efe319..59d6d9a5eeeb 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Já está no painel", "and_filter_logic": "E", "apply_changes": "Aplicar alterações", + "area_display": "Exibir como", + "area_display_filled": "Área preenchida", + "area_display_line": "Linha", "bar_direction": "Direção das barras", "chart": "Gráfico", "chart_added_to_dashboard": "Gráfico adicionado ao painel!", @@ -1663,10 +1666,9 @@ "chart_preview": "Visualização do gráfico", "chart_render_error": "Algo deu errado ao renderizar este gráfico.", "chart_saved_successfully": "Gráfico salvo com sucesso!", - "chart_type_area": "Gráfico de área", + "chart_type_area": "Gráfico de Área / Linha", "chart_type_bar": "Gráfico de barras", "chart_type_big_number": "Número grande", - "chart_type_line": "Gráfico de linhas", "chart_type_not_supported": "Tipo de gráfico \"{chartType}\" ainda não é suportado", "chart_type_pie": "Gráfico de pizza", "chart_updated_successfully": "Gráfico atualizado com sucesso!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Falha ao salvar gráfico", "field": "Campo", + "field_description_count": "Número total de registros de feedback", + "field_description_unique_respondents": "Pessoas identificadas únicas que deram feedback, sem duplicatas por pessoa — um respondente que responde 3 perguntas conta uma vez. Feedbacks anônimos (sem respondente identificado) não são contados aqui, mesmo que contem como um Registro de Feedback.", + "field_description_unique_responses": "Envios de pesquisa únicos, sem duplicatas por envio — um respondente que envia duas vezes conta duas vezes", "field_description_value_option": "Recomendado para respostas de seleção única e múltipla: o ID estável da opção mantém uma opção em um único grupo entre idiomas, após a edição de um rótulo e para respostas de texto livre do tipo 'outro'. Os gráficos mostram o rótulo da opção, não o ID.", "field_description_value_text": "Valor da resposta em texto (texto aberto ou o rótulo de uma resposta de múltipla escolha/categórica). Agrupa pelo texto exato, então um rótulo traduzido, um rótulo editado ou uma resposta de texto livre do tipo 'outro' se tornam grupos separados — para perguntas de escolha, prefira Valor (Opção). Combine com um filtro de fieldType para manter os tipos consistentes.", "field_label_anger_count": "Emoção: Raiva", "field_label_ces_average": "CES: Média", - "field_label_ces_count": "CES: Respostas", + "field_label_ces_count": "CES: Registros", "field_label_collected_at": "Coletado em", - "field_label_count": "Respostas", + "field_label_count": "Registros de Feedback", "field_label_created_at": "Criado em", "field_label_csat_average": "CSAT: Média", - "field_label_csat_count": "CSAT: Respostas", + "field_label_csat_count": "CSAT: Registros", "field_label_csat_dissatisfied_count": "CSAT: Insatisfeitos", "field_label_csat_neutral_count": "CSAT: Neutros", "field_label_csat_satisfied_count": "CSAT: Satisfeitos", @@ -1752,7 +1757,7 @@ "field_label_question": "Pergunta", "field_label_question_group": "Grupo de Perguntas", "field_label_rating_average": "Avaliação: Média", - "field_label_rating_count": "Avaliação: Respostas", + "field_label_rating_count": "Avaliação: Registros", "field_label_response_id": "ID da resposta", "field_label_sadness_count": "Emoção: Tristeza", "field_label_sentiment": "Sentimento", @@ -1849,7 +1854,9 @@ "start_date": "Data inicial", "time_dimension": "Dimensão temporal", "time_dimension_title": "Adicionar agrupamento por tempo", + "time_dimension_title_range_only": "Adicionar um filtro de intervalo de datas", "time_dimension_toggle_description": "Monitore tendências ao longo do tempo.", + "time_dimension_toggle_description_range_only": "Delimite este gráfico a um intervalo de datas, sem agrupar por período.", "vertical_bars": "Barras verticais" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Identificação de respondente", "comparison_row_responses": "Respostas / mês", "comparison_row_single_use_links": "Links de uso único", - "comparison_row_spam": "Proteção contra spam (reCAPTCHA)", "comparison_row_teams_roles": "Equipes e funções de acesso", "comparison_row_topic_labeling": "Rotulagem de tópicos e subtópicos (IA)", "comparison_row_two_factor_auth": "Autenticação de dois fatores", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Gerenciamento de cotas", "plan_scale_feature_rbac": "Equipes e funções de acesso", "plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos", - "plan_scale_feature_security": "Autenticação 2FA e proteção contra spam", + "plan_scale_feature_security": "Autenticação de dois fatores (2FA)", "plan_scale_feature_semantic_analysis": "Análise Semântica (IA)", "plan_scale_feature_workflow_runs": "1.000 execuções de workflow / mês com preços dinâmicos", "plan_scale_feature_workspaces": "5 espaços de trabalho", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index e98d3bcdb436..1b99ef5e48d4 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Já está no painel", "and_filter_logic": "E", "apply_changes": "Aplicar alterações", + "area_display": "Apresentar como", + "area_display_filled": "Área preenchida", + "area_display_line": "Linha", "bar_direction": "Direção das barras", "chart": "Gráfico", "chart_added_to_dashboard": "Gráfico adicionado ao painel!", @@ -1663,10 +1666,9 @@ "chart_preview": "Pré-visualização do gráfico", "chart_render_error": "Algo correu mal ao renderizar este gráfico.", "chart_saved_successfully": "Gráfico guardado com sucesso!", - "chart_type_area": "Gráfico de área", + "chart_type_area": "Gráfico de Área / Linha", "chart_type_bar": "Gráfico de barras", "chart_type_big_number": "Número grande", - "chart_type_line": "Gráfico de linhas", "chart_type_not_supported": "O tipo de gráfico \"{chartType}\" ainda não é suportado", "chart_type_pie": "Gráfico circular", "chart_updated_successfully": "Gráfico atualizado com sucesso!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Falha ao guardar gráfico", "field": "Campo", + "field_description_count": "Número total de registos de feedback", + "field_description_unique_respondents": "Pessoas identificadas únicas que deram feedback, sem duplicação por pessoa — um respondente que responde a 3 questões conta apenas uma vez. Feedback anónimo (sem respondente identificado) não é contado aqui, embora conte como Registo de Feedback.", + "field_description_unique_responses": "Submissões únicas de questionário, sem duplicação por submissão — um respondente que submete duas vezes conta duas vezes", "field_description_value_option": "Recomendado para respostas de seleção única e múltipla: o ID estável da opção mantém cada opção no mesmo grupo em todos os idiomas, após a edição de uma etiqueta e para respostas de texto livre 'outro'. Os gráficos mostram a etiqueta da opção, não o ID.", "field_description_value_text": "Valor de resposta em texto (texto aberto ou a etiqueta de uma resposta de escolha múltipla/categórica). Agrupa pelo texto exato, pelo que uma etiqueta traduzida, uma etiqueta editada ou uma resposta de texto livre 'outro' tornam-se cada uma o seu próprio grupo — para perguntas de escolha, prefere Valor (Opção). Combina com um filtro fieldType para manter os tipos consistentes.", "field_label_anger_count": "Emoção: Raiva", "field_label_ces_average": "CES: Média", - "field_label_ces_count": "CES: Respostas", + "field_label_ces_count": "CES: Registos", "field_label_collected_at": "Recolhido em", - "field_label_count": "Respostas", + "field_label_count": "Registos de Feedback", "field_label_created_at": "Criado em", "field_label_csat_average": "CSAT: Média", - "field_label_csat_count": "CSAT: Respostas", + "field_label_csat_count": "CSAT: Registos", "field_label_csat_dissatisfied_count": "CSAT: Insatisfeitos", "field_label_csat_neutral_count": "CSAT: Neutros", "field_label_csat_satisfied_count": "CSAT: Satisfeitos", @@ -1752,7 +1757,7 @@ "field_label_question": "Pergunta", "field_label_question_group": "Grupo de Perguntas", "field_label_rating_average": "Classificação: Média", - "field_label_rating_count": "Classificação: Respostas", + "field_label_rating_count": "Classificação: Registos", "field_label_response_id": "ID de resposta", "field_label_sadness_count": "Emoção: Tristeza", "field_label_sentiment": "Sentimento", @@ -1849,7 +1854,9 @@ "start_date": "Data de início", "time_dimension": "Dimensão temporal", "time_dimension_title": "Adicionar agrupamento temporal", + "time_dimension_title_range_only": "Adicionar um filtro de intervalo de datas", "time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo.", + "time_dimension_toggle_description_range_only": "Aplicar um intervalo de datas a este gráfico, sem agrupar por tempo.", "vertical_bars": "Barras verticais" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Identificação de inquiridos", "comparison_row_responses": "Respostas / mês", "comparison_row_single_use_links": "Links de utilização única", - "comparison_row_spam": "Proteção contra spam (reCAPTCHA)", "comparison_row_teams_roles": "Equipas e funções de acesso", "comparison_row_topic_labeling": "Etiquetagem de tópicos e subtópicos (IA)", "comparison_row_two_factor_auth": "Autenticação de dois fatores", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Gestão de quotas", "plan_scale_feature_rbac": "Equipas e funções de acesso", "plan_scale_feature_responses": "5.000 respostas / mês com preços dinâmicos", - "plan_scale_feature_security": "2FA e proteção contra spam", + "plan_scale_feature_security": "Autenticação de dois fatores (2FA)", "plan_scale_feature_semantic_analysis": "Análise Semântica (IA)", "plan_scale_feature_workflow_runs": "1000 execuções de fluxo de trabalho / mês com preços dinâmicos", "plan_scale_feature_workspaces": "5 áreas de trabalho", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 420146c6315d..d8d813e39229 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Deja pe tabloul de bord", "and_filter_logic": "ȘI", "apply_changes": "Aplică modificările", + "area_display": "Afișează ca", + "area_display_filled": "Zonă completată", + "area_display_line": "Linie", "bar_direction": "Direcția barelor", "chart": "Diagramă", "chart_added_to_dashboard": "Grafic adăugat la tablou de bord!", @@ -1663,10 +1666,9 @@ "chart_preview": "Previzualizare grafic", "chart_render_error": "Ceva nu a mers bine la afișarea acestui grafic.", "chart_saved_successfully": "Graficul a fost salvat cu succes!", - "chart_type_area": "Grafic de tip arie", + "chart_type_area": "Diagramă cu zonă / linie", "chart_type_bar": "Grafic de tip bară", "chart_type_big_number": "Număr mare", - "chart_type_line": "Grafic de tip linie", "chart_type_not_supported": "Tipul de diagramă \"{chartType}\" nu este încă acceptat", "chart_type_pie": "Grafic de tip plăcintă", "chart_updated_successfully": "Graficul a fost actualizat cu succes!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Nu s-a putut salva graficul", "field": "Câmp", + "field_description_count": "Numărul total de înregistrări de feedback", + "field_description_unique_respondents": "Persoane identificate unic care au oferit feedback, deduplicate per persoană — un respondent care răspunde la 3 întrebări este numărat o singură dată. Feedback-ul anonim (fără respondent identificat) nu este inclus aici, chiar dacă este contorizat ca înregistrare de feedback.", + "field_description_unique_responses": "Trimiteri unice de chestionare, deduplicate după trimitere — un respondent care trimite de două ori este numărat de două ori", "field_description_value_option": "Recomandat pentru răspunsuri cu selecție unică și selecție multiplă: id-ul stabil al opțiunii menține o opțiune într-o categorie unică în toate limbile, după editarea unei etichete și pentru răspunsuri libere de tip „altele“. Graficele afișează eticheta opțiunii, nu id-ul.", "field_description_value_text": "Valoarea textului răspunsului (text liber sau eticheta unui răspuns cu alegere multiplă/categoric). Grupează după textul exact, astfel încât o etichetă tradusă, o etichetă editată sau un răspuns liber de tip „altele“ devin fiecare propriul grup — pentru întrebări cu opțiuni, preferă Valoare (Opțiune). Asociază cu un filtru fieldType pentru a menține consistența tipurilor.", "field_label_anger_count": "Emoție: Furie", "field_label_ces_average": "CES: Medie", - "field_label_ces_count": "CES: Răspunsuri", + "field_label_ces_count": "CES: Înregistrări", "field_label_collected_at": "Colectat la", - "field_label_count": "Răspunsuri", + "field_label_count": "Înregistrări de feedback", "field_label_created_at": "Creat la", "field_label_csat_average": "CSAT: Medie", - "field_label_csat_count": "CSAT: Răspunsuri", + "field_label_csat_count": "CSAT: Înregistrări", "field_label_csat_dissatisfied_count": "CSAT: Nemulțumiți", "field_label_csat_neutral_count": "CSAT: Neutri", "field_label_csat_satisfied_count": "CSAT: Mulțumiți", @@ -1752,7 +1757,7 @@ "field_label_question": "Întrebare", "field_label_question_group": "Grup de întrebări", "field_label_rating_average": "Evaluare: Medie", - "field_label_rating_count": "Evaluare: Răspunsuri", + "field_label_rating_count": "Evaluare: Înregistrări", "field_label_response_id": "ID răspuns", "field_label_sadness_count": "Emoție: Tristețe", "field_label_sentiment": "Sentiment", @@ -1849,7 +1854,9 @@ "start_date": "Data de început", "time_dimension": "Dimensiune temporală", "time_dimension_title": "Adaugă grupare pe bază de timp", + "time_dimension_title_range_only": "Adaugă un filtru de interval de date", "time_dimension_toggle_description": "Monitorizează tendințele în timp.", + "time_dimension_toggle_description_range_only": "Limitează acest grafic la un interval de date, fără grupare după timp.", "vertical_bars": "bare verticale" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Identificare respondenți", "comparison_row_responses": "Răspunsuri / lună", "comparison_row_single_use_links": "Link-uri de unică folosință", - "comparison_row_spam": "Protecție anti-spam (reCAPTCHA)", "comparison_row_teams_roles": "Echipe & roluri de acces", "comparison_row_topic_labeling": "Etichetare subiecte & subtopicuri (AI)", "comparison_row_two_factor_auth": "Autentificare cu doi factori", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Gestionarea cotelor", "plan_scale_feature_rbac": "Echipe și roluri de acces", "plan_scale_feature_responses": "5.000 de răspunsuri / lună cu prețuri dinamice", - "plan_scale_feature_security": "2FA și protecție împotriva spam-ului", + "plan_scale_feature_security": "Autentificare cu doi factori (2FA)", "plan_scale_feature_semantic_analysis": "Analiză semantică (AI)", "plan_scale_feature_workflow_runs": "1.000 de rulări de fluxuri de lucru / lună cu prețuri dinamice", "plan_scale_feature_workspaces": "5 spații de lucru", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index d07deca8dc8d..18ee56fd624d 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Уже на дашборде", "and_filter_logic": "И", "apply_changes": "Применить изменения", + "area_display": "Отображать как", + "area_display_filled": "Заполненная область", + "area_display_line": "Линия", "bar_direction": "Направление столбцов", "chart": "График", "chart_added_to_dashboard": "График добавлен на панель!", @@ -1663,10 +1666,9 @@ "chart_preview": "Предпросмотр графика", "chart_render_error": "Что-то пошло не так при отображении этой диаграммы.", "chart_saved_successfully": "График успешно сохранён!", - "chart_type_area": "График областью", + "chart_type_area": "Диаграмма с областями / Линейная диаграмма", "chart_type_bar": "Столбчатая диаграмма", "chart_type_big_number": "Большое число", - "chart_type_line": "Линейный график", "chart_type_not_supported": "Тип диаграммы \"{chartType}\" пока не поддерживается", "chart_type_pie": "Круговая диаграмма", "chart_updated_successfully": "График успешно обновлён!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Не удалось сохранить график", "field": "Поле", + "field_description_count": "Общее количество записей обратной связи", + "field_description_unique_respondents": "Уникальные идентифицированные люди, оставившие отзыв, дедуплицированные по человеку — один респондент, ответивший на 3 вопроса, учитывается один раз. Анонимные отзывы (без идентифицированного респондента) здесь не учитываются, хотя они считаются записью отзыва.", + "field_description_unique_responses": "Уникальные отправки опросов, без дубликатов по отправке — один респондент, отправивший форму дважды, учитывается дважды", "field_description_value_option": "Рекомендуется для ответов с единичным или множественным выбором: стабильный идентификатор варианта сохраняет один вариант в одной группе для разных языков, после редактирования метки и для свободных текстовых ответов «другое». На графиках отображается метка варианта, а не его идентификатор.", "field_description_value_text": "Текстовое значение ответа (открытый текст или метка варианта с множественным выбором/категориального ответа). Группировка происходит по точному тексту, поэтому переведённая метка, отредактированная метка или свободный текстовый ответ «другое» образуют отдельные группы — для вопросов с выбором рекомендуется использовать Значение (вариант). Используй вместе с фильтром fieldType для сохранения единообразия типов.", "field_label_anger_count": "Эмоция: Гнев", "field_label_ces_average": "CES: Среднее", - "field_label_ces_count": "CES: Ответы", + "field_label_ces_count": "CES: Записи", "field_label_collected_at": "Дата сбора", - "field_label_count": "Ответы", + "field_label_count": "Записи отзывов", "field_label_created_at": "Дата создания", "field_label_csat_average": "CSAT: Среднее", - "field_label_csat_count": "CSAT: Ответы", + "field_label_csat_count": "CSAT: Записи", "field_label_csat_dissatisfied_count": "CSAT: Недовольные", "field_label_csat_neutral_count": "CSAT: Нейтральные", "field_label_csat_satisfied_count": "CSAT: Довольные", @@ -1752,7 +1757,7 @@ "field_label_question": "Вопрос", "field_label_question_group": "Группа вопросов", "field_label_rating_average": "Рейтинг: Среднее", - "field_label_rating_count": "Рейтинг: Ответы", + "field_label_rating_count": "Рейтинг: Записи", "field_label_response_id": "ID ответа", "field_label_sadness_count": "Эмоция: Грусть", "field_label_sentiment": "Тональность", @@ -1849,7 +1854,9 @@ "start_date": "Дата начала", "time_dimension": "Временное измерение", "time_dimension_title": "Добавить группировку по времени", + "time_dimension_title_range_only": "Добавить фильтр по диапазону дат", "time_dimension_toggle_description": "Отслеживайте тренды с течением времени.", + "time_dimension_toggle_description_range_only": "Ограничить этот график диапазоном дат без группировки по времени.", "vertical_bars": "Вертикальные столбцы" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Идентификация респондентов", "comparison_row_responses": "Ответы / месяц", "comparison_row_single_use_links": "Одноразовые ссылки", - "comparison_row_spam": "Защита от спама (reCAPTCHA)", "comparison_row_teams_roles": "Команды и роли доступа", "comparison_row_topic_labeling": "Маркировка тем и подтем (ИИ)", "comparison_row_two_factor_auth": "Двухфакторная аутентификация", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Управление квотами", "plan_scale_feature_rbac": "Команды и роли доступа", "plan_scale_feature_responses": "5 000 ответов в месяц с динамическим ценообразованием", - "plan_scale_feature_security": "Двухфакторная аутентификация и защита от спама", + "plan_scale_feature_security": "Двухфакторная аутентификация (2FA)", "plan_scale_feature_semantic_analysis": "Семантический анализ (AI)", "plan_scale_feature_workflow_runs": "1 000 запусков процессов в месяц с динамическим ценообразованием", "plan_scale_feature_workspaces": "5 рабочих пространств", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 16d19a55e695..c5d425b794e5 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Redan på instrumentpanelen", "and_filter_logic": "OCH", "apply_changes": "Verkställ ändringar", + "area_display": "Visa som", + "area_display_filled": "Fylld yta", + "area_display_line": "Linje", "bar_direction": "Stapelriktning", "chart": "Diagram", "chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!", @@ -1663,10 +1666,9 @@ "chart_preview": "Förhandsgranska diagram", "chart_render_error": "Något gick fel när diagrammet skulle visas.", "chart_saved_successfully": "Diagram sparat!", - "chart_type_area": "Ytdiagram", + "chart_type_area": "Område- / linjediagram", "chart_type_bar": "Stapeldiagram", "chart_type_big_number": "Stort tal", - "chart_type_line": "Linjediagram", "chart_type_not_supported": "Diagramtypen \"{chartType}\" stöds inte ännu", "chart_type_pie": "Cirkeldiagram", "chart_updated_successfully": "Diagram uppdaterat!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Det gick inte att spara diagrammet", "field": "Fält", + "field_description_count": "Totalt antal feedbackposter", + "field_description_unique_respondents": "Unika identifierade personer som har gett feedback, avduplicerade per person — en respondent som svarar på 3 frågor räknas en gång. Anonym feedback (ingen identifierad respondent) räknas inte här, även om den räknas som en Feedbackpost.", + "field_description_unique_responses": "Unika enkätsvar, avduplicerade per inskickning — en respondent som skickar in två gånger räknas två gånger", "field_description_value_option": "Rekommenderas för flervals- och envalsfrågor: det stabila alternativ-ID:t håller ett alternativ i samma grupp över olika språk, efter en etikettredigering och för fritextsvar under 'övrigt'. Diagram visar alternativets etikett, inte ID:t.", "field_description_value_text": "Textsvarvärde (öppen text eller etiketten för ett flervals-/kategoriskt svar). Grupperar efter exakt text, så en översatt etikett, en redigerad etikett eller ett fritextsvar under 'övrigt' blir var sitt fack – för valfrågor rekommenderas Värde (Alternativ). Kombinera med ett fälttypsfilter för att hålla typerna konsekventa.", "field_label_anger_count": "Känsla: Ilska", "field_label_ces_average": "CES: Medelvärde", - "field_label_ces_count": "CES: Svar", + "field_label_ces_count": "CES: Poster", "field_label_collected_at": "Insamlad", - "field_label_count": "Svar", + "field_label_count": "Feedbackposter", "field_label_created_at": "Skapad", "field_label_csat_average": "CSAT: Medelvärde", - "field_label_csat_count": "CSAT: Svar", + "field_label_csat_count": "CSAT: Poster", "field_label_csat_dissatisfied_count": "CSAT: Missnöjda", "field_label_csat_neutral_count": "CSAT: Neutrala", "field_label_csat_satisfied_count": "CSAT: Nöjda", @@ -1752,7 +1757,7 @@ "field_label_question": "Fråga", "field_label_question_group": "Frågegrupp", "field_label_rating_average": "Betyg: Medelvärde", - "field_label_rating_count": "Betyg: Svar", + "field_label_rating_count": "Betyg: Poster", "field_label_response_id": "Svar-ID", "field_label_sadness_count": "Känsla: Sorg", "field_label_sentiment": "Sentiment", @@ -1849,7 +1854,9 @@ "start_date": "Startdatum", "time_dimension": "Tidsdimension", "time_dimension_title": "Lägg till tidsbaserad gruppering", + "time_dimension_title_range_only": "Lägg till ett datumintervallfilter", "time_dimension_toggle_description": "Övervaka trender över tid.", + "time_dimension_toggle_description_range_only": "Begränsa det här diagrammet till ett datumintervall, utan att gruppera efter tid.", "vertical_bars": "Vertikala staplar" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Respondentidentifiering", "comparison_row_responses": "Svar / månad", "comparison_row_single_use_links": "Engångslänkar", - "comparison_row_spam": "Skydd mot spam (reCAPTCHA)", "comparison_row_teams_roles": "Team & åtkomstroller", "comparison_row_topic_labeling": "Ämnes- & underämnesmärkning (AI)", "comparison_row_two_factor_auth": "Tvåfaktorsautentisering", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Kvothantering", "plan_scale_feature_rbac": "Team och åtkomstroller", "plan_scale_feature_responses": "5 000 svar / månad med dynamisk prissättning", - "plan_scale_feature_security": "2FA och skräppostskydd", + "plan_scale_feature_security": "Tvåfaktorsautentisering (2FA)", "plan_scale_feature_semantic_analysis": "Semantisk analys (AI)", "plan_scale_feature_workflow_runs": "1 000 arbetsflödeskörningar/månad med dynamisk prissättning", "plan_scale_feature_workspaces": "5 arbetsytor", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 06016dff6526..7b70d5c998a0 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "Zaten panoda", "and_filter_logic": "VE", "apply_changes": "Değişiklikleri Uygula", + "area_display": "Görünüm", + "area_display_filled": "Dolu alan", + "area_display_line": "Çizgi", "bar_direction": "Çubuk yönü", "chart": "Grafik", "chart_added_to_dashboard": "Grafik panoya eklendi!", @@ -1663,10 +1666,9 @@ "chart_preview": "Grafik Önizleme", "chart_render_error": "Bu grafik görüntülenirken bir sorun oluştu.", "chart_saved_successfully": "Grafik başarıyla kaydedildi!", - "chart_type_area": "Alan Grafiği", + "chart_type_area": "Alan / Çizgi Grafik", "chart_type_bar": "Çubuk Grafik", "chart_type_big_number": "Büyük Sayı", - "chart_type_line": "Çizgi Grafik", "chart_type_not_supported": "\"{chartType}\" grafik türü henüz desteklenmiyor", "chart_type_pie": "Pasta Grafik", "chart_updated_successfully": "Grafik başarıyla güncellendi!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "Grafik kaydedilemedi", "field": "Alan", + "field_description_count": "Toplam geri bildirim kayıt sayısı", + "field_description_unique_respondents": "Geri bildirim veren benzersiz tanımlanmış kişiler, kişiye göre tekilleştirilmiş — 3 soruyu yanıtlayan bir katılımcı bir kez sayılır. Anonim geri bildirimler (tanımlanmış katılımcı yok) burada sayılmaz, ancak Geri Bildirim Kaydı olarak sayılır.", + "field_description_unique_responses": "Benzersiz anket gönderimi sayısı, gönderime göre tekilleştirilmiş — iki kez gönderen bir katılımcı iki kez sayılır", "field_description_value_option": "Tek seçimli ve çoklu seçimli yanıtlar için önerilir: sabit seçenek kimliği, bir seçeneği diller arasında, etiket düzenlemesinden sonra ve serbest metin 'diğer' yanıtları için tek bir grupta tutar. Grafikler, seçeneğin kimliğini değil, etiketini gösterir.", "field_description_value_text": "Metin yanıt değeri (açık metin veya çoktan seçmeli/kategorik bir yanıtın etiketi). Tam metne göre gruplandırır, bu nedenle çevrilmiş bir etiket, düzenlenmiş bir etiket veya serbest metin 'diğer' yanıtı her biri kendi grubunu oluşturur — seçim sorularında Değer (Seçenek) tercih edilir. Türleri tutarlı tutmak için fieldType filtresiyle eşleştirin.", "field_label_anger_count": "Duygu: Öfke", "field_label_ces_average": "CES: Ortalama", - "field_label_ces_count": "CES: Yanıtlar", + "field_label_ces_count": "CES: Kayıt", "field_label_collected_at": "Toplandığı Tarih", - "field_label_count": "Yanıtlar", + "field_label_count": "Geri Bildirim Kayıtları", "field_label_created_at": "Oluşturulma Tarihi", "field_label_csat_average": "CSAT: Ortalama", - "field_label_csat_count": "CSAT: Yanıtlar", + "field_label_csat_count": "CSAT: Kayıt", "field_label_csat_dissatisfied_count": "CSAT: Memnun Değil", "field_label_csat_neutral_count": "CSAT: Nötr", "field_label_csat_satisfied_count": "CSAT: Memnun", @@ -1752,7 +1757,7 @@ "field_label_question": "Soru", "field_label_question_group": "Soru Grubu", "field_label_rating_average": "Değerlendirme: Ortalama", - "field_label_rating_count": "Değerlendirme: Yanıt Sayısı", + "field_label_rating_count": "Değerlendirme: Kayıt", "field_label_response_id": "Yanıt Kimliği", "field_label_sadness_count": "Duygu: Üzüntü", "field_label_sentiment": "Duygu Durumu", @@ -1849,7 +1854,9 @@ "start_date": "Başlangıç tarihi", "time_dimension": "Zaman Boyutu", "time_dimension_title": "Zaman tabanlı gruplama ekle", + "time_dimension_title_range_only": "Tarih aralığı filtresi ekle", "time_dimension_toggle_description": "Zaman içindeki eğilimleri izle.", + "time_dimension_toggle_description_range_only": "Bu grafiği zamana göre gruplamadan, bir tarih aralığıyla sınırlandır.", "vertical_bars": "Dikey çubuklar" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "Katılımcı tanımlama", "comparison_row_responses": "Yanıt / ay", "comparison_row_single_use_links": "Tek kullanımlık bağlantılar", - "comparison_row_spam": "Spam koruması (reCAPTCHA)", "comparison_row_teams_roles": "Ekipler ve erişim rolleri", "comparison_row_topic_labeling": "Konu ve alt konu etiketleme (AI)", "comparison_row_two_factor_auth": "İki faktörlü kimlik doğrulama", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "Kota yönetimi", "plan_scale_feature_rbac": "Ekipler ve erişim rolleri", "plan_scale_feature_responses": "Ayda 5.000 yanıt, dinamik fiyatlandırma ile", - "plan_scale_feature_security": "2FA ve spam koruması", + "plan_scale_feature_security": "İki faktörlü kimlik doğrulama (2FA)", "plan_scale_feature_semantic_analysis": "Anlamsal Analiz (Yapay Zeka)", "plan_scale_feature_workflow_runs": "Ayda 1.000 iş akışı çalıştırması ile dinamik fiyatlandırma", "plan_scale_feature_workspaces": "5 çalışma alanı", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 8c4231332ba3..c3fe20ddd28f 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "已在仪表板上", "and_filter_logic": "且", "apply_changes": "应用更改", + "area_display": "显示为", + "area_display_filled": "填充面积图", + "area_display_line": "折线图", "bar_direction": "条形图方向", "chart": "图表", "chart_added_to_dashboard": "图表已添加到 Dashboard!", @@ -1663,10 +1666,9 @@ "chart_preview": "图表预览", "chart_render_error": "渲染此图表时出现了问题。", "chart_saved_successfully": "图表保存成功!", - "chart_type_area": "面积图", + "chart_type_area": "面积图/折线图", "chart_type_bar": "柱状图", "chart_type_big_number": "大数字", - "chart_type_line": "折线图", "chart_type_not_supported": "暂不支持图表类型 “{chartType}”", "chart_type_pie": "饼图", "chart_updated_successfully": "图表更新成功!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "Failed to load dashboards", "failed_to_save_chart": "图表保存失败", "field": "字段", + "field_description_count": "反馈记录总数", + "field_description_unique_respondents": "提供反馈的唯一已识别用户,按用户去重——一个受访者回答3个问题只计数一次。匿名反馈(无已识别受访者)不计入此处,尽管它会被计为反馈记录。", + "field_description_unique_responses": "唯一问卷提交数(按提交去重 — 一位受访者提交 2 次计为 2 次)", "field_description_value_option": "推荐用于单选和多选答题:稳定的选项 ID 可以在跨语言、标签编辑后以及自由文本\"其他\"答案的情况下,将一个选项保持在一个数据桶中。图表显示选项的标签,而非 ID。", "field_description_value_text": "文本答案值(开放文本,或多选/分类答案的标签)。按精确文本分桶,因此翻译后的标签、编辑后的标签或自由文本\"其他\"答案各自成为独立的数据桶——对于选择类问题,建议使用值(选项)。与 fieldType 筛选器配合使用以保持类型一致。", "field_label_anger_count": "情绪:愤怒", "field_label_ces_average": "CES:平均值", - "field_label_ces_count": "CES:回复数", + "field_label_ces_count": "CES:记录数", "field_label_collected_at": "收集时间", - "field_label_count": "回复数", + "field_label_count": "反馈记录", "field_label_created_at": "创建时间", "field_label_csat_average": "CSAT:平均值", - "field_label_csat_count": "CSAT:回复数", + "field_label_csat_count": "CSAT:记录数", "field_label_csat_dissatisfied_count": "CSAT:不满意", "field_label_csat_neutral_count": "CSAT:中立", "field_label_csat_satisfied_count": "CSAT:满意", @@ -1752,7 +1757,7 @@ "field_label_question": "问题", "field_label_question_group": "问题组", "field_label_rating_average": "评分:平均值", - "field_label_rating_count": "评分:回复数", + "field_label_rating_count": "评分:记录数", "field_label_response_id": "响应 ID", "field_label_sadness_count": "情绪:悲伤", "field_label_sentiment": "情感", @@ -1849,7 +1854,9 @@ "start_date": "开始日期", "time_dimension": "时间维度", "time_dimension_title": "添加基于时间的分组", + "time_dimension_title_range_only": "添加日期范围筛选", "time_dimension_toggle_description": "监控随时间变化的趋势。", + "time_dimension_toggle_description_range_only": "为此图表设置日期范围,不按时间分组。", "vertical_bars": "纵向条形图" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "受访者识别", "comparison_row_responses": "响应数 / 月", "comparison_row_single_use_links": "一次性链接", - "comparison_row_spam": "垃圾信息防护 (reCAPTCHA)", "comparison_row_teams_roles": "团队与访问角色", "comparison_row_topic_labeling": "主题与子主题标记 (AI)", "comparison_row_two_factor_auth": "双因素身份验证", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "配额管理", "plan_scale_feature_rbac": "团队和访问角色", "plan_scale_feature_responses": "每月 5,000 次响应,采用动态定价", - "plan_scale_feature_security": "双因素认证和垃圾邮件防护", + "plan_scale_feature_security": "双因素身份验证 (2FA)", "plan_scale_feature_semantic_analysis": "语义分析(AI)", "plan_scale_feature_workflow_runs": "每月 1,000 次工作流运行,采用动态定价", "plan_scale_feature_workspaces": "5 个工作区", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 03214d473e9e..19dc416421ec 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1648,6 +1648,9 @@ "already_on_dashboard": "已在儀表板上", "and_filter_logic": "且", "apply_changes": "套用變更", + "area_display": "顯示為", + "area_display_filled": "填充區域", + "area_display_line": "折線", "bar_direction": "長條方向", "chart": "圖表", "chart_added_to_dashboard": "圖表已新增到儀表板!", @@ -1663,10 +1666,9 @@ "chart_preview": "圖表預覽", "chart_render_error": "這個圖表在顯示時發生錯誤。", "chart_saved_successfully": "圖表已成功儲存!", - "chart_type_area": "區域圖", + "chart_type_area": "區域圖 / 折線圖", "chart_type_bar": "長條圖", "chart_type_big_number": "大數字", - "chart_type_line": "折線圖", "chart_type_not_supported": "尚不支援圖表類型「{chartType}」", "chart_type_pie": "圓餅圖", "chart_updated_successfully": "圖表已成功更新!", @@ -1720,16 +1722,19 @@ "failed_to_load_dashboards": "載入儀表板失敗", "failed_to_save_chart": "儲存圖表失敗", "field": "欄位", + "field_description_count": "意見回饋記錄總數", + "field_description_unique_respondents": "提供意見回饋且經去重處理的唯一識別使用者——一位受訪者回答 3 個問題只計算一次。匿名意見回饋(無法識別受訪者)不計入此處,儘管它仍計為一筆意見回饋記錄。", + "field_description_unique_responses": "不重複的問卷提交數,依提交去重 — 一位受訪者提交兩次計算兩次", "field_description_value_option": "建議用於單選和多選答案:穩定的選項 ID 可確保一個選項在不同語言、標籤編輯後以及自由文字「其他」答案中都保持在同一個分組。圖表顯示選項的標籤,而非 ID。", "field_description_value_text": "文字答案值(開放式文字,或多選/分類答案的標籤)。按確切文字分組,因此翻譯後的標籤、編輯後的標籤或自由文字「其他」答案都會成為各自的分組 — 對於選擇題,建議使用「值(選項)」。搭配 fieldType 篩選器以保持類型一致。", "field_label_anger_count": "情緒:憤怒", "field_label_ces_average": "CES:平均值", - "field_label_ces_count": "CES:回覆數", + "field_label_ces_count": "CES:記錄數", "field_label_collected_at": "收集時間", - "field_label_count": "回覆數", + "field_label_count": "意見回饋記錄", "field_label_created_at": "建立時間", "field_label_csat_average": "CSAT:平均值", - "field_label_csat_count": "CSAT:回覆數", + "field_label_csat_count": "CSAT:記錄數", "field_label_csat_dissatisfied_count": "CSAT:不滿意", "field_label_csat_neutral_count": "CSAT:普通", "field_label_csat_satisfied_count": "CSAT:滿意", @@ -1752,7 +1757,7 @@ "field_label_question": "問題", "field_label_question_group": "問題群組", "field_label_rating_average": "評分:平均", - "field_label_rating_count": "評分:回應數", + "field_label_rating_count": "評分:記錄數", "field_label_response_id": "回應 ID", "field_label_sadness_count": "情緒:悲傷", "field_label_sentiment": "情感", @@ -1849,7 +1854,9 @@ "start_date": "開始日期", "time_dimension": "時間維度", "time_dimension_title": "新增基於時間的分組", + "time_dimension_title_range_only": "新增日期範圍篩選條件", "time_dimension_toggle_description": "監控隨時間變化的趨勢。", + "time_dimension_toggle_description_range_only": "將此圖表限定在特定日期範圍內,不依時間分組。", "vertical_bars": "垂直長條" }, "dashboards": { @@ -2504,7 +2511,6 @@ "comparison_row_respondent_id": "受訪者識別", "comparison_row_responses": "回覆數 / 月", "comparison_row_single_use_links": "單次使用連結", - "comparison_row_spam": "垃圾訊息防護 (reCAPTCHA)", "comparison_row_teams_roles": "團隊與存取權限角色", "comparison_row_topic_labeling": "主題與子主題標記 (AI)", "comparison_row_two_factor_auth": "雙重驗證", @@ -2603,7 +2609,7 @@ "plan_scale_feature_quota": "配額管理", "plan_scale_feature_rbac": "團隊與存取權限角色", "plan_scale_feature_responses": "每月 5,000 次回應,採用動態定價", - "plan_scale_feature_security": "雙因素驗證與垃圾訊息防護", + "plan_scale_feature_security": "雙重驗證 (2FA)", "plan_scale_feature_semantic_analysis": "語義分析(AI)", "plan_scale_feature_workflow_runs": "每月 1,000 次工作流程執行,採用動態定價", "plan_scale_feature_workspaces": "5 個工作區", diff --git a/apps/web/modules/account/lib/better-auth-account-deletion.test.ts b/apps/web/modules/account/lib/better-auth-account-deletion.test.ts index 00fe8d15c08a..653950235cc5 100644 --- a/apps/web/modules/account/lib/better-auth-account-deletion.test.ts +++ b/apps/web/modules/account/lib/better-auth-account-deletion.test.ts @@ -2,6 +2,8 @@ import { APIError } from "better-auth/api"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; +import { deleteUserOrganizationRelationships } from "@/lib/authzed/organization-membership"; +import { deleteUserTeamRelationships } from "@/lib/authzed/team-workspace"; import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo"; @@ -16,6 +18,12 @@ import { vi.mock("@formbricks/database", () => ({ prisma: { invite: { deleteMany: vi.fn() } } })); vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } })); +vi.mock("@/lib/authzed/organization-membership", () => ({ + deleteUserOrganizationRelationships: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + deleteUserTeamRelationships: vi.fn(), +})); vi.mock("@/lib/organization/service", () => ({ deleteOrganization: vi.fn(), getOrganizationsWhereUserIsSingleOwner: vi.fn(), @@ -88,6 +96,8 @@ describe("accountDeletionAfterDelete", () => { test("deletes the Brevo customer and queues a success audit event with the deleted user", async () => { await accountDeletionAfterDelete(user); + expect(deleteUserOrganizationRelationships).toHaveBeenCalledWith("user-1"); + expect(deleteUserTeamRelationships).toHaveBeenCalledWith("user-1"); expect(deleteBrevoCustomerByEmail).toHaveBeenCalledWith({ email: "ada@example.com" }); expect(queueAccountDeletionAuditEvent).toHaveBeenCalledWith({ oldUser: user, @@ -107,6 +117,32 @@ describe("accountDeletionAfterDelete", () => { expect.objectContaining({ status: "success", targetUserId: "user-1" }) ); }); + + test("continues post-delete cleanup when the AuthZed cleanup unexpectedly rejects", async () => { + vi.mocked(deleteUserOrganizationRelationships).mockRejectedValue(new Error("sensitive raw error")); + + await expect(accountDeletionAfterDelete(user)).resolves.toBeUndefined(); + + expect(logger.error).toHaveBeenCalledWith( + { + component: "authzed", + errorCode: "authzed_internal", + errorName: "Error", + operation: "account_delete_organization_cleanup", + retryable: false, + status: "failed", + }, + "Unexpected AuthZed projection failure after source commit" + ); + expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain("sensitive raw error"); + expect(deleteBrevoCustomerByEmail).toHaveBeenCalledWith({ email: "ada@example.com" }); + expect(queueAccountDeletionAuditEvent).toHaveBeenCalledWith({ + oldUser: user, + status: "success", + targetUserId: "user-1", + }); + expect(capturePostHogEvent).toHaveBeenCalledWith("user-1", "delete_account"); + }); }); describe("accountDeletionConfig", () => { diff --git a/apps/web/modules/account/lib/better-auth-account-deletion.ts b/apps/web/modules/account/lib/better-auth-account-deletion.ts index d55b29eff0db..3aac943129d6 100644 --- a/apps/web/modules/account/lib/better-auth-account-deletion.ts +++ b/apps/web/modules/account/lib/better-auth-account-deletion.ts @@ -3,6 +3,9 @@ import type { BetterAuthOptions } from "better-auth"; import { APIError } from "better-auth/api"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; +import { deleteUserOrganizationRelationships } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { deleteUserTeamRelationships } from "@/lib/authzed/team-workspace"; import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { ACCOUNT_DELETION_SOLE_OWNER_BLOCK_MESSAGE } from "@/modules/account/constants"; @@ -68,6 +71,11 @@ export const accountDeletionBeforeDelete: NonNullable = async (user) => { + await runPostCommitProjection("account_delete_organization_cleanup", () => + deleteUserOrganizationRelationships(user.id) + ); + await runPostCommitProjection("account_delete_team_cleanup", () => deleteUserTeamRelationships(user.id)); + try { await deleteBrevoCustomerByEmail({ email: user.email }); } catch (error) { diff --git a/apps/web/modules/analysis/components/SingleResponseCard/actions.ts b/apps/web/modules/analysis/components/SingleResponseCard/actions.ts index 2c400dbf84ee..e328068a537e 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/actions.ts +++ b/apps/web/modules/analysis/components/SingleResponseCard/actions.ts @@ -3,12 +3,12 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { deleteResponse, getResponse, getResponseWithQuotas } from "@/lib/response/service"; import { createTag, getTagsByWorkspaceId } from "@/lib/tag/service"; import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromResponseId, getOrganizationIdFromWorkspaceId, @@ -16,6 +16,8 @@ import { getWorkspaceIdFromSurveyId, } from "@/lib/utils/helper"; import { getTag } from "@/lib/utils/services"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; const ZCreateTagAction = z.object({ @@ -27,21 +29,11 @@ export const createTagAction = authenticatedActionClient.inputSchema(ZCreateTagA withAuditLogging("created", "tag", async ({ parsedInput, ctx }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; const result = await createTag(parsedInput.workspaceId, parsedInput.tagName); @@ -80,21 +72,11 @@ export const createTagToResponseAction = authenticatedActionClient const organizationId = await getOrganizationIdFromWorkspaceId(responseWorkspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: responseWorkspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: responseWorkspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, responseWorkspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.tagId = parsedInput.tagId; const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId); @@ -126,21 +108,11 @@ export const deleteTagOnResponseAction = authenticatedActionClient throw new Error("Response and tag are not in the same workspace"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: responseWorkspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: responseWorkspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, responseWorkspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.tagId = parsedInput.tagId; const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId); @@ -158,21 +130,12 @@ const ZDeleteResponseAction = z.object({ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDeleteResponseAction).action( withAuditLogging("deleted", "response", async ({ parsedInput, ctx }) => { const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromResponseId(parsedInput.responseId), - minPermission: "readWrite", - }, - ], + const workspaceId = await getWorkspaceIdFromResponseId(parsedInput.responseId); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.responseId = parsedInput.responseId; const result = await deleteResponse(parsedInput.responseId, parsedInput.decrementQuotas); @@ -191,20 +154,9 @@ const ZGetTagsByWorkspaceIdAction = z.object({ export const getTagsByWorkspaceIdAction = authenticatedActionClient .inputSchema(ZGetTagsByWorkspaceIdAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: parsedInput.workspaceId, }); return await getTagsByWorkspaceId(parsedInput.workspaceId); @@ -217,20 +169,19 @@ const ZGetResponseAction = z.object({ export const getResponseAction = authenticatedActionClient .inputSchema(ZGetResponseAction) .action(async ({ parsedInput, ctx }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromResponseId(parsedInput.responseId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromResponseId(parsedInput.responseId), - }, - ], + let workspaceId: string; + try { + workspaceId = await getWorkspaceIdFromResponseId(parsedInput.responseId); + } catch (error) { + if (error instanceof ResourceNotFoundError) { + throw new AuthorizationError("Not authorized"); + } + throw error; + } + + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: workspaceId, }); return await getResponseWithQuotas(parsedInput.responseId); diff --git a/apps/web/modules/api/lib/api-key-auth.test.ts b/apps/web/modules/api/lib/api-key-auth.test.ts index 0f7d133621c5..182aa5d1f729 100644 --- a/apps/web/modules/api/lib/api-key-auth.test.ts +++ b/apps/web/modules/api/lib/api-key-auth.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders, @@ -7,6 +8,9 @@ import { const mocks = vi.hoisted(() => ({ getApiKeyWithPermissions: vi.fn() })); +vi.mock("@formbricks/logger", () => ({ + logger: { warn: vi.fn() }, +})); vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({ getApiKeyWithPermissions: mocks.getApiKeyWithPermissions, })); @@ -49,29 +53,65 @@ describe("api-key-auth helpers", () => { }); }); -describe("authenticateApiKeyFromHeaders — ENG-1749 cross-org permission filter", () => { +describe("authenticateApiKeyFromHeaders", () => { const headers = new Headers({ "x-api-key": "fbk_secret" }); const apiKeyData = (workspaces: unknown[]) => ({ - id: "key1", - organizationId: "org-self", - organizationAccess: { accessControl: { read: false, write: false } }, + id: "key-1", + organizationId: "org-1", + organizationAccess: { accessControl: { read: true, write: false } }, apiKeyWorkspaces: workspaces, }); - const ws = (id: string, organizationId: string) => ({ - permission: "manage", + const workspaceGrant = ( + id: string, + organizationId = "org-1", + permission: "read" | "write" | "manage" = "manage", + name = id + ) => ({ + permission, workspaceId: id, - workspace: { id, name: id, organizationId }, + workspace: { id, name, organizationId }, }); beforeEach(() => { vi.clearAllMocks(); }); + test("rejects a revoked or deleted key", async () => { + mocks.getApiKeyWithPermissions.mockResolvedValue(null); + await expect(authenticateApiKeyFromHeaders(headers)).resolves.toBeNull(); + }); + + test("rejects an organization-only key on routes that did not opt in", async () => { + mocks.getApiKeyWithPermissions.mockResolvedValue(apiKeyData([])); + await expect(authenticateApiKeyFromHeaders(headers)).resolves.toBeNull(); + }); + + test("accepts an organization-only key when the route opts in", async () => { + mocks.getApiKeyWithPermissions.mockResolvedValue(apiKeyData([])); + + const auth = await authenticateApiKeyFromHeaders(headers, { allowOrganizationOnlyApiKey: true }); + + expect(auth?.apiKeyId).toBe("key-1"); + expect(auth?.workspacePermissions).toEqual([]); + }); + + test("accepts a workspace-scoped key and maps its grants", async () => { + mocks.getApiKeyWithPermissions.mockResolvedValue( + apiKeyData([workspaceGrant("ws-1", "org-1", "read", "Growth")]) + ); + + const auth = await authenticateApiKeyFromHeaders(headers); + + expect(auth?.workspacePermissions).toEqual([ + { permission: "read", workspaceId: "ws-1", workspaceName: "Growth" }, + ]); + }); + test("drops workspace permissions whose workspace is in another organization", async () => { mocks.getApiKeyWithPermissions.mockResolvedValue( - apiKeyData([ws("ws-own", "org-self"), ws("ws-victim", "org-other")]) + apiKeyData([workspaceGrant("ws-own"), workspaceGrant("ws-victim", "org-other")]) ); const auth = await authenticateApiKeyFromHeaders(headers); @@ -79,16 +119,24 @@ describe("authenticateApiKeyFromHeaders — ENG-1749 cross-org permission filter expect(auth?.workspacePermissions).toEqual([ { permission: "manage", workspaceId: "ws-own", workspaceName: "ws-own" }, ]); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { component: "authorization", crossOrganizationGrantCount: 1 }, + "Cross-organization API-key workspace grants were filtered" + ); + const logged = JSON.stringify(vi.mocked(logger.warn).mock.calls); + expect(logged).not.toContain("key-1"); + expect(logged).not.toContain("ws-victim"); + expect(logged).not.toContain("org-other"); }); test("returns null when only cross-org permissions remain", async () => { - mocks.getApiKeyWithPermissions.mockResolvedValue(apiKeyData([ws("ws-victim", "org-other")])); + mocks.getApiKeyWithPermissions.mockResolvedValue(apiKeyData([workspaceGrant("ws-victim", "org-other")])); expect(await authenticateApiKeyFromHeaders(headers)).toBeNull(); }); test("keeps a cross-org-only key for org-scoped routes but with no workspace permissions", async () => { - mocks.getApiKeyWithPermissions.mockResolvedValue(apiKeyData([ws("ws-victim", "org-other")])); + mocks.getApiKeyWithPermissions.mockResolvedValue(apiKeyData([workspaceGrant("ws-victim", "org-other")])); const auth = await authenticateApiKeyFromHeaders(headers, { allowOrganizationOnlyApiKey: true }); diff --git a/apps/web/modules/api/lib/api-key-auth.ts b/apps/web/modules/api/lib/api-key-auth.ts index aee57d9f967d..4b052a6ea9bb 100644 --- a/apps/web/modules/api/lib/api-key-auth.ts +++ b/apps/web/modules/api/lib/api-key-auth.ts @@ -1,3 +1,4 @@ +import { logger } from "@formbricks/logger"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { parseApiKeyV2 } from "@/lib/crypto"; import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key"; @@ -57,15 +58,24 @@ export const authenticateApiKeyFromHeaders = async ( // always illegitimate (it can only exist from a pre-fix bug/exploit). Filtering here — the single // point where the permission list is built — protects every consumer, including the read/list // routes that authorize off this list directly rather than through resolveBodyIdsV2. - const workspacePermissions = (apiKeyData.apiKeyWorkspaces ?? []) - .filter( - (workspacePermission) => workspacePermission.workspace.organizationId === apiKeyData.organizationId - ) - .map((workspacePermission) => ({ - permission: workspacePermission.permission, - workspaceId: workspacePermission.workspaceId, - workspaceName: workspacePermission.workspace.name, - })); + const apiKeyWorkspaces = apiKeyData.apiKeyWorkspaces ?? []; + const sameOrganizationWorkspacePermissions = apiKeyWorkspaces.filter( + (workspacePermission) => workspacePermission.workspace.organizationId === apiKeyData.organizationId + ); + const crossOrganizationGrantCount = apiKeyWorkspaces.length - sameOrganizationWorkspacePermissions.length; + + if (crossOrganizationGrantCount > 0) { + logger.warn( + { component: "authorization", crossOrganizationGrantCount }, + "Cross-organization API-key workspace grants were filtered" + ); + } + + const workspacePermissions = sameOrganizationWorkspacePermissions.map((workspacePermission) => ({ + permission: workspacePermission.permission, + workspaceId: workspacePermission.workspaceId, + workspaceName: workspacePermission.workspace.name, + })); // Reject org-only API keys for routes that require workspace-scoped permissions // (those routes opt in via allowOrganizationOnlyApiKey when an org-only key is acceptable). diff --git a/apps/web/modules/api/v2/auth/api-wrapper.ts b/apps/web/modules/api/v2/auth/api-wrapper.ts index ab5e650035f6..c83a1d947064 100644 --- a/apps/web/modules/api/v2/auth/api-wrapper.ts +++ b/apps/web/modules/api/v2/auth/api-wrapper.ts @@ -3,6 +3,7 @@ import { logger } from "@formbricks/logger"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/request-body"; import { TApiAuditLog } from "@/app/lib/api/with-api-logging"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; @@ -163,10 +164,12 @@ export const apiWrapper = async ({ } } - return handler({ - authentication: authentication.data, - parsedInput, - request, - auditLog, - }); + return withAuthorizationSurface("api_v2", () => + handler({ + authentication: authentication.data, + parsedInput, + request, + auditLog, + }) + ); }; diff --git a/apps/web/modules/api/v2/management/authorized-collection-routes.test.ts b/apps/web/modules/api/v2/management/authorized-collection-routes.test.ts new file mode 100644 index 000000000000..ce1fa862dd9f --- /dev/null +++ b/apps/web/modules/api/v2/management/authorized-collection-routes.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { getContactAttributeKeys } from "./contact-attribute-keys/lib/contact-attribute-key"; +import { GET as getContactAttributeKeysRoute } from "./contact-attribute-keys/route"; +import { getAuthorizedApiKeyWorkspaceIds } from "./lib/authorized-workspace-ids"; +import { getResponses } from "./responses/lib/response"; +import { GET as getResponsesRoute } from "./responses/route"; +import { getWebhooks } from "./webhooks/lib/webhook"; +import { GET as getWebhooksRoute } from "./webhooks/route"; + +const { mockAuthenticatedApiClient, mockSuccessResponse } = vi.hoisted(() => ({ + mockAuthenticatedApiClient: vi.fn(), + mockSuccessResponse: vi.fn(), +})); + +vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({ + authenticatedApiClient: mockAuthenticatedApiClient, +})); +vi.mock("@/app/lib/pipelines", () => ({ sendToPipeline: vi.fn() })); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); +vi.mock("@/lib/authorization/permission-action", () => ({ + getWorkspaceAuthorizationActionForMethod: vi.fn(), +})); +vi.mock("@/lib/workspace/service", () => ({ getWorkspaceLegacyStoragePrefixes: vi.fn() })); +vi.mock("@/modules/api/lib/validation", () => ({ + formatValidationErrorsForV2Api: vi.fn(), + validateResponseData: vi.fn(), +})); +vi.mock("@/modules/api/v2/lib/element", () => ({ + validateOtherOptionLengthForMultipleChoice: vi.fn(), +})); +vi.mock("@/modules/api/v2/lib/response", () => ({ + responses: { successResponse: mockSuccessResponse }, +})); +vi.mock("@/modules/api/v2/lib/utils", () => ({ handleApiError: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/contacts-api-guard", () => ({ + checkContactsEnabledApiV2: vi.fn(), +})); +vi.mock("@/modules/api/v2/management/lib/helper", () => ({ + getWorkspaceId: vi.fn(), + getWorkspaceIdFromSurveyIds: vi.fn(), +})); +vi.mock("@/modules/api/v2/management/lib/workspace-resolver", () => ({ resolveBodyIdsV2: vi.fn() })); +vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/response", () => ({ + getResponseForPipeline: vi.fn(), +})); +vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/survey", () => ({ + getSurveyQuestions: vi.fn(), +})); +vi.mock("./lib/authorized-workspace-ids", () => ({ getAuthorizedApiKeyWorkspaceIds: vi.fn() })); +vi.mock("./contact-attribute-keys/lib/contact-attribute-key", () => ({ + createContactAttributeKey: vi.fn(), + getContactAttributeKeys: vi.fn(), +})); +vi.mock("./responses/lib/response", () => ({ + createResponseWithQuotaEvaluation: vi.fn(), + getResponses: vi.fn(), +})); +vi.mock("./webhooks/lib/webhook", () => ({ createWebhook: vi.fn(), getWebhooks: vi.fn() })); +vi.mock("@/modules/storage/utils", () => ({ + resolveStorageUrlsInObject: vi.fn((value) => value), + validateClientFileUploads: vi.fn(), +})); + +const authentication = { + apiKeyId: "api-key-1", + organizationAccess: { accessControl: { read: false, write: false } }, + organizationId: "organization-1", + type: "apiKey", + workspacePermissions: [{ permission: "read", workspaceId: "stale-workspace", workspaceName: "Stale" }], +} as const; + +const request = new Request("http://localhost/api/v2/management"); + +describe("API v2 collection authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthenticatedApiClient.mockImplementation( + async ({ handler }: Parameters[0]) => + handler({ authentication, parsedInput: { query: {} }, request } as never) + ); + vi.mocked(getAuthorizedApiKeyWorkspaceIds).mockResolvedValue(["authorized-workspace"]); + vi.mocked(getResponses).mockResolvedValue({ ok: true, data: { data: [] } } as never); + vi.mocked(getContactAttributeKeys).mockResolvedValue({ ok: true, data: [] } as never); + vi.mocked(getWebhooks).mockResolvedValue({ ok: true, data: [] } as never); + mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body)); + }); + + test.each([ + ["responses", getResponsesRoute, getResponses], + ["contact attribute keys", getContactAttributeKeysRoute, getContactAttributeKeys], + ["webhooks", getWebhooksRoute, getWebhooks], + ] as const)( + "scopes %s collection reads to the authoritative workspace intersection", + async (_, route, read) => { + await route(request as never); + + expect(getAuthorizedApiKeyWorkspaceIds).toHaveBeenCalledExactlyOnceWith(authentication); + expect(read).toHaveBeenCalledWith(["authorized-workspace"], expect.anything()); + expect(read).not.toHaveBeenCalledWith(["stale-workspace"], expect.anything()); + } + ); + + test.each([ + ["responses", getResponsesRoute, getResponses], + ["contact attribute keys", getContactAttributeKeysRoute, getContactAttributeKeys], + ["webhooks", getWebhooksRoute, getWebhooks], + ] as const)("does not query %s when the authoritative lookup fails", async (_, route, read) => { + const unavailable = new Error("AuthZed unavailable"); + vi.mocked(getAuthorizedApiKeyWorkspaceIds).mockRejectedValue(unavailable); + + await expect(route(request as never)).rejects.toBe(unavailable); + expect(read).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts index 269d3521cfd4..68c58b9da0a8 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -1,5 +1,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; @@ -14,7 +16,6 @@ import { } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { checkContactsEnabledApiV2 } from "@/modules/ee/license-check/lib/contacts-api-guard"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const GET = async ( request: NextRequest, @@ -40,7 +41,13 @@ export const GET = async ( return handleApiError(request, res.error as ApiErrorResponseV2); } - if (!hasPermission(authentication.workspacePermissions, res.data.workspaceId, "GET")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("GET"), + { type: "workspace", id: res.data.workspaceId } + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "environment", issue: "unauthorized" }], @@ -79,7 +86,13 @@ export const PUT = async ( if (!res.ok) { return handleApiError(request, res.error as ApiErrorResponseV2, auditLog); } - if (!hasPermission(authentication.workspacePermissions, res.data.workspaceId, "PUT")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("PUT"), + { type: "workspace", id: res.data.workspaceId } + )) + ) { return handleApiError( request, { @@ -157,7 +170,13 @@ export const DELETE = async ( return handleApiError(request, res.error as ApiErrorResponseV2, auditLog); } - if (!hasPermission(authentication.workspacePermissions, res.data.workspaceId, "DELETE")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("DELETE"), + { type: "workspace", id: res.data.workspaceId } + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts index b61b2efb5bda..7a4a765acb76 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts @@ -10,6 +10,7 @@ import { ZContactAttributeKeyCreateInput, ZGetContactAttributeKeysFilter, } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { getAuthorizedApiKeyWorkspaceIds } from "@/modules/api/v2/management/lib/authorized-workspace-ids"; import { resolveBodyIdsV2 } from "@/modules/api/v2/management/lib/workspace-resolver"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { checkContactsEnabledApiV2 } from "@/modules/ee/license-check/lib/contacts-api-guard"; @@ -28,9 +29,7 @@ export const GET = async (request: NextRequest) => return handleApiError(request, contactsNotEnabledError); } - const workspaceIds = [ - ...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)), - ]; + const workspaceIds = await getAuthorizedApiKeyWorkspaceIds(authentication); const res = await getContactAttributeKeys(workspaceIds, query); diff --git a/apps/web/modules/api/v2/management/lib/authorized-workspace-ids.test.ts b/apps/web/modules/api/v2/management/lib/authorized-workspace-ids.test.ts new file mode 100644 index 000000000000..4afbb733aefc --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/authorized-workspace-ids.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; +import { getAuthorizedApiKeyWorkspaceIds } from "./authorized-workspace-ids"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/authorization/resource-list", () => ({ lookupAuthorizedWorkspaceIds: vi.fn() })); + +const authentication = { + apiKeyId: "api-key-1", + organizationAccess: { accessControl: { read: false, write: false } }, + organizationId: "organization-1", + type: "apiKey", + workspacePermissions: [ + { permission: "read", workspaceId: "workspace-2", workspaceName: "Two" }, + { permission: "manage", workspaceId: "workspace-1", workspaceName: "One" }, + { permission: "read", workspaceId: "workspace-2", workspaceName: "Two duplicate" }, + { permission: "read", workspaceId: "stale-workspace", workspaceName: "Stale" }, + ], +} as const satisfies TAuthenticationApiKey; + +describe("getAuthorizedApiKeyWorkspaceIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns the deduplicated intersection of database grants and SpiceDB authorization", async () => { + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([ + "workspace-1", + "workspace-2", + "unexpected-workspace", + ]); + + await expect(getAuthorizedApiKeyWorkspaceIds(authentication)).resolves.toEqual([ + "workspace-2", + "workspace-1", + ]); + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith( + { type: "apiKey", id: "api-key-1" }, + "read" + ); + }); + + test("returns an empty scope when SpiceDB authorizes no workspace", async () => { + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue([]); + + await expect(getAuthorizedApiKeyWorkspaceIds(authentication)).resolves.toEqual([]); + }); + + test("propagates lookup and projection-freshness failures", async () => { + const unavailable = new Error("AuthZed unavailable"); + vi.mocked(lookupAuthorizedWorkspaceIds).mockRejectedValue(unavailable); + + await expect(getAuthorizedApiKeyWorkspaceIds(authentication)).rejects.toBe(unavailable); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/authorized-workspace-ids.ts b/apps/web/modules/api/v2/management/lib/authorized-workspace-ids.ts new file mode 100644 index 000000000000..391119e69f43 --- /dev/null +++ b/apps/web/modules/api/v2/management/lib/authorized-workspace-ids.ts @@ -0,0 +1,27 @@ +import "server-only"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; + +/** + * Intersect the API key's PostgreSQL scope with the sole authoritative SpiceDB workspace list. + * + * PostgreSQL remains the source of grant metadata and tenant scoping, while SpiceDB is the decision + * engine. Neither set can widen the other: stale database grants are denied by SpiceDB, and an + * unexpected SpiceDB relationship cannot add a workspace that is absent from the authenticated key's + * same-organization grant set. Lookup or freshness failures propagate so collection reads fail closed. + */ +export const getAuthorizedApiKeyWorkspaceIds = async ( + authentication: TAuthenticationApiKey +): Promise => { + const authorizedWorkspaceIds = new Set( + await lookupAuthorizedWorkspaceIds({ type: "apiKey", id: authentication.apiKeyId }, "read") + ); + + return [ + ...new Set( + authentication.workspacePermissions + .map(({ workspaceId }) => workspaceId) + .filter((workspaceId) => authorizedWorkspaceIds.has(workspaceId)) + ), + ]; +}; diff --git a/apps/web/modules/api/v2/management/lib/workspace-resolver.test.ts b/apps/web/modules/api/v2/management/lib/workspace-resolver.test.ts index 14813a3d712e..744b4e340ddc 100644 --- a/apps/web/modules/api/v2/management/lib/workspace-resolver.test.ts +++ b/apps/web/modules/api/v2/management/lib/workspace-resolver.test.ts @@ -1,22 +1,29 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { ApiKeyPermission } from "@formbricks/database/prisma"; +import { can } from "@/lib/authorization"; import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; import { resolveBodyIdsV2 } from "./workspace-resolver"; vi.mock("server-only", () => ({})); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); + vi.mock("@/lib/utils/resolve-client-id", () => ({ findWorkspaceByIdOrLegacyEnvId: vi.fn(), })); const auth = (organizationId: string, workspaceId: string, permission: ApiKeyPermission) => ({ + type: "apiKey" as const, + apiKeyId: "api-key-1", organizationId, + organizationAccess: { accessControl: { read: false, write: false } }, workspacePermissions: [{ workspaceId, workspaceName: "Test Workspace", permission }], }); describe("resolveBodyIdsV2", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(can).mockResolvedValue(false); }); test("returns bad_request when no workspaceId/environmentId is provided", async () => { @@ -41,7 +48,7 @@ describe("resolveBodyIdsV2", () => { }); // ENG-1749 defense-in-depth: a permission row for a workspace in another organization must not - // grant access, even though hasPermission alone would match on workspaceId. + // grant access, even if the central permission check would otherwise accept the workspaceId. test("returns forbidden when the workspace belongs to a different organization", async () => { vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "victim-ws", @@ -76,6 +83,7 @@ describe("resolveBodyIdsV2", () => { }); test("resolves the workspaceId for a same-org workspace the key can access", async () => { + vi.mocked(can).mockResolvedValueOnce(true); vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws1", organizationId: "org1", diff --git a/apps/web/modules/api/v2/management/lib/workspace-resolver.ts b/apps/web/modules/api/v2/management/lib/workspace-resolver.ts index bfb6d3c0c2e1..52a22b983a22 100644 --- a/apps/web/modules/api/v2/management/lib/workspace-resolver.ts +++ b/apps/web/modules/api/v2/management/lib/workspace-resolver.ts @@ -1,8 +1,9 @@ -import { TAuthenticationApiKey } from "@formbricks/types/auth"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -17,7 +18,7 @@ type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; */ export const resolveBodyIdsV2 = async ( body: { workspaceId?: string; environmentId?: string }, - authentication: Pick, + authentication: TAuthenticationApiKey, method: HttpMethod ): Promise> => { const rawId = body.workspaceId ?? body.environmentId; @@ -38,7 +39,13 @@ export const resolveBodyIdsV2 = async ( return err({ type: "forbidden" }); } - if (!hasPermission(authentication.workspacePermissions, workspace.id, method)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(method), + { type: "workspace", id: workspace.id } + )) + ) { return err({ type: "forbidden" }); } diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 67b7ad2ed037..ad99eadf4f0b 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service"; import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; @@ -15,7 +17,6 @@ import { } from "@/modules/api/v2/management/responses/[responseId]/lib/response"; import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules/storage/utils"; import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses"; @@ -41,7 +42,13 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI return handleApiError(request, workspaceIdResult.error); } - if (!hasPermission(authentication.workspacePermissions, workspaceIdResult.data.workspaceId, "GET")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("GET"), + { type: "workspace", id: workspaceIdResult.data.workspaceId } + )) + ) { return handleApiError(request, { type: "unauthorized", }); @@ -89,7 +96,13 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon return handleApiError(request, workspaceIdResult.error, auditLog); } - if (!hasPermission(authentication.workspacePermissions, workspaceIdResult.data.workspaceId, "DELETE")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("DELETE"), + { type: "workspace", id: workspaceIdResult.data.workspaceId } + )) + ) { return handleApiError( request, { @@ -142,7 +155,13 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str return handleApiError(request, workspaceIdResult.error, auditLog); } - if (!hasPermission(authentication.workspacePermissions, workspaceIdResult.data.workspaceId, "PUT")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("PUT"), + { type: "workspace", id: workspaceIdResult.data.workspaceId } + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/management/responses/route.test.ts b/apps/web/modules/api/v2/management/responses/route.test.ts index 9c1958e92be4..5162cf8d06da 100644 --- a/apps/web/modules/api/v2/management/responses/route.test.ts +++ b/apps/web/modules/api/v2/management/responses/route.test.ts @@ -5,14 +5,19 @@ import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated // whichever test runs first, which on a loaded CI runner exceeded the 5s testTimeout. import { GET } from "./route"; -const { mockAuthenticatedApiClient, mockGetResponses, mockHandleApiError, mockSuccessResponse } = vi.hoisted( - () => ({ - mockAuthenticatedApiClient: vi.fn(), - mockGetResponses: vi.fn(), - mockHandleApiError: vi.fn(), - mockSuccessResponse: vi.fn(), - }) -); +const { + mockAuthenticatedApiClient, + mockGetAuthorizedApiKeyWorkspaceIds, + mockGetResponses, + mockHandleApiError, + mockSuccessResponse, +} = vi.hoisted(() => ({ + mockAuthenticatedApiClient: vi.fn(), + mockGetAuthorizedApiKeyWorkspaceIds: vi.fn(), + mockGetResponses: vi.fn(), + mockHandleApiError: vi.fn(), + mockSuccessResponse: vi.fn(), +})); vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({ authenticatedApiClient: mockAuthenticatedApiClient, @@ -29,6 +34,10 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({ handleApiError: mockHandleApiError, })); +vi.mock("@/modules/api/v2/management/lib/authorized-workspace-ids", () => ({ + getAuthorizedApiKeyWorkspaceIds: mockGetAuthorizedApiKeyWorkspaceIds, +})); + vi.mock("./lib/response", () => ({ createResponseWithQuotaEvaluation: vi.fn(), getResponses: mockGetResponses, @@ -78,6 +87,7 @@ describe("GET /management/responses", () => { ); mockHandleApiError.mockImplementation((_request, error) => Response.json({ error }, { status: 400 })); mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body, { status: 200 })); + mockGetAuthorizedApiKeyWorkspaceIds.mockResolvedValue(["ws123"]); }); test("returns the pagination meta the service computed alongside the data", async () => { @@ -92,6 +102,9 @@ describe("GET /management/responses", () => { const response = await GET(buildRequest() as any); const body = await response.json(); + expect(mockGetAuthorizedApiKeyWorkspaceIds).toHaveBeenCalledWith( + expect.objectContaining({ apiKeyId: "apiKey123" }) + ); expect(mockGetResponses).toHaveBeenCalledWith(["ws123"], query); expect(response.status).toBe(200); expect(body).toEqual({ diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index b44113c74737..67489999cf07 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -1,17 +1,19 @@ import { NextRequest } from "next/server"; import { sendToPipeline } from "@/app/lib/pipelines"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getWorkspaceLegacyStoragePrefixes } from "@/lib/workspace/service"; import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; +import { getAuthorizedApiKeyWorkspaceIds } from "@/modules/api/v2/management/lib/authorized-workspace-ids"; import { getWorkspaceId } from "@/modules/api/v2/management/lib/helper"; import { getResponseForPipeline } from "@/modules/api/v2/management/responses/[responseId]/lib/response"; import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { resolveStorageUrlsInObject, validateClientFileUploads } from "@/modules/storage/utils"; import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response"; @@ -31,9 +33,7 @@ export const GET = async (request: NextRequest) => }); } - const workspaceIds = [ - ...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)), - ]; + const workspaceIds = await getAuthorizedApiKeyWorkspaceIds(authentication); const res = await getResponses(workspaceIds, query); @@ -76,7 +76,13 @@ export const POST = async (request: Request) => const { workspaceId } = workspaceIdResult.data; - if (!hasPermission(authentication.workspacePermissions, workspaceId, "POST")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: workspaceId } + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts index e296fd256730..b26d4aa96c86 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts @@ -1,3 +1,5 @@ +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; @@ -15,7 +17,6 @@ import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[su import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const GET = async (request: Request, props: { params: Promise }) => authenticatedApiClient({ @@ -43,7 +44,13 @@ export const GET = async (request: Request, props: { params: Promise }) => authenticatedApiClient({ @@ -39,7 +40,13 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho return handleApiError(request, webhook.error as ApiErrorResponseV2); } - if (!hasPermission(authentication.workspacePermissions, webhook.data.workspaceId, "GET")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("GET"), + { type: "workspace", id: webhook.data.workspaceId } + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "webhook", issue: "unauthorized" }], @@ -90,7 +97,13 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog); } - if (!hasPermission(authentication.workspacePermissions, webhook.data.workspaceId, "PUT")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("PUT"), + { type: "workspace", id: webhook.data.workspaceId } + )) + ) { return handleApiError( request, { @@ -162,7 +175,13 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we return handleApiError(request, webhook.error as ApiErrorResponseV2, auditLog); } - if (!hasPermission(authentication.workspacePermissions, webhook.data.workspaceId, "DELETE")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("DELETE"), + { type: "workspace", id: webhook.data.workspaceId } + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts index 1f5ec597ac43..36d182503eee 100644 --- a/apps/web/modules/api/v2/management/webhooks/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from "next/server"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; +import { getAuthorizedApiKeyWorkspaceIds } from "@/modules/api/v2/management/lib/authorized-workspace-ids"; import { getWorkspaceIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper"; import { resolveBodyIdsV2 } from "@/modules/api/v2/management/lib/workspace-resolver"; import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook"; @@ -23,9 +24,7 @@ export const GET = async (request: NextRequest) => }); } - const workspaceIds = [ - ...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)), - ]; + const workspaceIds = await getAuthorizedApiKeyWorkspaceIds(authentication); const res = await getWebhooks(workspaceIds, query); diff --git a/apps/web/modules/api/v2/me/route.test.ts b/apps/web/modules/api/v2/me/route.test.ts new file mode 100644 index 000000000000..bc86183e337e --- /dev/null +++ b/apps/web/modules/api/v2/me/route.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { can } from "@/lib/authorization"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; +import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; + +const { mockAuthenticatedApiClient, mockHandleApiError, mockSuccessResponse } = vi.hoisted(() => ({ + mockAuthenticatedApiClient: vi.fn(), + mockHandleApiError: vi.fn(), + mockSuccessResponse: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { workspace: { findMany: vi.fn() } }, +})); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); +vi.mock("@/lib/authorization/resource-list", () => ({ lookupAuthorizedWorkspaceIds: vi.fn() })); +vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({ + authenticatedApiClient: mockAuthenticatedApiClient, +})); +vi.mock("@/modules/api/v2/lib/response", () => ({ + responses: { successResponse: mockSuccessResponse }, +})); +vi.mock("@/modules/api/v2/lib/utils", () => ({ handleApiError: mockHandleApiError })); + +const authentication = { + apiKeyId: "api-key-1", + organizationAccess: { accessControl: { read: true, write: false } }, + organizationId: "organization-1", + type: "apiKey", + workspacePermissions: [ + { permission: "read", workspaceId: "workspace-1", workspaceName: "One" }, + { permission: "manage", workspaceId: "workspace-2", workspaceName: "Two" }, + { permission: "read", workspaceId: "foreign-workspace", workspaceName: "Foreign" }, + ], +} as const; + +const request = new Request("http://localhost/api/v2/me"); + +describe("GET /api/v2/me", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(can).mockResolvedValue(true); + mockAuthenticatedApiClient.mockImplementation( + async ({ handler }: Parameters[0]) => + handler({ authentication, request } as never) + ); + mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body)); + mockHandleApiError.mockImplementation((_request, error) => Response.json({ error }, { status: 403 })); + }); + + test("returns only workspace grants authorized by SpiceDB and scoped to the API-key organization", async () => { + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValue(["workspace-2", "workspace-1"]); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([ + { id: "workspace-1", legacyEnvironmentId: "environment-1" }, + { id: "workspace-2", legacyEnvironmentId: null }, + ] as never); + + const { GET } = await import("./route"); + const response = await GET(request as never); + const body = await response.json(); + + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith({ + id: "api-key-1", + type: "apiKey", + }); + expect(prisma.workspace.findMany).toHaveBeenCalledExactlyOnceWith({ + where: { + id: { in: ["workspace-1", "workspace-2"] }, + organizationId: "organization-1", + }, + select: { id: true, legacyEnvironmentId: true }, + }); + expect(body.data.workspacePermissions).toEqual([ + { permissions: "read", workspaceId: "workspace-1", workspaceName: "One" }, + { permissions: "manage", workspaceId: "workspace-2", workspaceName: "Two" }, + ]); + expect(body.data.environmentPermissions).toEqual([ + { + environmentId: "environment-1", + environmentType: "production", + permissions: "read", + projectId: "workspace-1", + projectName: "One", + }, + ]); + expect(body.data.workspacePermissions).not.toContainEqual( + expect.objectContaining({ workspaceId: "foreign-workspace" }) + ); + expect(body.data.environmentPermissions).not.toContainEqual( + expect.objectContaining({ projectId: "foreign-workspace" }) + ); + }); + + test("fails closed when the authoritative workspace lookup fails", async () => { + const unavailable = new Error("AuthZed unavailable"); + vi.mocked(lookupAuthorizedWorkspaceIds).mockRejectedValue(unavailable); + + const { GET } = await import("./route"); + await expect(GET(request as never)).rejects.toBe(unavailable); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/api/v2/me/route.ts b/apps/web/modules/api/v2/me/route.ts index a80bc77ac139..6b67966fe9f4 100644 --- a/apps/web/modules/api/v2/me/route.ts +++ b/apps/web/modules/api/v2/me/route.ts @@ -1,31 +1,53 @@ import { NextRequest } from "next/server"; import { prisma } from "@formbricks/database"; import { OrganizationAccessType } from "@formbricks/types/api-key"; +import { can } from "@/lib/authorization"; +import { getOrganizationAuthorizationActionForAccessType } from "@/lib/authorization/permission-action"; +import { lookupAuthorizedWorkspaceIds } from "@/lib/authorization/resource-list"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; -import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils"; export const GET = async (request: NextRequest) => authenticatedApiClient({ request, allowOrganizationOnlyApiKey: true, handler: async ({ authentication }) => { - if (!hasOrganizationAccess(authentication, OrganizationAccessType.Read)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getOrganizationAuthorizationActionForAccessType(OrganizationAccessType.Read), + { type: "organization", id: authentication.organizationId } + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "organizationId", issue: "unauthorized" }], }); } - const workspaceIds = authentication.workspacePermissions.map((p) => p.workspaceId); + const workspaceIds = await lookupAuthorizedWorkspaceIds({ + id: authentication.apiKeyId, + type: "apiKey", + }); + const authorizedWorkspaceIds = new Set(workspaceIds); + const authorizedWorkspacePermissions = authentication.workspacePermissions.filter((permission) => + authorizedWorkspaceIds.has(permission.workspaceId) + ); const workspaces = await prisma.workspace.findMany({ - where: { id: { in: workspaceIds } }, + where: { + id: { in: authorizedWorkspacePermissions.map(({ workspaceId }) => workspaceId) }, + organizationId: authentication.organizationId, + }, select: { id: true, legacyEnvironmentId: true }, }); const legacyEnvIdByWorkspaceId = new Map(workspaces.map((w) => [w.id, w.legacyEnvironmentId])); - const workspacePermissions = authentication.workspacePermissions.map((permission) => ({ + const resolvedWorkspaceIds = new Set(workspaces.map(({ id }) => id)); + const resolvedWorkspacePermissions = authorizedWorkspacePermissions.filter(({ workspaceId }) => + resolvedWorkspaceIds.has(workspaceId) + ); + const workspacePermissions = resolvedWorkspacePermissions.map((permission) => ({ permissions: permission.permission, workspaceId: permission.workspaceId, workspaceName: permission.workspaceName, @@ -33,7 +55,7 @@ export const GET = async (request: NextRequest) => // Backwards compat: expose environment-shaped permissions for consumers // from before the Environment model was removed. - const environmentPermissions = authentication.workspacePermissions.flatMap((permission) => { + const environmentPermissions = resolvedWorkspacePermissions.flatMap((permission) => { const legacyEnvironmentId = legacyEnvIdByWorkspaceId.get(permission.workspaceId); if (!legacyEnvironmentId) return []; return [ diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts index 61abd41ec6b0..933f0464f507 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts @@ -1,57 +1,63 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { logger } from "@formbricks/logger"; import { OrganizationAccessType } from "@formbricks/types/api-key"; +import { can } from "@/lib/authorization"; import { hasOrganizationIdAndAccess } from "./utils"; +// The central API-key ladder is covered by the SpiceDB evaluator and schema assertions. +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); + describe("hasOrganizationIdAndAccess", () => { beforeEach(() => { vi.restoreAllMocks(); }); - test("should return false and log error if authentication has no organizationId", () => { + test("should return false and log error if authentication has no organizationId", async () => { const spyError = vi.spyOn(logger, "error").mockImplementation(() => {}); const authentication = { organizationAccess: { accessControl: { read: true } }, } as any; - const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); + const result = await hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); expect(result).toBe(false); expect(spyError).toHaveBeenCalledWith( "Organization ID from params does not match the authenticated organization ID" ); }); - test("should return false and log error if param organizationId does not match authentication organizationId", () => { + test("should return false and log error if param organizationId does not match authentication organizationId", async () => { const spyError = vi.spyOn(logger, "error").mockImplementation(() => {}); const authentication = { organizationId: "org2", organizationAccess: { accessControl: { read: true } }, } as any; - const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); + const result = await hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); expect(result).toBe(false); expect(spyError).toHaveBeenCalledWith( "Organization ID from params does not match the authenticated organization ID" ); }); - test("should return false if access type is missing in organizationAccess", () => { + test("should return false if access type is missing in organizationAccess", async () => { + vi.mocked(can).mockResolvedValue(false); const authentication = { organizationId: "org1", organizationAccess: { accessControl: {} }, } as any; - const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); + const result = await hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); expect(result).toBe(false); }); - test("should return true if organizationId and access type are valid", () => { + test("should return true if organizationId and access type are valid", async () => { + vi.mocked(can).mockResolvedValue(true); const authentication = { organizationId: "org1", organizationAccess: { accessControl: { read: true } }, } as any; - const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); + const result = await hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); expect(result).toBe(true); }); }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts index 6807ca1fc11c..5384b1cb2358 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts @@ -1,18 +1,23 @@ import { logger } from "@formbricks/logger"; import { OrganizationAccessType } from "@formbricks/types/api-key"; import { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils"; +import { can } from "@/lib/authorization"; +import { getOrganizationAuthorizationActionForAccessType } from "@/lib/authorization/permission-action"; -export const hasOrganizationIdAndAccess = ( +export const hasOrganizationIdAndAccess = async ( paramOrganizationId: string, authentication: TAuthenticationApiKey, accessType: OrganizationAccessType -): boolean => { +): Promise => { if (paramOrganizationId !== authentication.organizationId) { logger.error("Organization ID from params does not match the authenticated organization ID"); return false; } - return hasOrganizationAccess(authentication, accessType); + return can( + { type: "apiKey", id: authentication.apiKeyId }, + getOrganizationAuthorizationActionForAccessType(accessType), + { type: "organization", id: authentication.organizationId } + ); }; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts index a5db5f280cea..a22da5a66d0b 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts @@ -4,6 +4,8 @@ import { prisma } from "@formbricks/database"; import { Prisma, Team } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; @@ -48,6 +50,10 @@ export const deleteTeam = async ( }, }); + await runPostCommitProjection("api_v2_team_delete", () => + reconcileTeamWorkspaceRelationships({ teamIds: [teamId] }) + ); + return ok(deletedTeam); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -86,6 +92,10 @@ export const updateTeam = async ( }, }); + await runPostCommitProjection("api_v2_team_update", () => + reconcileTeamWorkspaceRelationships({ teamIds: [teamId] }) + ); + return ok(updatedTeam); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts index 4a17160e6b4b..6fc153c909ba 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { deleteTeam, getTeam, updateTeam } from "../teams"; vi.mock("@formbricks/database", () => ({ @@ -14,6 +15,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + // Define a mock team const mockTeam = { id: "team123", @@ -23,6 +28,10 @@ const mockTeam = { }; describe("Teams Lib", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("getTeam", () => { test("returns the team when found", async () => { (prisma.team.findUnique as any).mockResolvedValueOnce(mockTeam); @@ -67,6 +76,7 @@ describe("Teams Lib", () => { include: { workspaceTeams: { select: { workspaceId: true } } }, }); expect(result.ok).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamIds: ["team123"] }); if (result.ok) { expect(result.data).toEqual(mockTeam); } @@ -113,6 +123,7 @@ describe("Teams Lib", () => { include: { workspaceTeams: { select: { workspaceId: true } } }, }); expect(result.ok).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamIds: ["team123"] }); if (result.ok) { expect(result.data).toEqual(updatedTeam); } diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts index 28d4e8333180..9044555fe766 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts @@ -8,6 +8,7 @@ const { mockDeleteTeam, mockGetApiKeyCreatorRole, mockGetTeam, + mockHasOrganizationIdAndAccess, mockHandleApiError, mockSuccessResponse, mockUpdateTeam, @@ -17,6 +18,7 @@ const { mockDeleteTeam: vi.fn(), mockGetApiKeyCreatorRole: vi.fn(), mockGetTeam: vi.fn(), + mockHasOrganizationIdAndAccess: vi.fn(), mockHandleApiError: vi.fn(), mockSuccessResponse: vi.fn(), mockUpdateTeam: vi.fn(), @@ -36,6 +38,10 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({ handleApiError: mockHandleApiError, })); +vi.mock("@/modules/api/v2/organizations/[organizationId]/lib/utils", () => ({ + hasOrganizationIdAndAccess: mockHasOrganizationIdAndAccess, +})); + vi.mock("@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams", () => ({ deleteTeam: mockDeleteTeam, getTeam: mockGetTeam, @@ -58,6 +64,7 @@ const buildRequest = (method: string) => describe("PUT/DELETE /organizations/[organizationId]/teams/[teamId]", () => { beforeEach(() => { vi.clearAllMocks(); + mockHasOrganizationIdAndAccess.mockResolvedValue(true); mockAuthenticatedApiClient.mockImplementation( async ({ handler }: Parameters[0]) => diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts index d1fdba9ac217..def110d6488e 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts @@ -34,7 +34,13 @@ export const GET = async ( }, externalParams: props.params, handler: async ({ authentication, parsedInput: { params } }) => { - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Read + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "organizationId", issue: "unauthorized" }], @@ -66,7 +72,13 @@ export const DELETE = async ( auditLog.targetId = params.teamId; } - if (!hasOrganizationIdAndAccess(params.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { @@ -132,7 +144,13 @@ export const PUT = ( auditLog.targetId = params.teamId; } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts index 5972f57bfe18..8d9deca6dc95 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts @@ -2,6 +2,8 @@ import "server-only"; import { prisma } from "@formbricks/database"; import { Team } from "@formbricks/database/prisma"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils"; import { TGetTeamsFilter, @@ -24,6 +26,10 @@ export const createTeam = async ( }, }); + await runPostCommitProjection("api_v2_team_create", () => + reconcileTeamWorkspaceRelationships({ teamIds: [team.id] }) + ); + return ok(team); } catch (error) { return err({ diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts index d0388a300019..ef166bf04ddf 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams"; import { createTeam, getTeams } from "../teams"; @@ -26,6 +27,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + describe("Teams Lib", () => { describe("createTeam", () => { test("creates a team successfully and revalidates cache", async () => { @@ -41,6 +46,7 @@ describe("Teams Lib", () => { }, }); expect(result.ok).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamIds: ["team123"] }); if (result.ok) expect(result.data).toEqual(mockTeam); }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts index b5a8c49582cd..2e1eeff1511a 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts @@ -7,6 +7,7 @@ const { mockCanManageOrganizationUsers, mockCreateTeam, mockGetApiKeyCreatorRole, + mockHasOrganizationIdAndAccess, mockHandleApiError, mockSuccessResponse, } = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const { mockCanManageOrganizationUsers: vi.fn(), mockCreateTeam: vi.fn(), mockGetApiKeyCreatorRole: vi.fn(), + mockHasOrganizationIdAndAccess: vi.fn(), mockHandleApiError: vi.fn(), mockSuccessResponse: vi.fn(), })); @@ -33,6 +35,10 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({ handleApiError: mockHandleApiError, })); +vi.mock("@/modules/api/v2/organizations/[organizationId]/lib/utils", () => ({ + hasOrganizationIdAndAccess: mockHasOrganizationIdAndAccess, +})); + vi.mock("@/modules/api/v2/organizations/[organizationId]/teams/lib/teams", () => ({ createTeam: mockCreateTeam, getTeams: vi.fn(), @@ -53,6 +59,7 @@ const buildRequest = () => describe("POST /organizations/[organizationId]/teams", () => { beforeEach(() => { vi.clearAllMocks(); + mockHasOrganizationIdAndAccess.mockResolvedValue(true); mockAuthenticatedApiClient.mockImplementation( async ({ handler }: Parameters[0]) => diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts index 937779ec5c41..2a5cbcabe454 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts @@ -26,7 +26,13 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ organ }, externalParams: props.params, handler: async ({ authentication, parsedInput: { query, params } }) => { - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Read + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "organizationId", issue: "unauthorized" }], @@ -53,7 +59,13 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza }, externalParams: props.params, handler: async ({ authentication, parsedInput: { body, params }, auditLog }) => { - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts index 84b55d5bf726..e22501fa8d6b 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { Prisma } from "@formbricks/database/prisma"; +import { Prisma, type Team, type WorkspaceTeam } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; import { createUser, getUsers, updateUser } from "../users"; @@ -22,6 +24,10 @@ const mockUser = { teamUsers: [{ team: { name: "Test Team", id: "team123", workspaceTeams: [{ workspaceId: "proj789" }] } }], }; +type TExistingTeam = Pick & { + workspaceTeams: Pick[]; +}; + // getOrganizationOwnerCount (pulled in via the last-owner guard) request-caches its result with // React's cache(); mocked to identity so repeated calls across tests re-hit the prisma mock below. vi.mock("react", () => ({ cache: (fn: Function) => fn })); @@ -49,7 +55,18 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/authzed/organization-membership", () => ({ + reconcileOrganizationMembership: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + describe("Users Lib", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("getUsers", () => { test("returns users with meta on success", async () => { const usersArray = [mockUser]; @@ -90,12 +107,21 @@ describe("Users Lib", () => { describe("createUser", () => { test("creates user and revalidates caches", async () => { + const existingTeams = [ + { id: "team123", name: "Test Team", workspaceTeams: [] }, + ] satisfies TExistingTeam[]; + + vi.mocked(prisma.team.findMany).mockResolvedValueOnce(existingTeams as never); (prisma.user.create as any).mockResolvedValueOnce(mockUser); const result = await createUser( - { name: "Test User", email: "test@example.com", role: "member" }, + { name: "Test User", email: "test@example.com", role: "member", teams: ["Test Team"] }, "org456" ); expect(prisma.user.create).toHaveBeenCalled(); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith("org456", mockUser.id); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [{ teamId: "team123", userId: mockUser.id }], + }); expect(result.ok).toBe(true); if (result.ok) { expect(result.data.id).toBe(mockUser.id); @@ -171,6 +197,10 @@ describe("Users Lib", () => { (prisma.$transaction as any).mockResolvedValueOnce([{ ...mockUser, name: "Updated User" }]); const result = await updateUser({ email: mockUser.email, name: "Updated User" }, "org456"); expect(prisma.user.findFirst).toHaveBeenCalled(); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith("org456", mockUser.id); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [{ teamId: "team123", userId: mockUser.id }], + }); expect(result.ok).toBe(true); if (result.ok) { expect(result.data.name).toBe("Updated User"); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts index 5ce9f5c034e9..a40664a0da5e 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts @@ -2,6 +2,9 @@ import { prisma } from "@formbricks/database"; import { OrganizationRole, Prisma, TeamUserRole } from "@formbricks/database/prisma"; import { TUser } from "@formbricks/database/zod/users"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { isUniqueConstraintError } from "@/lib/utils/prisma-constraint"; import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; import { @@ -130,6 +133,13 @@ export const createUser = async ( }, }); + await reconcileOrganizationMembership(organizationId, user.id); + await runPostCommitProjection("api_v2_organization_user_create", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: (existingTeams ?? []).map(({ id: teamId }) => ({ teamId, userId: user.id })), + }) + ); + const returnedUser = { id: user.id, createdAt: user.createdAt, @@ -371,6 +381,17 @@ export const updateUser = async ( updatedUser = results[results.length - 1]; } + await reconcileOrganizationMembership(organizationId, updatedUser.id); + const affectedTeamIds = new Set([ + ...existingUser.teamUsers.map(({ team }) => team.id), + ...(newTeams ?? []).map(({ id }) => id), + ]); + await runPostCommitProjection("api_v2_organization_user_update", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: [...affectedTeamIds].map((teamId) => ({ teamId, userId: updatedUser.id })), + }) + ); + const returnedUser = { id: updatedUser.id, createdAt: updatedUser.createdAt, diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts index 745f435603ee..7f64363f0eff 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts @@ -44,7 +44,13 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ organ }); } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Read + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "organizationId", issue: "unauthorized" }], @@ -84,7 +90,13 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza ); } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { @@ -160,7 +172,13 @@ export const PATCH = async (request: Request, props: { params: Promise<{ organiz ); } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/tests/workspace-teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/tests/workspace-teams.test.ts index 083107b2f073..1451da7ac77d 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/tests/workspace-teams.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/tests/workspace-teams.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { TypeOf } from "zod"; import { prisma } from "@formbricks/database"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { TGetWorkspaceTeamsFilter, TWorkspaceTeamInput, @@ -26,6 +27,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + describe("WorkspaceTeams Lib", () => { beforeEach(() => { vi.clearAllMocks(); @@ -65,6 +70,9 @@ describe("WorkspaceTeams Lib", () => { teamId: "t1", } as TWorkspaceTeamInput & { workspaceId: string }); expect(result.ok).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + workspaceTeamGrants: [{ teamId: "t1", workspaceId: "p1" }], + }); if (result.ok) { expect((result.data as any).id).toBe("ptx"); } @@ -95,6 +103,9 @@ describe("WorkspaceTeams Lib", () => { typeof ZWorkspaceZTeamUpdateSchema >); expect(result.ok).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + workspaceTeamGrants: [{ teamId: "t1", workspaceId: "p1" }], + }); if (result.ok) { expect(result.data.permission).toBe("READ"); } @@ -121,6 +132,9 @@ describe("WorkspaceTeams Lib", () => { }); const result = await deleteWorkspaceTeam("t1", "p1"); expect(result.ok).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + workspaceTeamGrants: [{ teamId: "t1", workspaceId: "p1" }], + }); if (result.ok) { expect(result.data.workspaceId).toBe("p1"); expect(result.data.teamId).toBe("t1"); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/workspace-teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/workspace-teams.ts index 04ea90bea2aa..157c3bb6309e 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/workspace-teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/workspace-teams.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { prisma } from "@formbricks/database"; import { WorkspaceTeam } from "@formbricks/database/prisma"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { getWorkspaceTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/workspace-teams/lib/utils"; import { TGetWorkspaceTeamsFilter, @@ -59,6 +61,12 @@ export const createWorkspaceTeam = async ( }, }); + await runPostCommitProjection("api_v2_workspace_team_create", () => + reconcileTeamWorkspaceRelationships({ + workspaceTeamGrants: [{ teamId, workspaceId }], + }) + ); + return ok(workspaceTeam); } catch (error) { return err({ @@ -86,6 +94,12 @@ export const updateWorkspaceTeam = async ( data: teamInput, }); + await runPostCommitProjection("api_v2_workspace_team_update", () => + reconcileTeamWorkspaceRelationships({ + workspaceTeamGrants: [{ teamId, workspaceId }], + }) + ); + return ok(updatedWorkspaceTeam); } catch (error) { return err({ @@ -111,6 +125,12 @@ export const deleteWorkspaceTeam = async ( }, }); + await runPostCommitProjection("api_v2_workspace_team_delete", () => + reconcileTeamWorkspaceRelationships({ + workspaceTeamGrants: [{ teamId, workspaceId }], + }) + ); + return ok(deletedWorkspaceTeam); } catch (error) { return err({ diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/route.ts index a12db39b1f53..5a4cac18b8ac 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/workspace-teams/route.ts @@ -35,7 +35,13 @@ export async function GET(request: Request, props: { params: Promise<{ organizat }, externalParams: props.params, handler: async ({ parsedInput: { query, params }, authentication }) => { - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Read + )) + ) { return handleApiError(request, { type: "unauthorized", details: [{ field: "organizationId", issue: "unauthorized" }], @@ -81,7 +87,13 @@ export async function POST(request: Request, props: { params: Promise<{ organiza auditLog.targetId = `${workspaceId}-${teamId}`; } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { @@ -166,7 +178,13 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat auditLog.targetId = `${workspaceId}-${teamId}`; } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { @@ -247,7 +265,13 @@ export async function DELETE(request: Request, props: { params: Promise<{ organi auditLog.targetId = `${workspaceId}-${teamId}`; } - if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + if ( + !(await hasOrganizationIdAndAccess( + params!.organizationId, + authentication, + OrganizationAccessType.Write + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/auth/invite/lib/team.test.ts b/apps/web/modules/auth/invite/lib/team.test.ts index 08fcd574474a..5a12c2e8eaa7 100644 --- a/apps/web/modules/auth/invite/lib/team.test.ts +++ b/apps/web/modules/auth/invite/lib/team.test.ts @@ -1,7 +1,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { OrganizationRole, Prisma } from "@formbricks/database/prisma"; +import { + OrganizationRole, + Prisma, + type Team, + type TeamUser, + TeamUserRole, +} from "@formbricks/database/prisma"; import { DatabaseError } from "@formbricks/types/errors"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { createTeamMembership } from "./team"; vi.mock("@formbricks/database", () => ({ @@ -10,11 +17,15 @@ vi.mock("@formbricks/database", () => ({ findUnique: vi.fn(), }, teamUser: { - create: vi.fn(), + upsert: vi.fn(), }, }, })); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + describe("createTeamMembership", () => { const mockInvite = { teamIds: ["team1", "team2"], @@ -22,6 +33,13 @@ describe("createTeamMembership", () => { organizationId: "org1", }; const mockUserId = "user1"; + const mockTeamUser = { + createdAt: new Date(), + role: TeamUserRole.contributor, + teamId: "team1", + updatedAt: new Date(), + userId: mockUserId, + } satisfies TeamUser; beforeEach(() => { vi.clearAllMocks(); @@ -29,16 +47,22 @@ describe("createTeamMembership", () => { test("creates team memberships and revalidates caches", async () => { const mockTeam = { - workspaceTeams: [{ workspaceId: "workspace1" }], - }; + id: "team1", + } satisfies Pick; - vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam as any); - vi.mocked(prisma.teamUser.create).mockResolvedValue({} as any); + vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam as never); + vi.mocked(prisma.teamUser.upsert).mockResolvedValue(mockTeamUser); await createTeamMembership(mockInvite, mockUserId); expect(prisma.team.findUnique).toHaveBeenCalledTimes(2); - expect(prisma.teamUser.create).toHaveBeenCalledTimes(2); + expect(prisma.teamUser.upsert).toHaveBeenCalledTimes(2); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [ + { teamId: "team1", userId: mockUserId }, + { teamId: "team2", userId: mockUserId }, + ], + }); }); test("handles database errors", async () => { @@ -49,5 +73,21 @@ describe("createTeamMembership", () => { vi.mocked(prisma.team.findUnique).mockRejectedValue(dbError); await expect(createTeamMembership(mockInvite, mockUserId)).rejects.toThrow(DatabaseError); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamMemberships: [] }); + }); + + test("reconciles successfully committed pairs before propagating a later source failure", async () => { + const mockTeam = { id: "team" } satisfies Pick; + + vi.mocked(prisma.team.findUnique).mockResolvedValue(mockTeam as never); + vi.mocked(prisma.teamUser.upsert) + .mockResolvedValueOnce(mockTeamUser) + .mockRejectedValueOnce(new Error("second write failed")); + + await expect(createTeamMembership(mockInvite, mockUserId)).rejects.toThrow("second write failed"); + + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [{ teamId: "team1", userId: mockUserId }], + }); }); }); diff --git a/apps/web/modules/auth/invite/lib/team.ts b/apps/web/modules/auth/invite/lib/team.ts index 21d3caa6c6d1..1a7001ce5798 100644 --- a/apps/web/modules/auth/invite/lib/team.ts +++ b/apps/web/modules/auth/invite/lib/team.ts @@ -2,6 +2,8 @@ import "server-only"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError } from "@formbricks/types/errors"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { getAccessFlags } from "@/lib/membership/utils"; import { CreateMembershipInvite } from "@/modules/auth/invite/types/invites"; @@ -10,8 +12,7 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI const userMembershipRole = invite.role; const { isOwner, isManager } = getAccessFlags(userMembershipRole); - const validTeamIds: string[] = []; - const validWorkspaceIds: string[] = []; + const committedTeamIds: string[] = []; const isOwnerOrManager = isOwner || isManager; try { @@ -21,25 +22,29 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI id: teamId, }, select: { - workspaceTeams: { - select: { - workspaceId: true, - }, - }, + id: true, }, }); if (team) { - await prisma.teamUser.create({ - data: { + await prisma.teamUser.upsert({ + create: { teamId, userId, role: isOwnerOrManager ? "admin" : "contributor", }, + update: { + role: isOwnerOrManager ? "admin" : "contributor", + }, + where: { + teamId_userId: { + teamId, + userId, + }, + }, }); - validTeamIds.push(teamId); - validWorkspaceIds.push(...team.workspaceTeams.map((pt) => pt.workspaceId)); + committedTeamIds.push(teamId); } } } catch (error) { @@ -48,5 +53,11 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI } throw error; + } finally { + await runPostCommitProjection("invite_team_membership_create", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: committedTeamIds.map((teamId) => ({ teamId, userId })), + }) + ); } }; diff --git a/apps/web/modules/auth/lib/oauth-urls.test.ts b/apps/web/modules/auth/lib/oauth-urls.test.ts index 3444cf40ae17..8eba288daafa 100644 --- a/apps/web/modules/auth/lib/oauth-urls.test.ts +++ b/apps/web/modules/auth/lib/oauth-urls.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const envMock = { BETTER_AUTH_URL: undefined as string | undefined, + MCP_OAUTH_JWKS_URL: undefined as string | undefined, NEXTAUTH_URL: undefined as string | undefined, PUBLIC_URL: undefined as string | undefined, WEBAPP_URL: undefined as string | undefined, @@ -19,6 +20,7 @@ const loadOAuthUrls = async () => { describe("OAuth URL helpers", () => { beforeEach(() => { envMock.BETTER_AUTH_URL = undefined; + envMock.MCP_OAUTH_JWKS_URL = undefined; envMock.NEXTAUTH_URL = undefined; envMock.PUBLIC_URL = undefined; envMock.WEBAPP_URL = undefined; @@ -86,4 +88,22 @@ describe("OAuth URL helpers", () => { expect(getOAuthUserInfoUrl()).toBe("https://auth.example.com/custom/api/auth/oauth2/userinfo"); expect(getOAuthUserInfoUrl()).toBe(`${getAuthIssuerUrl()}/oauth2/userinfo`); }); + + test("derives the JWKS URL from the public issuer by default", async () => { + envMock.BETTER_AUTH_URL = "https://auth.example.com"; + + const { getMcpOAuthJwksUrl } = await loadOAuthUrls(); + + expect(getMcpOAuthJwksUrl()).toBe("https://auth.example.com/api/auth/jwks"); + }); + + test("uses an internal JWKS URL without changing the public issuer", async () => { + envMock.BETTER_AUTH_URL = "https://auth.example.com"; + envMock.MCP_OAUTH_JWKS_URL = "http://formbricks:3000/api/auth/jwks"; + + const { getAuthIssuerUrl, getMcpOAuthJwksUrl } = await loadOAuthUrls(); + + expect(getAuthIssuerUrl()).toBe("https://auth.example.com/api/auth"); + expect(getMcpOAuthJwksUrl()).toBe("http://formbricks:3000/api/auth/jwks"); + }); }); diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts index 68dc8eb2048e..bd5aa0965e05 100644 --- a/apps/web/modules/auth/lib/oauth-urls.ts +++ b/apps/web/modules/auth/lib/oauth-urls.ts @@ -33,6 +33,15 @@ export const getAuthIssuerUrl = (): string => { return appendPath(authBaseUrl, AUTH_BASE_PATH); }; +/** + * Returns the server-side endpoint used only to fetch Better Auth's signing keys. + * + * Token issuer validation, OAuth discovery, redirects, cookies, and audiences continue to use the public + * Auth/WEBAPP URLs. Deployments whose pods cannot resolve or hairpin through that public origin can point this + * fetch at an internal service without changing the externally visible OAuth contract. + */ +export const getMcpOAuthJwksUrl = (): string => env.MCP_OAUTH_JWKS_URL ?? `${getAuthIssuerUrl()}/jwks`; + export const getMcpResourceUrl = (): string => appendPath(getWebAppBaseUrl(), MCP_RESOURCE_PATH); export const getMcpProtectedResourceMetadataUrl = (): string => diff --git a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts index ea2b7ad6c7b7..73dab8df3ed3 100644 --- a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts +++ b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts @@ -2,6 +2,7 @@ import { MOCK_IDS, MOCK_INVITE, MOCK_TEAM_USER } from "./__mocks__/team-mocks"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { OrganizationRole } from "@formbricks/database/prisma"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites"; import { createTeamMembership, getTeamForOrganization } from "../team"; @@ -28,6 +29,10 @@ const setupMocks = () => { getMembershipByUserIdOrganizationId: vi.fn(), })); + vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), + })); + vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), @@ -88,6 +93,9 @@ describe("Team Management", () => { }, }, }); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [{ teamId: MOCK_IDS.teamId, userId: MOCK_IDS.userId }], + }); }); }); @@ -168,6 +176,25 @@ describe("Team Management", () => { }); }); }); + + test("defers projection when called inside an outer transaction", async () => { + const transaction = { + team: { + findUnique: vi.fn().mockResolvedValue(mockTeamLookup), + }, + teamUser: { + upsert: vi.fn().mockResolvedValue(MOCK_TEAM_USER), + }, + } as any; + + await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId, { + projection: "deferred", + transaction, + }); + + expect(transaction.teamUser.upsert).toHaveBeenCalled(); + expect(reconcileTeamWorkspaceRelationships).not.toHaveBeenCalled(); + }); }); describe("getTeamForOrganization", () => { diff --git a/apps/web/modules/auth/signup/lib/team.ts b/apps/web/modules/auth/signup/lib/team.ts index 4df13d6f82a1..a126d936e8b5 100644 --- a/apps/web/modules/auth/signup/lib/team.ts +++ b/apps/web/modules/auth/signup/lib/team.ts @@ -4,11 +4,17 @@ import { prisma } from "@formbricks/database"; import { Prisma, PrismaClient, Team } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { getAccessFlags } from "@/lib/membership/utils"; import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites"; type TTeamDbClient = PrismaClient | Prisma.TransactionClient; type TTeamMembershipTarget = Pick; +type TDeferredTeamMembershipProjection = Readonly<{ + projection: "deferred"; + transaction: Prisma.TransactionClient; +}>; const getDbClient = (tx?: Prisma.TransactionClient): TTeamDbClient => tx ?? prisma; @@ -41,18 +47,20 @@ const getTeamForOrganizationCached = reactCache(async (teamId: string, organizat export const createTeamMembership = async ( invite: CreateMembershipInvite, userId: string, - tx?: Prisma.TransactionClient + options?: TDeferredTeamMembershipProjection ): Promise => { const teamIds = invite.teamIds || []; + const committedTeamIds: string[] = []; const userMembershipRole = invite.role; const { isOwner, isManager } = getAccessFlags(userMembershipRole); const isOwnerOrManager = isOwner || isManager; try { - const prismaClient = getDbClient(tx); + const transaction = options?.transaction; + const prismaClient = getDbClient(transaction); for (const teamId of teamIds) { - const team = await getTeamForOrganization(teamId, invite.organizationId, tx); + const team = await getTeamForOrganization(teamId, invite.organizationId, transaction); if (!team) { logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance"); @@ -75,6 +83,7 @@ export const createTeamMembership = async ( }, }, }); + committedTeamIds.push(teamId); } } catch (error) { logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`); @@ -83,6 +92,14 @@ export const createTeamMembership = async ( } throw error; + } finally { + if (!options) { + await runPostCommitProjection("signup_team_membership_create", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: committedTeamIds.map((teamId) => ({ teamId, userId })), + }) + ); + } } }; diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts index 41ce037bad00..ba8bd36ee5ad 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts @@ -102,6 +102,12 @@ describe("rateLimitConfigs", () => { "bulkInviteMembers", "generateExampleResponses", "integrationMutation", + "feedbackSourceMutation", + "historicalResponseImport", + "chartCreation", + "feedbackDirectoryMutation", + "feedbackRecordDeletion", + "stateMutation", ]); // Exact values, not just presence: this quota is the only thing bounding one account from @@ -112,6 +118,16 @@ describe("rateLimitConfigs", () => { allowedPerInterval: 30, namespace: "action:unsplash", }); + expect(rateLimitConfigs.actions.historicalResponseImport).toEqual({ + interval: 3600, + allowedPerInterval: 10, + namespace: "action:historical-response-import", + }); + expect(rateLimitConfigs.actions.stateMutation).toEqual({ + interval: 60, + allowedPerInterval: 120, + namespace: "action:state-mutation", + }); }); test("should have all storage configurations", () => { diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.ts index 916f2df0e3eb..0c795c7ff41c 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.ts @@ -67,6 +67,36 @@ export const rateLimitConfigs = { }, // 30 per minute per user — one save or delete per UI interaction, so this bounds a readWrite // member churning integration rows (each write hits the provider config and the audit log) without // getting in the way of legitimate mapping edits + feedbackSourceMutation: { + interval: 60, + allowedPerInterval: 60, + namespace: "action:feedback-source-mutation", + }, // 60 per minute per user + historicalResponseImport: { + interval: 3600, + allowedPerInterval: 10, + namespace: "action:historical-response-import", + }, // 10 per hour per user — bounds repeated full-survey imports + chartCreation: { + interval: 60, + allowedPerInterval: 60, + namespace: "action:chart-creation", + }, // 60 per minute per user + feedbackDirectoryMutation: { + interval: 60, + allowedPerInterval: 60, + namespace: "action:feedback-directory-mutation", + }, // 60 per minute per user + feedbackRecordDeletion: { + interval: 60, + allowedPerInterval: 100, + namespace: "action:feedback-record-deletion", + }, // 100 per minute per user — supports deliberate bulk deletion while bounding abuse + stateMutation: { + interval: 60, + allowedPerInterval: 120, + namespace: "action:state-mutation", + }, // 120 per minute per organization/workspace — shared guard for authenticated settings writes }, storage: { diff --git a/apps/web/modules/ee/ai-translation/lib/actions.ts b/apps/web/modules/ee/ai-translation/lib/actions.ts index 15e5e95c1bf1..fd8e215b5910 100644 --- a/apps/web/modules/ee/ai-translation/lib/actions.ts +++ b/apps/web/modules/ee/ai-translation/lib/actions.ts @@ -3,8 +3,8 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { assertOrganizationAIConfigured, getOrganizationAIConfig } from "@/lib/ai/service"; +import { assertCan } from "@/lib/authorization"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromSurveyId, getOrganizationIdFromWorkspaceId, @@ -20,20 +20,9 @@ export const checkAITranslationAvailableAction = authenticatedActionClient .inputSchema(ZCheckAITranslationAvailableAction) .action(async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); const aiConfig = await getOrganizationAIConfig(organizationId); @@ -65,20 +54,9 @@ export const translateSurveyFieldsAction = authenticatedActionClient .action(async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); await assertOrganizationAIConfigured(organizationId); diff --git a/apps/web/modules/ee/analysis/charts/actions.test.ts b/apps/web/modules/ee/analysis/charts/actions.test.ts index cb9ec3d624b7..d6bb3e85079a 100644 --- a/apps/web/modules/ee/analysis/charts/actions.test.ts +++ b/apps/web/modules/ee/analysis/charts/actions.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { executeQueryAction as executeQueryActionExport, generateAIChartAction } from "./actions"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { + createChartAction, + executeQueryAction as executeQueryActionExport, + generateAIChartAction, +} from "./actions"; // The action-client mock below turns `.inputSchema(...).action(fn)` into the identity, so the // export IS the raw handler at runtime — re-type it accordingly (the SafeActionResult type on the @@ -26,6 +31,7 @@ const mocks = vi.hoisted(() => { executeTenantScopedQuery: vi.fn(), generateAIChartQuery: vi.fn(), updateChart: vi.fn(), + applyRateLimit: vi.fn(), getFeedbackSourcesWithMappings: vi.fn(), getSurvey: vi.fn(), getElementsFromBlocks: vi.fn(), @@ -40,6 +46,8 @@ vi.mock("@/lib/utils/action-client", () => ({ }, })); +vi.mock("@/modules/core/rate-limit/helpers", () => ({ applyRateLimit: mocks.applyRateLimit })); + vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), @@ -142,9 +150,9 @@ describe("chart Cube actions", () => { expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith("user-1", "workspace-1", "read"); expect(mocks.checkFeedbackDirectoryAccess).toHaveBeenCalledWith({ feedbackDirectoryId: "frd-1", - organizationId: "organization-1", workspaceId: "workspace-1", userId: "user-1", + minPermission: "read", source: "charts.executeQueryAction", }); expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith({ @@ -157,6 +165,25 @@ describe("chart Cube actions", () => { }); }); + test("createChartAction applies the chart creation rate limit", async () => { + await createChartAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + chartInput: { + name: "Chart", + type: "bar", + query: { measures: ["FeedbackRecords.count"] }, + config: {}, + feedbackDirectoryId: "frd-1", + }, + }, + } as any); + + expect(mocks.applyRateLimit).toHaveBeenCalledWith(rateLimitConfigs.actions.chartCreation, "user-1"); + expect(mocks.createChart).toHaveBeenCalled(); + }); + test("executeQueryAction does not delegate before workspace authorization succeeds", async () => { mocks.checkWorkspaceAccess.mockRejectedValueOnce(new Error("forbidden")); diff --git a/apps/web/modules/ee/analysis/charts/actions.ts b/apps/web/modules/ee/analysis/charts/actions.ts index eb4b77058a00..9e7ec3589783 100644 --- a/apps/web/modules/ee/analysis/charts/actions.ts +++ b/apps/web/modules/ee/analysis/charts/actions.ts @@ -7,6 +7,8 @@ import { OperationNotAllowedError } from "@formbricks/types/errors"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { executeTenantScopedQuery } from "@/modules/ee/analysis/api/lib/cube-client"; import { generateAIChartQuery } from "@/modules/ee/analysis/charts/lib/ai-chart-query.server"; import { @@ -55,6 +57,8 @@ export const createChartAction = authenticatedActionClient.inputSchema(ZCreateCh ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; }) => { + ctx.auditLoggingCtx.workspaceId = parsedInput.workspaceId; + await applyRateLimit(rateLimitConfigs.actions.chartCreation, ctx.user.id); const { organizationId, workspaceId } = await checkWorkspaceAccess( ctx.user.id, parsedInput.workspaceId, @@ -64,9 +68,9 @@ export const createChartAction = authenticatedActionClient.inputSchema(ZCreateCh await checkFeedbackDirectoryAccess({ feedbackDirectoryId: parsedInput.chartInput.feedbackDirectoryId, - organizationId, workspaceId, userId: ctx.user.id, + minPermission: "readWrite", source: "charts.createChartAction", }); @@ -278,9 +282,9 @@ export const executeQueryAction = authenticatedActionClient const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({ feedbackDirectoryId: parsedInput.feedbackDirectoryId, - organizationId, workspaceId, userId: ctx.user.id, + minPermission: "read", source: "charts.executeQueryAction", }); @@ -327,9 +331,9 @@ export const generateAIChartAction = authenticatedActionClient const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({ feedbackDirectoryId: parsedInput.feedbackDirectoryId, - organizationId, workspaceId, userId: ctx.user.id, + minPermission: "read", source: "charts.generateAIChartAction", }); @@ -400,9 +404,9 @@ export const getDimensionValuesAction = authenticatedActionClient const { feedbackDirectoryId } = await checkFeedbackDirectoryAccess({ feedbackDirectoryId: parsedInput.feedbackDirectoryId, - organizationId, workspaceId, userId: ctx.user.id, + minPermission: "read", source: "charts.getDimensionValuesAction", }); diff --git a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx index 0738dabd29d4..db0434b97231 100644 --- a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx +++ b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx @@ -9,6 +9,7 @@ import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures- import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel"; import { useChartQuery } from "@/modules/ee/analysis/charts/hooks/use-chart-query"; import { prepareQueryForChartType } from "@/modules/ee/analysis/charts/lib/big-number"; +import { supportsTimeGrouping } from "@/modules/ee/analysis/charts/lib/chart-display"; import { type ChartBuilderState, type FilterRow, @@ -119,6 +120,19 @@ export function AdvancedChartBuilder({ ); const timeDimensionOpen = state.timeDimension != null; const filtersOpen = state.filters.length > 0; + const timeGroupingSupported = supportsTimeGrouping(chartType); + + // Switching to a chart type that doesn't support time grouping (Big Number, Pie) drops the + // granularity left over from a previous type, matching how sanitizeChartDisplay drops other + // per-type display settings rather than saving them as dead values. The time dimension itself is + // kept: with no granularity it is the chart's date-range filter (see TimeDimensionConfig), not a + // grouping, and stripping it would silently widen the chart to all-time. + useEffect(() => { + if (!timeGroupingSupported && state.timeDimension?.granularity) { + const { granularity: _granularity, ...rest } = state.timeDimension; + dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: rest }); + } + }, [timeGroupingSupported, state.timeDimension]); // The executed query depends on the chart type as well as the form: a big number has nowhere to // put groups, so its query drops them (see prepareQueryForChartType). Switching the chart type @@ -267,13 +281,22 @@ export function AdvancedChartBuilder({ } }} htmlId="chart-time-dimension-toggle" - title={t("workspace.analysis.charts.time_dimension_title")} - description={t("workspace.analysis.charts.time_dimension_toggle_description")} + title={ + timeGroupingSupported + ? t("workspace.analysis.charts.time_dimension_title") + : t("workspace.analysis.charts.time_dimension_title_range_only") + } + description={ + timeGroupingSupported + ? t("workspace.analysis.charts.time_dimension_toggle_description") + : t("workspace.analysis.charts.time_dimension_toggle_description_range_only") + } customContainerClass="mt-2 px-0" childrenContainerClass="flex-col gap-3 p-4" childBorder> dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: config })} /> diff --git a/apps/web/modules/ee/analysis/charts/components/chart-display-settings.tsx b/apps/web/modules/ee/analysis/charts/components/chart-display-settings.tsx index cf51709ce9fe..74764de7e3b6 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-display-settings.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-display-settings.tsx @@ -1,13 +1,22 @@ "use client"; -import { ChartBarIcon, ChartColumnIcon, ChartPieIcon, RectangleHorizontalIcon } from "lucide-react"; +import { + AreaChartIcon, + ChartBarIcon, + ChartColumnIcon, + ChartPieIcon, + LineChartIcon, + RectangleHorizontalIcon, +} from "lucide-react"; import { useId } from "react"; import { useTranslation } from "react-i18next"; import type { TChartConfig } from "@formbricks/types/analysis"; import { + type TAreaDisplay, type TBarOrientation, type TPieDisplay, resolveChartDisplay, + supportsAreaDisplay, supportsBarOrientation, supportsPieDisplay, } from "@/modules/ee/analysis/charts/lib/chart-display"; @@ -28,15 +37,17 @@ interface ChartDisplaySettingsProps { */ export function ChartDisplaySettings({ chartType, config, onChange }: Readonly) { const { t } = useTranslation(); - const { barOrientation, pieDisplay } = resolveChartDisplay(config); + const { barOrientation, pieDisplay, areaDisplay } = resolveChartDisplay(config); const showBarOrientation = supportsBarOrientation(chartType); const showPieDisplay = supportsPieDisplay(chartType); + const showAreaDisplay = supportsAreaDisplay(chartType); // Generated rather than hardcoded: two of these panels on one page would otherwise share ids. const barOrientationLabelId = useId(); const pieDisplayLabelId = useId(); + const areaDisplayLabelId = useId(); // For a chart type with no applicable setting the section would be a heading with nothing under it. - if (!showBarOrientation && !showPieDisplay) return null; + if (!showBarOrientation && !showPieDisplay && !showAreaDisplay) return null; return (
@@ -45,6 +56,28 @@ export function ChartDisplaySettings({ chartType, config, onChange }: Readonly
+ {showAreaDisplay && ( +
+ + , + }, + { + value: "line", + label: t("workspace.analysis.charts.area_display_line"), + icon: , + }, + ]} + currentOption={areaDisplay} + handleOptionChange={(value) => onChange({ ...config, areaDisplay: value as TAreaDisplay })} + /> +
+ )} {showPieDisplay && (
diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index e88336e77f80..7b7e4efedaca 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -365,7 +365,7 @@ export function ChartRenderer({ config, }: Readonly) { const { t } = useTranslation(); - const { barOrientation, pieDisplay } = resolveChartDisplay(config); + const { barOrientation, pieDisplay, areaDisplay } = resolveChartDisplay(config); // Unique across charts on the same page so SVG ids don't collide. const gradientIdPrefix = useId(); @@ -447,8 +447,10 @@ export function ChartRenderer({ isHorizontal={barOrientation === "horizontal"} /> ); - case "line": - // AreaChart with a thin stroke + gradient fade reads as a line with a soft tint. + // Line is a display style of this type, not a type of its own: both render the same Recharts + // area series over the same axes and differ only in how the band under the stroke is painted. + case "area": { + const isLine = areaDisplay === "line"; return ( - - {dataKeys.map((key) => { - const color = chartConfig[key]?.color; - return ( - - - - - ); - })} - + {isLine ? ( + + {dataKeys.map((key) => { + const color = chartConfig[key]?.color; + return ( + + + + + ); + })} + + ) : null} {dataKeys.map((key) => { const color = chartConfig[key]?.color; return ( @@ -480,9 +490,13 @@ export function ChartRenderer({ dataKey={key} stroke={color} strokeWidth={2} - fill={`url(#${gradientIdPrefix}-line-${key})`} + // A thin stroke over a gradient that fades to nothing reads as a line with a soft + // tint; a flat fill reads as an area. 0.6 is Recharts' own default, spelled out + // here so the line style keeps the tint it had as a separate chart type. + fill={isLine ? `url(#${gradientIdPrefix}-line-${key})` : color} + fillOpacity={isLine ? 0.6 : 0.4} dot={false} - activeDot={{ r: 5, stroke: color, strokeWidth: 2, fill: "#fff" }} + activeDot={isLine ? { r: 5, stroke: color, strokeWidth: 2, fill: "#fff" } : undefined} // Cube returns null for empty buckets; render them as gaps, not a dip to zero. connectNulls={false} /> @@ -490,32 +504,7 @@ export function ChartRenderer({ })} ); - case "area": - return ( - - {dataKeys.map((key) => ( - - ))} - - ); + } case "pie": // A pie and a breakdown bar answer the same question — the share each group takes of the // whole — so they are two renderings of one chart type rather than two chart types. diff --git a/apps/web/modules/ee/analysis/charts/components/charts-list-page.tsx b/apps/web/modules/ee/analysis/charts/components/charts-list-page.tsx index d9f8a420ec60..7a08a343e162 100644 --- a/apps/web/modules/ee/analysis/charts/components/charts-list-page.tsx +++ b/apps/web/modules/ee/analysis/charts/components/charts-list-page.tsx @@ -51,7 +51,7 @@ interface ChartsListPageProps { export async function ChartsListPage({ workspaceId }: Readonly) { const t = await getTranslate(); - const { isReadOnly, organization, isOwner, isManager } = await getWorkspaceAuth(workspaceId); + const { isReadOnly, organization, isOwner, isManager, session } = await getWorkspaceAuth(workspaceId); const isDashboardsAllowed = await getIsDashboardsEnabled(organization.id); if (!isDashboardsAllowed) { @@ -81,7 +81,7 @@ export async function ChartsListPage({ workspaceId }: Readonly ({ value: m.id, label: getTranslatedFieldLabel(m.id, t), - description: m.description, + description: getTranslatedFieldDescription(m.id, m.description, t), group: groupMeta[group].label, groupIcon: groupMeta[group].icon, })) diff --git a/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx b/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx index 8fb5e50304f6..73452c46316b 100644 --- a/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx +++ b/apps/web/modules/ee/analysis/charts/components/time-dimension-panel.tsx @@ -36,12 +36,20 @@ interface TimeDimensionPanelProps { timeDimension: TimeDimensionConfig | null; onTimeDimensionChange: (config: TimeDimensionConfig | null) => void; hideTitle?: boolean; + /** + * Hides the granularity selector for chart types that can't render a time series (Big Number, + * Pie — see supportsTimeGrouping). The field and date-range controls stay: with no granularity a + * time dimension is a date-range filter, not grouping, and is still the only way to scope those + * chart types to a rolling window. + */ + hideGranularity?: boolean; } export function TimeDimensionPanel({ timeDimension, onTimeDimensionChange, hideTitle = false, + hideGranularity = false, }: Readonly) { const { t } = useTranslation(); const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">( @@ -150,22 +158,24 @@ export function TimeDimensionPanel({
{/* Granularity Selector */} -
- - -
+ {!hideGranularity && ( +
+ + +
+ )} {/* Date Range */}
diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts index d549b2c864ec..cea42e59a076 100644 --- a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts @@ -159,7 +159,7 @@ describe("generateAIChartQuery", () => { { dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 30 days" }, { dimension: "FeedbackRecords.collectedAt", granularity: null, dateRange: null }, ], - chartType: "line", + chartType: "area", filters: [ { member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }, { member: "FeedbackRecords.sourceType", operator: "set", values: null }, diff --git a/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts b/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts index 23e1f59b652e..6257b1198080 100644 --- a/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts @@ -58,7 +58,7 @@ describe("prepareQueryForChartType", () => { }); }); - test.each(["line", "area", "bar", "pie"] as const)("leaves a %s chart grouped", (chartType) => { + test.each(["area", "bar", "pie"] as const)("leaves a %s chart grouped", (chartType) => { expect(prepareQueryForChartType(grouped, chartType)).toBe(grouped); }); }); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts index bcbce4acd887..9e3d6bafb63f 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts @@ -2,21 +2,25 @@ import { describe, expect, test } from "vitest"; import { resolveChartDisplay, sanitizeChartDisplay, + supportsAreaDisplay, supportsBarOrientation, supportsPieDisplay, + supportsTimeGrouping, } from "./chart-display"; describe("resolveChartDisplay", () => { test("falls back to vertical bars for charts saved before these settings existed", () => { - expect(resolveChartDisplay({})).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); - expect(resolveChartDisplay(undefined)).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); - expect(resolveChartDisplay(null)).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + const defaults = { barOrientation: "vertical", pieDisplay: "pie", areaDisplay: "filled" }; + expect(resolveChartDisplay({})).toEqual(defaults); + expect(resolveChartDisplay(undefined)).toEqual(defaults); + expect(resolveChartDisplay(null)).toEqual(defaults); }); test("returns the saved setting", () => { expect(resolveChartDisplay({ barOrientation: "horizontal" })).toEqual({ barOrientation: "horizontal", pieDisplay: "pie", + areaDisplay: "filled", }); }); }); @@ -25,7 +29,6 @@ describe("supportsBarOrientation", () => { test("only bar charts have an orientation", () => { expect(supportsBarOrientation("bar")).toBe(true); expect(supportsBarOrientation("area")).toBe(false); - expect(supportsBarOrientation("line")).toBe(false); expect(supportsBarOrientation("pie")).toBe(false); expect(supportsBarOrientation("big_number")).toBe(false); expect(supportsBarOrientation(undefined)).toBe(false); @@ -96,3 +99,59 @@ describe("pie display", () => { }); }); }); + +describe("area display", () => { + test("falls back to the filled area for charts saved before Line merged into Area", () => { + expect(resolveChartDisplay({}).areaDisplay).toBe("filled"); + expect(resolveChartDisplay(undefined).areaDisplay).toBe("filled"); + }); + + test("returns the saved setting", () => { + expect(resolveChartDisplay({ areaDisplay: "line" }).areaDisplay).toBe("line"); + }); + + test("only an area chart supports it", () => { + expect(supportsAreaDisplay("area")).toBe(true); + expect(supportsAreaDisplay("bar")).toBe(false); + expect(supportsAreaDisplay("pie")).toBe(false); + expect(supportsAreaDisplay("big_number")).toBe(false); + expect(supportsAreaDisplay(undefined)).toBe(false); + }); + + test("keeps the setting for an area chart and drops it for anything else", () => { + expect(sanitizeChartDisplay({ areaDisplay: "line" }, "area")).toEqual({ areaDisplay: "line" }); + expect(sanitizeChartDisplay({ areaDisplay: "line" }, "bar")).toEqual({}); + }); + + test("each chart type keeps only its own setting", () => { + expect(sanitizeChartDisplay({ areaDisplay: "line", barOrientation: "horizontal" }, "area")).toEqual({ + areaDisplay: "line", + }); + expect(sanitizeChartDisplay({ areaDisplay: "line", barOrientation: "horizontal" }, "bar")).toEqual({ + barOrientation: "horizontal", + }); + }); + + test("preserves unrelated config either way", () => { + expect(sanitizeChartDisplay({ areaDisplay: "line", showLegend: true }, "area")).toEqual({ + areaDisplay: "line", + showLegend: true, + }); + }); +}); + +describe("supportsTimeGrouping", () => { + test("big number and pie are point-in-time snapshots, not trends", () => { + expect(supportsTimeGrouping("big_number")).toBe(false); + expect(supportsTimeGrouping("pie")).toBe(false); + }); + + test("bar and area/line can show a trend over time", () => { + expect(supportsTimeGrouping("bar")).toBe(true); + expect(supportsTimeGrouping("area")).toBe(true); + }); + + test("defaults to supported when the chart type is unknown", () => { + expect(supportsTimeGrouping(undefined)).toBe(true); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-display.ts b/apps/web/modules/ee/analysis/charts/lib/chart-display.ts index eab23ed4b811..1979dd3362e8 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-display.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-display.ts @@ -3,15 +3,33 @@ import type { TChartType } from "@/modules/ee/analysis/types/analysis"; export type TBarOrientation = NonNullable; export type TPieDisplay = NonNullable; +export type TAreaDisplay = NonNullable; /** Charts render with vertical bars unless the saved config says otherwise. */ export const DEFAULT_BAR_ORIENTATION: TBarOrientation = "vertical"; /** A pie chart renders as a pie unless the saved config says otherwise. */ export const DEFAULT_PIE_DISPLAY: TPieDisplay = "pie"; +/** An area chart renders as a filled band unless the saved config says otherwise. */ +export const DEFAULT_AREA_DISPLAY: TAreaDisplay = "filled"; /** Each setting so far belongs to exactly one chart type. */ export const supportsBarOrientation = (chartType: TChartType | undefined): boolean => chartType === "bar"; export const supportsPieDisplay = (chartType: TChartType | undefined): boolean => chartType === "pie"; +export const supportsAreaDisplay = (chartType: TChartType | undefined): boolean => chartType === "area"; + +/** + * Big Number shows a single snapshot value and Pie shows composition at a point in time — bucketing + * either into time series by granularity produces a chart that no longer answers the question its + * type implies, which is what ENG-2541 flagged as confusing. Bar and Line/Area keep it: a trend over + * time is exactly what those types are for. + * + * Only gates *grouping* (the granularity control). A time dimension with no granularity is a + * date-range filter, not grouping — see `TimeDimensionConfig` — and stays available on every chart + * type, including Big Number and Pie, since that's the only way to scope those to a rolling window + * (the filters panel only supports absolute dates). + */ +export const supportsTimeGrouping = (chartType: TChartType | undefined): boolean => + chartType !== "big_number" && chartType !== "pie"; /** * Resolves the display settings a chart renders with. Charts saved before these settings @@ -19,9 +37,10 @@ export const supportsPieDisplay = (chartType: TChartType | undefined): boolean = */ export const resolveChartDisplay = ( config: TChartConfig | null | undefined -): { barOrientation: TBarOrientation; pieDisplay: TPieDisplay } => ({ +): { barOrientation: TBarOrientation; pieDisplay: TPieDisplay; areaDisplay: TAreaDisplay } => ({ barOrientation: config?.barOrientation ?? DEFAULT_BAR_ORIENTATION, pieDisplay: config?.pieDisplay ?? DEFAULT_PIE_DISPLAY, + areaDisplay: config?.areaDisplay ?? DEFAULT_AREA_DISPLAY, }); /** @@ -33,11 +52,12 @@ export const sanitizeChartDisplay = ( config: TChartConfig | null | undefined, chartType: TChartType | undefined ): TChartConfig => { - const { barOrientation, pieDisplay, ...rest } = config ?? {}; + const { barOrientation, pieDisplay, areaDisplay, ...rest } = config ?? {}; return { ...rest, ...(supportsBarOrientation(chartType) && barOrientation ? { barOrientation } : {}), ...(supportsPieDisplay(chartType) && pieDisplay ? { pieDisplay } : {}), + ...(supportsAreaDisplay(chartType) && areaDisplay ? { areaDisplay } : {}), }; }; diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-types.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-types.test.ts index 5c141bf6a405..3d56639c8534 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-types.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-types.test.ts @@ -3,15 +3,16 @@ import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types"; describe("chart-types", () => { test("CHART_TYPE_ICONS has all chart types", () => { - expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]); + expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "pie", "big_number"]); }); test("getChartTypes returns chart types with translated labels", () => { const t = vi.fn((key: string) => key) as unknown as Parameters[0]; const result = getChartTypes(t); - expect(result).toHaveLength(5); - expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]); + expect(result).toHaveLength(4); + // Line is a display style of "area", not an entry of its own. + expect(result.map((r) => r.id)).toEqual(["area", "bar", "pie", "big_number"]); expect(t).toHaveBeenCalledWith("workspace.analysis.charts.chart_type_area"); expect(result[0].label).toBe("workspace.analysis.charts.chart_type_area"); }); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-types.ts b/apps/web/modules/ee/analysis/charts/lib/chart-types.ts index 80bea35e699b..87bd2570c928 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-types.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-types.ts @@ -1,5 +1,5 @@ import type { TFunction } from "i18next"; -import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react"; +import { ActivityIcon, AreaChartIcon, BarChart3Icon, PieChartIcon } from "lucide-react"; import type React from "react"; import type { TChartType } from "@/modules/ee/analysis/types/analysis"; @@ -11,7 +11,6 @@ export const CHART_TYPE_ICONS: Record< > = { area: AreaChartIcon, bar: BarChart3Icon, - line: LineChartIcon, pie: PieChartIcon, big_number: ActivityIcon, }; @@ -22,9 +21,10 @@ export function getChartTypes(t: TFunction): readonly { label: string; }[] { return [ + // Line is not a type of its own: it is this type rendered with `config.areaDisplay: "line"`, + // toggled in ChartDisplaySettings. Named for both so it is still findable by "line". { id: "area", icon: CHART_TYPE_ICONS.area, label: t("workspace.analysis.charts.chart_type_area") }, { id: "bar", icon: CHART_TYPE_ICONS.bar, label: t("workspace.analysis.charts.chart_type_bar") }, - { id: "line", icon: CHART_TYPE_ICONS.line, label: t("workspace.analysis.charts.chart_type_line") }, { id: "pie", icon: CHART_TYPE_ICONS.pie, label: t("workspace.analysis.charts.chart_type_pie") }, { id: "big_number", diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts index 1b8218a53206..e21b13e255f4 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts @@ -75,14 +75,19 @@ describe("chart-utils", () => { test("returns valid chart types", () => { expect(resolveChartType("area")).toBe("area"); expect(resolveChartType("bar")).toBe("bar"); - expect(resolveChartType("line")).toBe("line"); expect(resolveChartType("pie")).toBe("pie"); expect(resolveChartType("big_number")).toBe("big_number"); }); + test("maps the retired line type onto area rather than the bar fallback", () => { + expect(resolveChartType("line")).toBe("area"); + }); + test("defaults to bar for invalid type", () => { expect(resolveChartType("invalid")).toBe("bar"); expect(resolveChartType("")).toBe("bar"); + // An inherited Object key must not resolve through the legacy alias lookup. + expect(resolveChartType("constructor")).toBe("bar"); }); }); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts index 0fd8335abb9d..41962ce11061 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts @@ -74,10 +74,19 @@ export const getSentimentMeasureColor = (measureId: string): string | undefined return value ? CHART_SENTIMENT_COLORS[value] : undefined; }; -/** Validate a chart type string, defaulting to "bar" if unrecognized. */ +/** + * Chart types that no longer exist, mapped to the type that replaced them. `line` merged into + * `area` as the `areaDisplay: "line"` style, so anything still holding the old value — an AI + * response, a cached payload — lands on the merged type instead of the "bar" fallback. The + * display style is not recovered here; the migration is what carries it for stored charts. + */ +const LEGACY_CHART_TYPE_ALIASES = new Map([["line", "area"]]); + +/** Validate a chart type string, mapping retired types forward and defaulting to "bar". */ export const resolveChartType = (raw: string): TChartType => { const parsed = ZChartType.safeParse(raw); - return parsed.success ? parsed.data : "bar"; + if (parsed.success) return parsed.data; + return LEGACY_CHART_TYPE_ALIASES.get(raw) ?? "bar"; }; const isNumericValue = (val: unknown): boolean => { diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx index 995d182d1edf..5220486150fb 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx @@ -12,6 +12,7 @@ import type { TChartQuery } from "@formbricks/types/analysis"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog"; import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability"; +import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar"; import { DashboardDateFilter } from "@/modules/ee/analysis/dashboards/components/dashboard-date-filter"; import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header"; @@ -141,7 +142,9 @@ const MemoizedWidgetContent = memo(function WidgetContent({ }> diff --git a/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx b/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx index 1fe8c1aaef3e..d59769a1c363 100644 --- a/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx +++ b/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx @@ -13,8 +13,8 @@ import { resolveOptionGrouping } from "@/modules/ee/analysis/charts/lib/option-g import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout"; import { checkFeedbackDirectoryAccess } from "@/modules/ee/analysis/lib/access"; import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsDashboardsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getAuthorizedWorkspaceFeedbackDirectories } from "@/modules/ee/unify-feedback/lib/access"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; import { DashboardDetailClient } from "../components/dashboard-detail-client"; @@ -47,9 +47,9 @@ async function executeWidgetQuery( try { const tenant = await checkFeedbackDirectoryAccess({ feedbackDirectoryId, - organizationId, workspaceId, userId, + minPermission: "read", source: "dashboards.widget", }); @@ -127,7 +127,7 @@ export async function DashboardDetailPage({ } const [directories, aiConfig] = await Promise.all([ - getFeedbackDirectoriesByWorkspaceId(workspaceId), + getAuthorizedWorkspaceFeedbackDirectories(session.user.id, workspaceId), getOrganizationAIConfig(organization.id), ]); const aiUnavailableReason = getAISmartToolsUnavailableReason(aiConfig); diff --git a/apps/web/modules/ee/analysis/dashboards/pages/dashboards-list-page.tsx b/apps/web/modules/ee/analysis/dashboards/pages/dashboards-list-page.tsx index 64cdf38a6246..c54e9492497b 100644 --- a/apps/web/modules/ee/analysis/dashboards/pages/dashboards-list-page.tsx +++ b/apps/web/modules/ee/analysis/dashboards/pages/dashboards-list-page.tsx @@ -34,7 +34,7 @@ interface DashboardsListPageProps { export const DashboardsListPage = async ({ workspaceId }: Readonly) => { const t = await getTranslate(); - const { isReadOnly, organization, isOwner, isManager } = await getWorkspaceAuth(workspaceId); + const { isReadOnly, organization, isOwner, isManager, session } = await getWorkspaceAuth(workspaceId); const isDashboardsAllowed = await getIsDashboardsEnabled(organization.id); if (!isDashboardsAllowed) { @@ -63,7 +63,7 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly ({})); const mocks = vi.hoisted(() => ({ - checkAuthorizationUpdated: vi.fn(), - getFeedbackDirectoryAuthContext: vi.fn(), + assertCan: vi.fn(), + can: vi.fn(), getOrganizationIdFromWorkspaceId: vi.fn(), - loggerError: vi.fn(), loggerWarn: vi.fn(), })); vi.mock("@formbricks/logger", () => ({ logger: { - error: mocks.loggerError, warn: mocks.loggerWarn, }, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mocks.checkAuthorizationUpdated, -})); +vi.mock("@/lib/authorization", () => ({ assertCan: mocks.assertCan, can: mocks.can })); vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId, })); -vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ - getFeedbackDirectoryAuthContext: mocks.getFeedbackDirectoryAuthContext, -})); - const accessInput = { feedbackDirectoryId: "frd-1", - organizationId: "organization-1", workspaceId: "workspace-1", userId: "user-1", + minPermission: "read" as const, source: "charts.executeQueryAction" as const, }; @@ -47,12 +39,13 @@ const workspaceAccessInput = { beforeEach(() => { vi.clearAllMocks(); + mocks.can.mockResolvedValue(true); }); describe("checkWorkspaceAccess", () => { test("returns organizationId and workspaceId on successful access check", async () => { mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue(workspaceAccessInput.organizationId); - mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); const result = await checkWorkspaceAccess( workspaceAccessInput.userId, @@ -65,19 +58,16 @@ describe("checkWorkspaceAccess", () => { workspaceId: workspaceAccessInput.workspaceId, }); expect(mocks.getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith(workspaceAccessInput.workspaceId); - expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: workspaceAccessInput.userId, - organizationId: workspaceAccessInput.organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", minPermission: "readWrite", workspaceId: workspaceAccessInput.workspaceId }, - ], - }); + expect(mocks.assertCan).toHaveBeenCalledWith( + { type: "user", id: workspaceAccessInput.userId }, + "workspace.write", + { type: "workspace", id: workspaceAccessInput.workspaceId } + ); }); - test("propagates authorization errors from checkAuthorizationUpdated", async () => { + test("propagates central authorization errors", async () => { mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue(workspaceAccessInput.organizationId); - mocks.checkAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized")); + mocks.assertCan.mockRejectedValue(new Error("Unauthorized")); await expect( checkWorkspaceAccess(workspaceAccessInput.userId, workspaceAccessInput.workspaceId, "manage") @@ -87,52 +77,35 @@ describe("checkWorkspaceAccess", () => { describe("checkFeedbackDirectoryAccess", () => { test("returns the feedback directory ID when it belongs to the authorized workspace", async () => { - mocks.getFeedbackDirectoryAuthContext.mockResolvedValue({ - organizationId: "organization-1", - workspaceIds: ["workspace-1"], - isArchived: false, - }); - await expect(checkFeedbackDirectoryAccess(accessInput)).resolves.toEqual({ feedbackDirectoryId: "frd-1", }); + expect(mocks.can).toHaveBeenCalledWith( + { type: "user", id: "user-1" }, + "feedbackDirectoryAssignment.read", + { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: "frd-1", + workspaceId: "workspace-1", + } + ); }); test("rejects inaccessible feedback record directories with an audit-safe warning", async () => { - mocks.getFeedbackDirectoryAuthContext.mockResolvedValue({ - organizationId: "organization-1", - workspaceIds: ["workspace-2"], - isArchived: false, - }); + mocks.can.mockResolvedValue(false); await expect(checkFeedbackDirectoryAccess(accessInput)).rejects.toBeInstanceOf(AuthorizationError); expect(mocks.loggerWarn).toHaveBeenCalledWith( - expect.objectContaining({ - feedbackDirectoryId: "frd-1", - organizationId: "organization-1", - workspaceId: "workspace-1", - userId: "user-1", - source: "charts.executeQueryAction", - }), + { source: "charts.executeQueryAction" }, "Feedback directory access denied for Cube query" ); }); - test("logs unexpected lookup failures before rethrowing", async () => { + test("propagates operational failures without logging identifiers or raw errors", async () => { const error = new Error("database unavailable"); - mocks.getFeedbackDirectoryAuthContext.mockRejectedValue(error); + mocks.can.mockRejectedValue(error); await expect(checkFeedbackDirectoryAccess(accessInput)).rejects.toThrow("database unavailable"); - expect(mocks.loggerError).toHaveBeenCalledWith( - expect.objectContaining({ - error, - feedbackDirectoryId: "frd-1", - organizationId: "organization-1", - workspaceId: "workspace-1", - userId: "user-1", - source: "charts.executeQueryAction", - }), - "Failed to verify feedback directory access for Cube query" - ); + expect(mocks.loggerWarn).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/modules/ee/analysis/lib/access.ts b/apps/web/modules/ee/analysis/lib/access.ts index 040be0422916..ede88cfaf7c1 100644 --- a/apps/web/modules/ee/analysis/lib/access.ts +++ b/apps/web/modules/ee/analysis/lib/access.ts @@ -1,9 +1,12 @@ import "server-only"; import { logger } from "@formbricks/logger"; import { AuthorizationError } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { assertCan, can } from "@/lib/authorization"; +import { + getFeedbackDirectoryAssignmentAuthorizationAction, + getWorkspaceAuthorizationAction, +} from "@/lib/authorization/permission-action"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; export const checkWorkspaceAccess = async ( @@ -13,13 +16,9 @@ export const checkWorkspaceAccess = async ( ) => { const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId, - organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", minPermission, workspaceId }, - ], + await assertCan({ type: "user", id: userId }, getWorkspaceAuthorizationAction(minPermission), { + type: "workspace", + id: workspaceId, }); return { organizationId, workspaceId }; @@ -34,57 +33,29 @@ type TFeedbackDirectoryAccessSource = type TCheckFeedbackDirectoryAccessInput = { feedbackDirectoryId: string; - organizationId: string; workspaceId: string; userId: string; + minPermission: TTeamPermission; source: TFeedbackDirectoryAccessSource; }; export const checkFeedbackDirectoryAccess = async ({ feedbackDirectoryId, - organizationId, workspaceId, userId, + minPermission, source, }: TCheckFeedbackDirectoryAccessInput): Promise<{ feedbackDirectoryId: string }> => { - try { - const directory = await getFeedbackDirectoryAuthContext(feedbackDirectoryId); - const isAccessible = - directory?.organizationId === organizationId && - directory.workspaceIds.includes(workspaceId) && - !directory.isArchived; - - if (!isAccessible) { - logger.warn( - { - feedbackDirectoryId, - organizationId, - workspaceId, - userId, - source, - }, - "Feedback directory access denied for Cube query" - ); - throw new AuthorizationError("Feedback directory is not accessible from this workspace"); - } - - return { feedbackDirectoryId }; - } catch (error) { - if (error instanceof AuthorizationError) { - throw error; - } - - logger.error( - { - error, - feedbackDirectoryId, - organizationId, - workspaceId, - userId, - source, - }, - "Failed to verify feedback directory access for Cube query" - ); - throw error; + const allowed = await can( + { type: "user", id: userId }, + getFeedbackDirectoryAssignmentAuthorizationAction(minPermission), + { type: "feedbackDirectoryAssignment", feedbackDirectoryId, workspaceId } + ); + + if (!allowed) { + logger.warn({ source }, "Feedback directory access denied for Cube query"); + throw new AuthorizationError("Feedback directory is not accessible from this workspace"); } + + return { feedbackDirectoryId }; }; diff --git a/apps/web/modules/ee/analysis/lib/ai-schema-context.test.ts b/apps/web/modules/ee/analysis/lib/ai-schema-context.test.ts index dda769695c77..b8e958db61dd 100644 --- a/apps/web/modules/ee/analysis/lib/ai-schema-context.test.ts +++ b/apps/web/modules/ee/analysis/lib/ai-schema-context.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "vitest"; +import { CHART_TYPE_IDS } from "@/modules/ee/analysis/types/analysis"; import { generateSchemaContext } from "./ai-schema-context"; describe("AI schema context", () => { test.each([ + [ + "the responses alias, distinct from the unique-submissions measure it could be confused with", + '"responses", "response count", or "feedback records" means `FeedbackRecords.count` — not `FeedbackRecords.uniqueResponses`', + ], [ "the NPS score alias for the canonical score measure", '"NPS score" or "net promoter score" means `FeedbackRecords.npsScore`', @@ -40,4 +45,19 @@ describe("AI schema context", () => { ])("documents %s", (_description, expectedSnippet) => { expect(generateSchemaContext()).toContain(expectedSnippet); }); + + test("offers exactly the chart types the output schema accepts", () => { + const context = generateSchemaContext(); + const guideline = context.split("\n").find((line) => line.includes("most appropriate chart type")); + + expect(guideline).toBeDefined(); + for (const id of CHART_TYPE_IDS) { + expect(guideline).toContain(`\`${id}\``); + } + // Only the ids are backticked, so this catches `line` being offered as a type again without + // tripping on the prose that tells the model area covers a line rendering. Steering the model + // at `line` would produce a value ZChartType rejects inside generateObject — before + // resolveChartType's legacy alias could rescue it. + expect(guideline).not.toContain("`line`"); + }); }); diff --git a/apps/web/modules/ee/analysis/lib/ai-schema-context.ts b/apps/web/modules/ee/analysis/lib/ai-schema-context.ts index 398a65c159a1..634077a6eb3e 100644 --- a/apps/web/modules/ee/analysis/lib/ai-schema-context.ts +++ b/apps/web/modules/ee/analysis/lib/ai-schema-context.ts @@ -2,6 +2,7 @@ * Generates a system prompt for the AI chart query LLM. * Derived from FEEDBACK_FIELDS to keep schema and prompt in sync. */ +import { CHART_TYPE_IDS } from "@/modules/ee/analysis/types/analysis"; import { DATE_PRESETS, FEEDBACK_FIELDS, @@ -22,6 +23,16 @@ function formatDimension(d: FieldDefinition): string { return `- ${d.id}: ${d.label}${suffix}`; } +/** + * Read off CHART_TYPE_IDS rather than spelled out, so the prompt cannot offer the model a type + * ZChartType would reject — which is exactly how it kept offering `line` after that type merged + * into `area`. + */ +function formatChartTypes(): string { + const ids = CHART_TYPE_IDS.map((id) => `\`${id}\``); + return `${ids.slice(0, -1).join(", ")}, or ${ids.at(-1)}`; +} + function formatOperators(): string { const lines = Object.entries(FILTER_OPERATORS).map(([type, ops]) => ` ${type}: ${ops.join(", ")}`); return lines.join("\n"); @@ -32,6 +43,7 @@ export function generateSchemaContext(): string { const dimensionsText = FEEDBACK_FIELDS.dimensions.map(formatDimension).join("\n"); const datePresetsText = DATE_PRESETS.map((p) => `"${p.value}"`).join(", "); const operatorsText = formatOperators(); + const chartTypesText = formatChartTypes(); return `You are an expert at converting natural language questions into Cube.js analytics queries. @@ -48,6 +60,7 @@ The time field is \`${CUBE_NAME}.collectedAt\`. Supported granularities: hour, d Date range presets: ${datePresetsText} ### Metric aliases +- "responses", "response count", or "feedback records" means \`${CUBE_NAME}.count\` — not \`${CUBE_NAME}.uniqueResponses\`, which counts distinct submissions rather than every record. - "NPS score" or "net promoter score" means \`${CUBE_NAME}.npsScore\`. - "NPS value", "NPS average", or "NPS average rating" means \`${CUBE_NAME}.npsAverage\`. - "CSAT score" means \`${CUBE_NAME}.csatScore\`; "CSAT average" means \`${CUBE_NAME}.csatAverage\`. @@ -63,7 +76,7 @@ ${operatorsText} - Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sourceType\`, \`FeedbackRecords.collectedAt\`). - For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only). - For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange. -- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries). +- Choose the most appropriate chart type from ${chartTypesText}; use \`big_number\` for single-number queries. There is no separate line type — \`area\` renders as a line through a display setting, so answer requests for a line chart with \`area\`. - Filters must use the exact operator strings from the schema. - For human-readable text dimensions (\`${CUBE_NAME}.sourceName\`, \`${CUBE_NAME}.sourceType\`, \`${CUBE_NAME}.fieldLabel\`, \`${CUBE_NAME}.fieldGroupLabel\`, \`${CUBE_NAME}.valueText\`), prefer the \`contains\` operator over \`equals\` unless the user clearly wants an exact full-string match — \`equals\` is an exact match and the stored value may differ in casing or spacing from the user's phrasing. - \`${CUBE_NAME}.sentiment\` stores exact machine tokens: very_negative, negative, neutral, positive, very_positive, mixed. Filter it with \`equals\`/\`notEquals\` using those exact lowercase tokens (e.g. "negative feedback" → sentiment equals ["negative", "very_negative"]). diff --git a/apps/web/modules/ee/analysis/lib/feedback-data-availability.test.ts b/apps/web/modules/ee/analysis/lib/feedback-data-availability.test.ts index 88c8a734998f..bdd520da504f 100644 --- a/apps/web/modules/ee/analysis/lib/feedback-data-availability.test.ts +++ b/apps/web/modules/ee/analysis/lib/feedback-data-availability.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { getFeedbackSourcesWithMappings } from "@/lib/feedback-source/service"; import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { getAuthorizedWorkspaceFeedbackDirectories } from "@/modules/ee/unify-feedback/lib/access"; import { getFeedbackDataAvailability } from "./feedback-data-availability"; vi.mock("@/lib/feedback-source/service", () => ({ @@ -10,11 +10,12 @@ vi.mock("@/lib/feedback-source/service", () => ({ vi.mock("@/modules/ee/analysis/lib/feedback-records", () => ({ hasFeedbackRecordsInDirectories: vi.fn(), })); -vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ - getFeedbackDirectoriesByWorkspaceId: vi.fn(), +vi.mock("@/modules/ee/unify-feedback/lib/access", () => ({ + getAuthorizedWorkspaceFeedbackDirectories: vi.fn(), })); const workspaceId = "ws-1"; +const userId = "user-1"; const sources = [{ id: "src-1" }] as never; describe("getFeedbackDataAvailability", () => { @@ -23,32 +24,32 @@ describe("getFeedbackDataAvailability", () => { }); test("returns 'no-directory' and skips the records lookup when the workspace has no directories", async () => { - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([] as never); + vi.mocked(getAuthorizedWorkspaceFeedbackDirectories).mockResolvedValue([] as never); vi.mocked(getFeedbackSourcesWithMappings).mockResolvedValue(sources); - const result = await getFeedbackDataAvailability(workspaceId); + const result = await getFeedbackDataAvailability(userId, workspaceId); expect(result).toEqual({ status: "no-directory", directories: [], feedbackSources: sources }); expect(hasFeedbackRecordsInDirectories).not.toHaveBeenCalled(); }); test("fetches directories and sources for the given workspace in parallel", async () => { - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([] as never); + vi.mocked(getAuthorizedWorkspaceFeedbackDirectories).mockResolvedValue([] as never); vi.mocked(getFeedbackSourcesWithMappings).mockResolvedValue(sources); - await getFeedbackDataAvailability(workspaceId); + await getFeedbackDataAvailability(userId, workspaceId); - expect(getFeedbackDirectoriesByWorkspaceId).toHaveBeenCalledWith(workspaceId); + expect(getAuthorizedWorkspaceFeedbackDirectories).toHaveBeenCalledWith(userId, workspaceId); expect(getFeedbackSourcesWithMappings).toHaveBeenCalledWith(workspaceId); }); test("returns 'ready' when directories exist and they contain feedback records", async () => { const directories = [{ id: "dir-1" }, { id: "dir-2" }] as never; - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue(directories); + vi.mocked(getAuthorizedWorkspaceFeedbackDirectories).mockResolvedValue(directories); vi.mocked(getFeedbackSourcesWithMappings).mockResolvedValue(sources); vi.mocked(hasFeedbackRecordsInDirectories).mockResolvedValue(true); - const result = await getFeedbackDataAvailability(workspaceId); + const result = await getFeedbackDataAvailability(userId, workspaceId); expect(hasFeedbackRecordsInDirectories).toHaveBeenCalledWith(["dir-1", "dir-2"]); expect(result).toEqual({ @@ -61,11 +62,11 @@ describe("getFeedbackDataAvailability", () => { test("returns 'no-records' when directories exist but contain no feedback records", async () => { const directories = [{ id: "dir-1" }] as never; - vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue(directories); + vi.mocked(getAuthorizedWorkspaceFeedbackDirectories).mockResolvedValue(directories); vi.mocked(getFeedbackSourcesWithMappings).mockResolvedValue(sources); vi.mocked(hasFeedbackRecordsInDirectories).mockResolvedValue(false); - const result = await getFeedbackDataAvailability(workspaceId); + const result = await getFeedbackDataAvailability(userId, workspaceId); expect(result).toEqual({ status: "no-records", diff --git a/apps/web/modules/ee/analysis/lib/feedback-data-availability.ts b/apps/web/modules/ee/analysis/lib/feedback-data-availability.ts index 8ec5373c7072..6fee054f92aa 100644 --- a/apps/web/modules/ee/analysis/lib/feedback-data-availability.ts +++ b/apps/web/modules/ee/analysis/lib/feedback-data-availability.ts @@ -1,10 +1,10 @@ import { getFeedbackSourcesWithMappings } from "@/lib/feedback-source/service"; import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { getAuthorizedWorkspaceFeedbackDirectories } from "@/modules/ee/unify-feedback/lib/access"; -export async function getFeedbackDataAvailability(workspaceId: string) { +export async function getFeedbackDataAvailability(userId: string, workspaceId: string) { const [directories, feedbackSources] = await Promise.all([ - getFeedbackDirectoriesByWorkspaceId(workspaceId), + getAuthorizedWorkspaceFeedbackDirectories(userId, workspaceId), getFeedbackSourcesWithMappings(workspaceId), ]); diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts index 7c77f38fada6..08f99d1bd0ab 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts @@ -16,6 +16,7 @@ import { getMeasureAxisLabel, getSentimentValueForMeasureId, getTranslatedDimensionValueLabel, + getTranslatedFieldDescription, getTranslatedFieldLabel, isEnrichmentDimensionId, isNotEnrichedDimensionValue, @@ -71,7 +72,7 @@ describe("schema-definition", () => { test("returns measure by id", () => { const field = getFieldById("FeedbackRecords.count"); expect(field).toBeDefined(); - expect(field?.label).toBe("Responses"); + expect(field?.label).toBe("Feedback Records"); }); test("returns undefined for unknown id", () => { @@ -87,7 +88,7 @@ describe("schema-definition", () => { test("returns field label for known dimension/measure", () => { expect(formatCubeColumnHeader("FeedbackRecords.sourceType")).toBe("Source Type"); - expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Responses"); + expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Feedback Records"); }); test("converts last segment to title case for unknown keys", () => { @@ -299,6 +300,45 @@ describe("schema-definition", () => { }); }); + describe("getTranslatedFieldDescription", () => { + // Returns something distinguishable from the key, so an assertion cannot pass on an id that + // resolves to the *wrong* `field_description_*` key — which a key-echoing `t` would allow. + const t = ((key: string) => `translated:${key}`) as TFunction; + + // The descriptions these ids carry are the copy that tells a user which of three + // near-identical measures to pick. Routing them through `t()` is what puts them in front of a + // non-English user; nothing else fails if a key is dropped from the map, because the fallback + // silently serves the hardcoded English from FEEDBACK_FIELDS and `pnpm i18n` still passes. + test.each([ + ["FeedbackRecords.valueId", "workspace.analysis.charts.field_description_value_option"], + ["FeedbackRecords.valueText", "workspace.analysis.charts.field_description_value_text"], + ["FeedbackRecords.count", "workspace.analysis.charts.field_description_count"], + ["FeedbackRecords.uniqueRespondents", "workspace.analysis.charts.field_description_unique_respondents"], + ["FeedbackRecords.uniqueResponses", "workspace.analysis.charts.field_description_unique_responses"], + ])("resolves %s through i18n rather than the English fallback", (id, key) => { + expect(getTranslatedFieldDescription(id, "english fallback", t)).toBe(`translated:${key}`); + }); + + test("falls back to the schema's own description for a member with no key", () => { + expect(getTranslatedFieldDescription("FeedbackRecords.sourceType", "english fallback", t)).toBe( + "english fallback" + ); + }); + + test("passes an absent description through rather than inventing one", () => { + expect(getTranslatedFieldDescription("FeedbackRecords.sourceType", undefined, t)).toBeUndefined(); + }); + + // The lookup is an object literal, so an id that collides with a prototype member must not + // resolve through the prototype chain — the defect #8985 had to convert its own lookup to a Map + // for. Unreachable today (every call site passes an id from the hardcoded FEEDBACK_FIELDS), so + // this pins it rather than fixing something live. + test("does not resolve an inherited property as a description", () => { + expect(getTranslatedFieldDescription("constructor", "english fallback", t)).toBe("english fallback"); + expect(getTranslatedFieldDescription("toString", undefined, t)).toBeUndefined(); + }); + }); + describe("getTranslatedDimensionValueLabel", () => { const t = ((key: string) => key) as TFunction; diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.ts b/apps/web/modules/ee/analysis/lib/schema-definition.ts index 9b138660a8c3..dce2966260f6 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.ts @@ -244,24 +244,26 @@ export const FEEDBACK_FIELDS = { measures: [ { id: "FeedbackRecords.count", - label: "Responses", + label: "Feedback Records", type: "count", group: "count", - description: "Total number of feedback responses", + description: "Total number of feedback records", }, { id: "FeedbackRecords.uniqueRespondents", label: "Unique respondents", type: "number", group: "count", - description: "Number of unique users who provided feedback", + description: + "Unique identified people who gave feedback, deduplicated by person — one respondent answering 3 questions counts once. Anonymous feedback (no identified respondent) isn't counted here, even though it counts as a Feedback Record.", }, { id: "FeedbackRecords.uniqueResponses", label: "Unique responses", type: "number", group: "count", - description: "Number of unique survey submissions", + description: + "Unique survey submissions, deduplicated by submission — one respondent submitting twice counts twice", }, { id: "FeedbackRecords.npsScore", @@ -337,10 +339,10 @@ export const FEEDBACK_FIELDS = { }, { id: "FeedbackRecords.csatCount", - label: "CSAT: Responses", + label: "CSAT: Records", type: "count", group: "count", - description: "Number of CSAT responses", + description: "Number of answered feedback records from CSAT questions (dismissed excluded)", }, { id: "FeedbackRecords.cesAverage", @@ -352,10 +354,10 @@ export const FEEDBACK_FIELDS = { }, { id: "FeedbackRecords.cesCount", - label: "CES: Responses", + label: "CES: Records", type: "count", group: "count", - description: "Number of CES responses", + description: "Number of answered feedback records from CES questions (dismissed excluded)", }, { id: "FeedbackRecords.ratingAverage", @@ -371,10 +373,10 @@ export const FEEDBACK_FIELDS = { }, { id: "FeedbackRecords.ratingCount", - label: "Rating: Responses", + label: "Rating: Records", type: "count", group: "count", - description: "Number of answered rating responses (dismissed responses excluded)", + description: "Number of answered feedback records from rating questions (dismissed excluded)", }, { id: "FeedbackRecords.sentimentAverage", @@ -660,20 +662,31 @@ export function getFieldById(id: string): FieldDefinition | MeasureDefinition | * Translate a field/measure ID. Each t() call uses a literal key so the i18n scanner can detect it. */ /** - * Translated description for the dimensions whose copy guides a chart-building decision. The rest of - * the schema descriptions are still the inline English in FEEDBACK_FIELDS, so this falls back to that - * rather than showing a key. + * Translated description for the members whose copy guides a chart-building decision — two + * dimensions (Value (Option) vs Value (Text)) and the three count measures a user has to choose + * between. The rest of the schema descriptions are still the inline English in FEEDBACK_FIELDS, so + * this falls back to that rather than showing a key. */ export function getTranslatedFieldDescription( id: string, fallback: string | undefined, t: TFunction ): string | undefined { - const descriptions: Record = { - "FeedbackRecords.valueId": t("workspace.analysis.charts.field_description_value_option"), - "FeedbackRecords.valueText": t("workspace.analysis.charts.field_description_value_text"), - }; - return descriptions[id] ?? fallback; + // A `Map`, not an object literal: `descriptions[id]` resolves inherited members, so an id of + // "constructor" or "toString" returned a function where a description string was expected. Same + // lookup shape #8985 converted for the same reason. Not reachable from today's call sites — they + // all pass ids from the hardcoded FEEDBACK_FIELDS arrays — but it costs nothing to close. + const descriptions = new Map([ + ["FeedbackRecords.valueId", t("workspace.analysis.charts.field_description_value_option")], + ["FeedbackRecords.valueText", t("workspace.analysis.charts.field_description_value_text")], + ["FeedbackRecords.count", t("workspace.analysis.charts.field_description_count")], + [ + "FeedbackRecords.uniqueRespondents", + t("workspace.analysis.charts.field_description_unique_respondents"), + ], + ["FeedbackRecords.uniqueResponses", t("workspace.analysis.charts.field_description_unique_responses")], + ]); + return descriptions.get(id) ?? fallback; } export function getTranslatedFieldLabel(id: string, t: TFunction): string { diff --git a/apps/web/modules/ee/analysis/types/analysis.ts b/apps/web/modules/ee/analysis/types/analysis.ts index 81b5c303b25e..bfabb381b552 100644 --- a/apps/web/modules/ee/analysis/types/analysis.ts +++ b/apps/web/modules/ee/analysis/types/analysis.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { TWidgetLayout, ZChartConfig, ZChartQuery, ZWidgetLayout } from "@formbricks/types/analysis"; import { ZId } from "@formbricks/types/common"; -export const CHART_TYPE_IDS = ["area", "bar", "line", "pie", "big_number"] as const; +export const CHART_TYPE_IDS = ["area", "bar", "pie", "big_number"] as const; export const ZChartType = z.enum(CHART_TYPE_IDS); export type TChartType = z.infer; diff --git a/apps/web/modules/ee/audit-logs/lib/handler.test.ts b/apps/web/modules/ee/audit-logs/lib/handler.test.ts index c1b39cad9557..6d64464bbe16 100644 --- a/apps/web/modules/ee/audit-logs/lib/handler.test.ts +++ b/apps/web/modules/ee/audit-logs/lib/handler.test.ts @@ -313,6 +313,21 @@ describe("withAuditLogging", () => { expect(callArgs.target.id).toBe("chart-1"); }); + test("resolves targetId for feedback source target type", async () => { + const feedbackSourceCtx = { + ...mockCtxBase, + auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, feedbackSourceId: "feedback-source-1" }, + }; + const handlerImpl = vi.fn().mockResolvedValue("ok"); + const wrapped = OriginalHandler.withAuditLogging("created", "feedbackSource", handlerImpl); + await wrapped({ ctx: feedbackSourceCtx as any, parsedInput: mockParsedInput }); + await new Promise(setImmediate); + expect(serviceLogAuditEventMockHandle).toHaveBeenCalled(); + const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0]; + expect(callArgs.target.type).toBe("feedbackSource"); + expect(callArgs.target.id).toBe("feedback-source-1"); + }); + test("resolves targetId for dashboard target type", async () => { const dashCtx = { ...mockCtxBase, diff --git a/apps/web/modules/ee/audit-logs/lib/handler.ts b/apps/web/modules/ee/audit-logs/lib/handler.ts index c2ab9e08faa7..db461ed3b2c1 100644 --- a/apps/web/modules/ee/audit-logs/lib/handler.ts +++ b/apps/web/modules/ee/audit-logs/lib/handler.ts @@ -327,6 +327,9 @@ export const withAuditLogging = < case "feedbackRecord": targetId = auditLoggingCtx.feedbackRecordId; break; + case "feedbackSource": + targetId = auditLoggingCtx.feedbackSourceId; + break; default: targetId = UNKNOWN_DATA; break; diff --git a/apps/web/modules/ee/audit-logs/types/audit-log.ts b/apps/web/modules/ee/audit-logs/types/audit-log.ts index b26630a18879..73ce41b1261f 100644 --- a/apps/web/modules/ee/audit-logs/types/audit-log.ts +++ b/apps/web/modules/ee/audit-logs/types/audit-log.ts @@ -32,6 +32,7 @@ export const ZAuditTarget = z.enum([ "cubeQuery", "feedbackDirectory", "feedbackRecord", + "feedbackSource", ]); export const ZAuditAction = z.enum([ "created", diff --git a/apps/web/modules/ee/billing/actions.test.ts b/apps/web/modules/ee/billing/actions.test.ts index f58c7ef694fe..d4b53fdc575f 100644 --- a/apps/web/modules/ee/billing/actions.test.ts +++ b/apps/web/modules/ee/billing/actions.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { createTrialPaymentCheckoutAction, startHobbyAction, startProTrialAction } from "./actions"; const mocks = vi.hoisted(() => ({ - checkAuthorizationUpdated: vi.fn(), + assertCan: vi.fn(), getOrganization: vi.fn(), getOrganizationIdFromWorkspaceId: vi.fn(), getWorkspace: vi.fn(), @@ -35,8 +35,8 @@ vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: vi.fn(), })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mocks.checkAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, })); vi.mock("@/lib/organization/service", () => ({ @@ -87,7 +87,7 @@ vi.mock("@/modules/ee/billing/lib/stripe-client", () => ({ describe("billing actions", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); mocks.getOrganization.mockResolvedValue({ id: "org_1", billing: { @@ -107,15 +107,9 @@ describe("billing actions", () => { parsedInput: { organizationId: "org_1" }, } as any); - expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: "org_1", - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + expect(mocks.assertCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: "org_1", }); expect(mocks.getOrganization).toHaveBeenCalledWith("org_1"); expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1"); diff --git a/apps/web/modules/ee/billing/actions.ts b/apps/web/modules/ee/billing/actions.ts index ef8c9d3e4369..7e0237b9e969 100644 --- a/apps/web/modules/ee/billing/actions.ts +++ b/apps/web/modules/ee/billing/actions.ts @@ -4,11 +4,11 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZCloudBillingInterval } from "@formbricks/types/organizations"; +import { assertCan } from "@/lib/authorization"; import { WEBAPP_URL } from "@/lib/constants"; import { getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { createCustomerPortalSession } from "@/modules/ee/billing/api/lib/create-customer-portal-session"; @@ -37,15 +37,9 @@ export const manageSubscriptionAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const organization = await getOrganization(organizationId); @@ -78,15 +72,9 @@ export const createPlanCheckoutAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const organization = await getOrganization(organizationId); @@ -131,15 +119,9 @@ export const getUpgradeChargePreviewAction = authenticatedActionClient .inputSchema(ZGetUpgradeChargePreviewAction) .action(async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const organization = await getOrganization(organizationId); @@ -166,15 +148,9 @@ const ZRetryStripeSetupAction = z.object({ export const retryStripeSetupAction = authenticatedActionClient .inputSchema(ZRetryStripeSetupAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: parsedInput.organizationId, }); await ensureCloudStripeSetupForOrganization(parsedInput.organizationId); @@ -192,15 +168,9 @@ export const createTrialPaymentCheckoutAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const organization = await getOrganization(organizationId); @@ -254,15 +224,9 @@ const ZStartScaleTrialAction = z.object({ export const startHobbyAction = authenticatedActionClient .inputSchema(ZStartScaleTrialAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); const organization = await getOrganization(parsedInput.organizationId); @@ -295,15 +259,9 @@ export const startHobbyAction = authenticatedActionClient export const startProTrialAction = authenticatedActionClient .inputSchema(ZStartScaleTrialAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); const organization = await getOrganization(parsedInput.organizationId); @@ -368,15 +326,9 @@ const ZChangeBillingPlanAction = z.discriminatedUnion("targetPlan", [ export const changeBillingPlanAction = authenticatedActionClient.inputSchema(ZChangeBillingPlanAction).action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const organization = await getOrganization(organizationId); @@ -431,15 +383,9 @@ export const reportUpgradePaymentIssueAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId, paymentIntentId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); await setOrganizationPaymentAttemptError(organizationId, { @@ -467,15 +413,9 @@ export const finalizeSetupCheckoutUpgradeAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId, checkoutSessionId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const result = await applySetupCheckoutUpgrade({ organizationId, checkoutSessionId }); @@ -525,15 +465,9 @@ export const waitForBillingPlanAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId, targetPlan } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const plan = await pollBillingSync( @@ -559,15 +493,9 @@ export const waitForBillingPaymentMethodAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const hasPaymentMethod = await pollBillingSync( @@ -590,15 +518,9 @@ export const undoPendingPlanChangeAction = authenticatedActionClient .action( withAuditLogging("subscriptionAccessed", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: organizationId, }); const organization = await getOrganization(organizationId); diff --git a/apps/web/modules/ee/billing/components/plan-comparison.tsx b/apps/web/modules/ee/billing/components/plan-comparison.tsx index cfd73ad8b262..72c1651ed7e0 100644 --- a/apps/web/modules/ee/billing/components/plan-comparison.tsx +++ b/apps/web/modules/ee/billing/components/plan-comparison.tsx @@ -219,11 +219,6 @@ export const PlanComparisonTable = ({ columns }: Readonly<{ columns: TPlanColumn label: t("workspace.settings.billing.comparison_row_two_factor_auth"), values: [false, false, "addon"], }, - { - type: "feature", - label: t("workspace.settings.billing.comparison_row_spam"), - values: [false, false, "addon"], - }, ]; const displayRows: ComparisonDisplayRow[] = [ diff --git a/apps/web/modules/ee/contacts/[contactId]/actions.ts b/apps/web/modules/ee/contacts/[contactId]/actions.ts index aa755b1dcfeb..54342b0f23b5 100644 --- a/apps/web/modules/ee/contacts/[contactId]/actions.ts +++ b/apps/web/modules/ee/contacts/[contactId]/actions.ts @@ -3,14 +3,17 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { InvalidInputError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromContactId, getWorkspaceIdFromContactId, getWorkspaceIdFromSurveyId, } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { CONTACT_SURVEY_WORKSPACE_MISMATCH_ERROR_CODE } from "@/modules/ee/contacts/lib/personal-link-errors"; @@ -23,68 +26,67 @@ const ZGeneratePersonalSurveyLinkAction = z.object({ export const generatePersonalSurveyLinkAction = authenticatedActionClient .inputSchema(ZGeneratePersonalSurveyLinkAction) - .action(async ({ ctx, parsedInput }) => { - const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId); - const workspaceId = await getWorkspaceIdFromContactId(parsedInput.contactId); + .action( + withAuditLogging("created", "contact", async ({ ctx, parsedInput }) => { + const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId); + const workspaceId = await getWorkspaceIdFromContactId(parsedInput.contactId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], - }); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, + }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); - await ensureContactsEnabled(organizationId); + await ensureContactsEnabled(organizationId); - // Cross-tenant guard: the survey must belong to the same workspace as the - // contact the caller was authorized against. Authorization above is derived - // from `contactId` only, so without this a caller could pass a `surveyId` - // from another workspace and mint a working personal link for it. Mirrors the - // workspace assertion the segment-based personal-links path performs. - const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - if (surveyWorkspaceId !== workspaceId) { - throw new ValidationError(CONTACT_SURVEY_WORKSPACE_MISMATCH_ERROR_CODE); - } + // Cross-tenant guard: the survey must belong to the same workspace as the + // contact the caller was authorized against. Authorization above is derived + // from `contactId` only, so without this a caller could pass a `surveyId` + // from another workspace and mint a working personal link for it. Mirrors the + // workspace assertion the segment-based personal-links path performs. + const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); + if (surveyWorkspaceId !== workspaceId) { + throw new ValidationError(CONTACT_SURVEY_WORKSPACE_MISMATCH_ERROR_CODE); + } - const result = await getContactSurveyLink( - parsedInput.contactId, - parsedInput.surveyId, - parsedInput.expirationDays - ); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.workspaceId = workspaceId; + ctx.auditLoggingCtx.contactId = parsedInput.contactId; + ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; - if (!result.ok) { - if (result.error.type === "not_found") { - throw new ResourceNotFoundError("Survey", parsedInput.surveyId); - } - if (result.error.type === "bad_request") { - const errorMessage = result.error.details?.[0]?.issue || "Invalid request"; + const result = await getContactSurveyLink( + parsedInput.contactId, + parsedInput.surveyId, + parsedInput.expirationDays + ); + + if (!result.ok) { + if (result.error.type === "not_found") { + throw new ResourceNotFoundError("Survey", parsedInput.surveyId); + } + if (result.error.type === "bad_request") { + const errorMessage = result.error.details?.[0]?.issue || "Invalid request"; + throw new InvalidInputError(errorMessage); + } + const errorMessage = result.error.details?.[0]?.issue || "Failed to generate personal survey link"; throw new InvalidInputError(errorMessage); } - const errorMessage = result.error.details?.[0]?.issue || "Failed to generate personal survey link"; - throw new InvalidInputError(errorMessage); - } - capturePostHogEvent( - ctx.user.id, - "personal_link_created", - { - organization_id: organizationId, - workspace_id: workspaceId, - survey_id: parsedInput.surveyId, - }, - { organizationId, workspaceId: workspaceId } - ); + capturePostHogEvent( + ctx.user.id, + "personal_link_created", + { + organization_id: organizationId, + workspace_id: workspaceId, + survey_id: parsedInput.surveyId, + }, + { organizationId, workspaceId: workspaceId } + ); - return { - surveyUrl: result.data, - }; - }); + const response = { + surveyUrl: result.data, + }; + ctx.auditLoggingCtx.newObject = { surveyId: parsedInput.surveyId }; + return response; + }) + ); diff --git a/apps/web/modules/ee/contacts/actions.ts b/apps/web/modules/ee/contacts/actions.ts index 2fd780a31243..376c9af43d50 100644 --- a/apps/web/modules/ee/contacts/actions.ts +++ b/apps/web/modules/ee/contacts/actions.ts @@ -5,14 +5,16 @@ import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { ZContactAttributesInput } from "@formbricks/types/contact-attribute"; import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromContactId, getOrganizationIdFromWorkspaceId, getWorkspaceIdFromContactId, } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { createContactsFromCSV, deleteContact, getContact, getContacts } from "./lib/contacts"; @@ -35,20 +37,9 @@ export const getContactsAction = authenticatedActionClient const workspaceId = parsedInput.workspaceId; const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: workspaceId, }); await ensureContactsEnabled(organizationId); @@ -65,21 +56,11 @@ export const deleteContactAction = authenticatedActionClient.inputSchema(ZContac const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId); const workspaceId = await getWorkspaceIdFromContactId(parsedInput.contactId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await ensureContactsEnabled(organizationId); @@ -106,21 +87,11 @@ export const createContactsFromCSVAction = authenticatedActionClient withAuditLogging("createdFromCSV", "contact", async ({ ctx, parsedInput }) => { const workspaceId = parsedInput.workspaceId; const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId, - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await ensureContactsEnabled(organizationId); @@ -171,21 +142,11 @@ export const updateContactAttributesAction = authenticatedActionClient const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId); const workspaceId = await getWorkspaceIdFromContactId(parsedInput.contactId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await ensureContactsEnabled(organizationId); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts index f0eba0ce9ffe..a4b5d5925b0e 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -4,9 +4,10 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { CONTACTS_API_V1_NOT_ENABLED_MESSAGE } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { deleteContactAttributeKey, getContactAttributeKey, @@ -31,7 +32,13 @@ async function fetchAndAuthorizeContactAttributeKey( return { error: responses.notFoundResponse("Attribute Key", attributeKeyId) }; } - if (!hasPermission(authentication.workspacePermissions, attributeKey.workspaceId, requiredPermission)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(requiredPermission), + { type: "workspace", id: attributeKey.workspaceId } + )) + ) { return { error: responses.unauthorizedResponse() }; } diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts index 50f39ccdb940..c70711957a8e 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts @@ -5,9 +5,10 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { CONTACTS_API_V1_NOT_ENABLED_MESSAGE } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { ZContactAttributeKeyCreateInput } from "./[contactAttributeKeyId]/types/contact-attribute-keys"; import { createContactAttributeKey, getContactAttributeKeys } from "./lib/contact-attribute-keys"; @@ -76,11 +77,7 @@ export const POST = withV1ApiWrapper({ } // Accept workspaceId as alternative to environmentId — resolve to production environment - const resolved = await resolveBodyIds( - contactAttributeKeyInput, - authentication.workspacePermissions, - "POST" - ); + const resolved = await resolveBodyIds(contactAttributeKeyInput, authentication, "POST"); if (!resolved.ok) return { response: resolved.response }; const inputValidation = ZContactAttributeKeyCreateInput.safeParse(resolved.body); @@ -96,7 +93,11 @@ export const POST = withV1ApiWrapper({ } if ( !resolved.alreadyAuthorized && - !hasPermission(authentication.workspacePermissions, inputValidation.data.workspaceId, "POST") + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: inputValidation.data.workspaceId } + )) ) { return { response: responses.unauthorizedResponse() }; } diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts index 2003c543f8f7..f8beed9b18da 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/[contactId]/route.ts @@ -1,15 +1,16 @@ import { handleErrorResponse } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { deleteContact, getContact } from "./lib/contact"; // Please use the methods provided by the client API to update a person const fetchAndAuthorizeContact = async ( contactId: string, - workspacePermissions: NonNullable["workspacePermissions"], + authentication: NonNullable, requiredPermission: "GET" | "PUT" | "DELETE" ) => { const contact = await getContact(contactId); @@ -18,7 +19,13 @@ const fetchAndAuthorizeContact = async ( return { error: responses.notFoundResponse("Contact", contactId) }; } - if (!hasPermission(workspacePermissions, contact.workspaceId, requiredPermission)) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod(requiredPermission), + { type: "workspace", id: contact.workspaceId } + )) + ) { return { error: responses.unauthorizedResponse() }; } @@ -43,11 +50,7 @@ export const GET = withV1ApiWrapper({ }; } - const result = await fetchAndAuthorizeContact( - params.contactId, - authentication.workspacePermissions, - "GET" - ); + const result = await fetchAndAuthorizeContact(params.contactId, authentication, "GET"); if (result.error) { return { response: result.error, @@ -88,11 +91,7 @@ export const DELETE = withV1ApiWrapper({ }; } - const result = await fetchAndAuthorizeContact( - params.contactId, - authentication.workspacePermissions, - "DELETE" - ); + const result = await fetchAndAuthorizeContact(params.contactId, authentication, "DELETE"); if (result.error) { return { response: result.error, diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts index 82031ad5bfc0..ee9c4c024810 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts @@ -1,3 +1,5 @@ +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; @@ -5,7 +7,6 @@ import { resolveBodyIdsV2 } from "@/modules/api/v2/management/lib/workspace-reso import { upsertBulkContacts } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact"; import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const PUT = async (request: Request) => authenticatedApiClient({ @@ -46,8 +47,13 @@ export const PUT = async (request: Request) => const { contacts } = parsedInput.body ?? { contacts: [] }; - const perm = authentication.workspacePermissions.find((p) => p.workspaceId === workspaceId); - if (!perm || !hasPermission(authentication.workspacePermissions, perm.workspaceId, "PUT")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("PUT"), + { type: "workspace", id: workspaceId } + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts index 898d035e122e..fd6a32ac4492 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts @@ -1,4 +1,6 @@ import { NextRequest } from "next/server"; +import { can } from "@/lib/authorization"; +import { getWorkspaceAuthorizationActionForMethod } from "@/lib/authorization/permission-action"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { responses } from "@/modules/api/v2/lib/response"; import { handleApiError } from "@/modules/api/v2/lib/utils"; @@ -6,7 +8,6 @@ import { resolveBodyIdsV2 } from "@/modules/api/v2/management/lib/workspace-reso import { createContact } from "@/modules/ee/contacts/api/v2/management/contacts/lib/contact"; import { ZContactCreateRequest } from "@/modules/ee/contacts/types/contact"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; -import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const POST = async (request: NextRequest) => authenticatedApiClient({ @@ -36,8 +37,13 @@ export const POST = async (request: NextRequest) => const { workspaceId } = body; - const perm = authentication.workspacePermissions.find((p) => p.workspaceId === workspaceId); - if (!perm || !hasPermission(authentication.workspacePermissions, perm.workspaceId, "POST")) { + if ( + !(await can( + { type: "apiKey", id: authentication.apiKeyId }, + getWorkspaceAuthorizationActionForMethod("POST"), + { type: "workspace", id: workspaceId } + )) + ) { return handleApiError( request, { diff --git a/apps/web/modules/ee/contacts/attributes/actions.ts b/apps/web/modules/ee/contacts/attributes/actions.ts index 15a96d3330a2..2cbc817e3cf1 100644 --- a/apps/web/modules/ee/contacts/attributes/actions.ts +++ b/apps/web/modules/ee/contacts/attributes/actions.ts @@ -4,11 +4,13 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key"; import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { RESERVED_FUTURE_DEFAULT_ATTRIBUTE_KEY_VALIDATION_MESSAGE, @@ -45,21 +47,11 @@ export const createContactAttributeKeyAction = authenticatedActionClient const workspaceId = parsedInput.workspaceId; const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await ensureContactsEnabled(organizationId); @@ -109,21 +101,11 @@ export const updateContactAttributeKeyAction = authenticatedActionClient const workspaceId = existingKey.workspaceId; const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await ensureContactsEnabled(organizationId); @@ -158,21 +140,11 @@ export const deleteContactAttributeKeyAction = authenticatedActionClient const workspaceId = existingKey.workspaceId; const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await ensureContactsEnabled(organizationId); diff --git a/apps/web/modules/ee/contacts/segments/actions.test.ts b/apps/web/modules/ee/contacts/segments/actions.test.ts index c73a63e01d20..a53583ad3923 100644 --- a/apps/web/modules/ee/contacts/segments/actions.test.ts +++ b/apps/web/modules/ee/contacts/segments/actions.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { InvalidInputError } from "@formbricks/types/errors"; const mocks = vi.hoisted(() => ({ - checkAuthorizationUpdated: vi.fn(), + assertCan: vi.fn(), getOrganizationIdFromSegmentId: vi.fn(), getWorkspaceIdFromSegmentId: vi.fn(), getWorkspaceIdFromSurveyId: vi.fn(), @@ -23,8 +23,8 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn), })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mocks.checkAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, })); vi.mock("@/lib/utils/helper", () => ({ @@ -69,7 +69,7 @@ const callUpdate = (data: Record) => describe("updateSegmentAction — ENG-1920 cross-workspace survey re-point", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); mocks.getOrganizationIdFromSegmentId.mockResolvedValue("org1"); mocks.getWorkspaceIdFromSegmentId.mockResolvedValue("ws-segment"); mocks.getIsContactsEnabled.mockResolvedValue(true); diff --git a/apps/web/modules/ee/contacts/segments/actions.ts b/apps/web/modules/ee/contacts/segments/actions.ts index 6e2689ccfe25..b6e87cf7c426 100644 --- a/apps/web/modules/ee/contacts/segments/actions.ts +++ b/apps/web/modules/ee/contacts/segments/actions.ts @@ -4,13 +4,12 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment"; +import { assertCan } from "@/lib/authorization"; import { getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { loadNewSegmentInSurvey } from "@/lib/survey/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { - getOrganizationIdFromContactAttributeKeyId, getOrganizationIdFromSegmentId, getOrganizationIdFromSurveyId, getOrganizationIdFromWorkspaceId, @@ -18,6 +17,8 @@ import { getWorkspaceIdFromSegmentId, getWorkspaceIdFromSurveyId, } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getDistinctAttributeValues } from "@/modules/ee/contacts/lib/contact-attributes"; import { @@ -66,21 +67,11 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen // Set the organizationId in the context to be used in the audit log ctx.auditLoggingCtx.organizationId = organizationId; - await checkAuthorizationUpdated({ - userId: ctx.user?.id ?? "", - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user?.id ?? "" }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await checkAdvancedTargetingPermission(organizationId); @@ -124,21 +115,11 @@ export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdate withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId); const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: segmentWorkspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: segmentWorkspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, segmentWorkspaceId); await checkAdvancedTargetingPermission(organizationId); @@ -189,9 +170,8 @@ const ZLoadNewSegmentAction = z.object({ segmentId: ZId, }); -export const loadNewSegmentAction = authenticatedActionClient - .inputSchema(ZLoadNewSegmentAction) - .action(async ({ ctx, parsedInput }) => { +export const loadNewSegmentAction = authenticatedActionClient.inputSchema(ZLoadNewSegmentAction).action( + withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); const segmentWorkspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId); @@ -200,26 +180,21 @@ export const loadNewSegmentAction = authenticatedActionClient } const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: surveyWorkspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: surveyWorkspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, surveyWorkspaceId); await checkAdvancedTargetingPermission(organizationId); - return await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId); - }); + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; + const result = await loadNewSegmentInSurvey(parsedInput.surveyId, parsedInput.segmentId); + ctx.auditLoggingCtx.newObject = result; + return result; + }) +); const ZCloneSegmentAction = z.object({ segmentId: ZId, @@ -237,21 +212,11 @@ export const cloneSegmentAction = authenticatedActionClient.inputSchema(ZCloneSe const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: surveyWorkspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: surveyWorkspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, surveyWorkspaceId); await checkAdvancedTargetingPermission(organizationId); @@ -271,22 +236,13 @@ const ZDeleteSegmentAction = z.object({ export const deleteSegmentAction = authenticatedActionClient.inputSchema(ZDeleteSegmentAction).action( withAuditLogging("deleted", "segment", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId); + const workspaceId = await getWorkspaceIdFromSegmentId(parsedInput.segmentId); - await checkAuthorizationUpdated({ - userId: ctx.user?.id ?? "", - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromSegmentId(parsedInput.segmentId), - }, - ], + await assertCan({ type: "user", id: ctx.user?.id ?? "" }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await checkAdvancedTargetingPermission(organizationId); @@ -307,22 +263,13 @@ export const resetSegmentFiltersAction = authenticatedActionClient .action( withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); + const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); await checkAdvancedTargetingPermission(organizationId); @@ -344,22 +291,12 @@ const ZGetDistinctAttributeValuesAction = z.object({ export const getDistinctAttributeValuesAction = authenticatedActionClient .inputSchema(ZGetDistinctAttributeValuesAction) .action(async ({ ctx, parsedInput }) => { - const organizationId = await getOrganizationIdFromContactAttributeKeyId(parsedInput.attributeKeyId); - - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromContactAttributeKeyId(parsedInput.attributeKeyId), - }, - ], + const workspaceId = await getWorkspaceIdFromContactAttributeKeyId(parsedInput.attributeKeyId); + const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); + + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: workspaceId, }); await checkAdvancedTargetingPermission(organizationId); @@ -376,20 +313,9 @@ export const getSurveysForSegmentFilterAction = authenticatedActionClient .action(async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: parsedInput.workspaceId, }); await checkAdvancedTargetingPermission(organizationId); diff --git a/apps/web/modules/ee/feedback-directory/actions.test.ts b/apps/web/modules/ee/feedback-directory/actions.test.ts new file mode 100644 index 000000000000..904dcd606d7a --- /dev/null +++ b/apps/web/modules/ee/feedback-directory/actions.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { assertCan } from "@/lib/authorization"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { + createFeedbackDirectoryAction, + getFeedbackDirectoryDetailsAction, + updateFeedbackDirectoryAction, +} from "./actions"; + +const mocks = vi.hoisted(() => { + const action = vi.fn((handler) => handler); + return { + action, + inputSchema: vi.fn(() => ({ action })), + createFeedbackDirectory: vi.fn(), + getFeedbackDirectoryDetails: vi.fn(), + getOrganizationIdFromDirectoryId: vi.fn(), + applyRateLimit: vi.fn(), + updateFeedbackDirectory: vi.fn(), + getIsFeedbackDirectoriesEnabled: vi.fn(), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/authorization", () => ({ assertCan: vi.fn() })); +vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: vi.fn() })); +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { inputSchema: mocks.inputSchema }, +})); +vi.mock("@/modules/core/rate-limit/helpers", () => ({ applyRateLimit: mocks.applyRateLimit })); +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_event, _target, handler) => handler), +})); +vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ + createFeedbackDirectory: mocks.createFeedbackDirectory, + getFeedbackDirectoryDetails: mocks.getFeedbackDirectoryDetails, + getOrganizationIdFromDirectoryId: mocks.getOrganizationIdFromDirectoryId, + updateFeedbackDirectory: mocks.updateFeedbackDirectory, +})); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsFeedbackDirectoriesEnabled: mocks.getIsFeedbackDirectoriesEnabled, +})); + +const ctx = { user: { id: "user_1" }, auditLoggingCtx: {} }; +const organizationId = "organization_1"; +const directoryId = "directory_1"; + +describe("feedback directory administration actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(assertCan).mockResolvedValue(undefined); + mocks.getIsFeedbackDirectoriesEnabled.mockResolvedValue(true); + mocks.getOrganizationIdFromDirectoryId.mockResolvedValue(organizationId); + mocks.createFeedbackDirectory.mockResolvedValue(directoryId); + mocks.getFeedbackDirectoryDetails.mockResolvedValue({ id: directoryId, name: "Dataset" }); + mocks.updateFeedbackDirectory.mockResolvedValue({ id: directoryId }); + }); + + test.each([ + ["create", createFeedbackDirectoryAction, { organizationId, name: "Dataset" }], + ["read", getFeedbackDirectoryDetailsAction, { directoryId }], + ["update", updateFeedbackDirectoryAction, { directoryId, data: { name: "Renamed" } }], + ])("routes %s through organization.manage", async (_name, action, parsedInput) => { + await (action as any)({ ctx, parsedInput }); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: organizationId, + }); + + if (_name === "read") { + expect(mocks.applyRateLimit).not.toHaveBeenCalled(); + } else { + expect(mocks.applyRateLimit).toHaveBeenCalledWith( + rateLimitConfigs.actions.feedbackDirectoryMutation, + "user_1" + ); + } + }); +}); diff --git a/apps/web/modules/ee/feedback-directory/actions.ts b/apps/web/modules/ee/feedback-directory/actions.ts index 422ef64eec89..1b98b7e56061 100644 --- a/apps/web/modules/ee/feedback-directory/actions.ts +++ b/apps/web/modules/ee/feedback-directory/actions.ts @@ -3,9 +3,11 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { createFeedbackDirectory, @@ -33,16 +35,12 @@ export const createFeedbackDirectoryAction = authenticatedActionClient .inputSchema(ZCreateFeedbackDirectoryAction) .action( withAuditLogging("created", "feedbackDirectory", async ({ ctx, parsedInput }) => { + ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; + await applyRateLimit(rateLimitConfigs.actions.feedbackDirectoryMutation, ctx.user.id); await checkFeedbackDirectoriesEnabled(parsedInput.organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); const result = await createFeedbackDirectory( @@ -75,15 +73,9 @@ export const getFeedbackDirectoryDetailsAction = authenticatedActionClient const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId); await checkFeedbackDirectoriesEnabled(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); return await getFeedbackDirectoryDetails(parsedInput.directoryId); @@ -98,18 +90,14 @@ export const updateFeedbackDirectoryAction = authenticatedActionClient .inputSchema(ZUpdateFeedbackDirectoryAction) .action( withAuditLogging("updated", "feedbackDirectory", async ({ ctx, parsedInput }) => { + ctx.auditLoggingCtx.feedbackDirectoryId = parsedInput.directoryId; + await applyRateLimit(rateLimitConfigs.actions.feedbackDirectoryMutation, ctx.user.id); const organizationId = await getOrganizationIdFromDirectoryId(parsedInput.directoryId); await checkFeedbackDirectoriesEnabled(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); ctx.auditLoggingCtx.organizationId = organizationId; diff --git a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts index 94d751484660..005f3c6d9d52 100644 --- a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts +++ b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma, type PrismaClientKnownRequestError } from "@formbricks/database/prisma"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { reconcileFeedbackDirectoryRelationships } from "@/lib/authzed/feedback-directory"; import { createFeedbackDirectory, getFeedbackDirectories, @@ -15,6 +16,10 @@ import { vi.mock("server-only", () => ({})); +vi.mock("@/lib/authzed/feedback-directory", () => ({ + reconcileFeedbackDirectoryRelationships: vi.fn(), +})); + vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn(), })); @@ -89,6 +94,8 @@ const makeForeignKeyError = (constraintName: string): PrismaClientKnownRequestEr describe("FeedbackDirectory Service", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValue(mockDirectoryDetailsDbRow as any); + vi.mocked(reconcileFeedbackDirectoryRelationships).mockResolvedValue({ passes: 1, status: "projected" }); }); describe("getFeedbackDirectories", () => { @@ -285,6 +292,10 @@ describe("FeedbackDirectory Service", () => { data: { name: "New Directory", organizationId: mockOrganizationId }, select: { id: true }, }); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledWith({ + assignments: [], + feedbackDirectoryIds: [mockDirectoryId], + }); }); test("creates a directory with workspace links", async () => { @@ -312,6 +323,13 @@ describe("FeedbackDirectory Service", () => { }, select: { id: true }, }); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledWith({ + assignments: [ + { feedbackDirectoryId: mockDirectoryId, workspaceId: mockWorkspaceId1 }, + { feedbackDirectoryId: mockDirectoryId, workspaceId: mockWorkspaceId2 }, + ], + feedbackDirectoryIds: [mockDirectoryId], + }); }); test("throws InvalidInputError when workspaceIds belong to different org", async () => { @@ -485,7 +503,7 @@ describe("FeedbackDirectory Service", () => { expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled(); }); - test("updates workspace assignments with diff", async () => { + test("reconciles both previous and submitted assignments when removing a workspace", async () => { // getFeedbackDirectoryDetails call vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any); @@ -504,6 +522,22 @@ describe("FeedbackDirectory Service", () => { organizationId: mockOrganizationId, }, }); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledWith({ + assignments: [ + { feedbackDirectoryId: mockDirectoryId, workspaceId: mockWorkspaceId1 }, + { feedbackDirectoryId: mockDirectoryId, workspaceId: mockWorkspaceId2 }, + ], + feedbackDirectoryIds: [mockDirectoryId], + }); + }); + + test("does not replace a successful source update when projection unexpectedly rejects", async () => { + vi.mocked(prisma.feedbackDirectory.update).mockResolvedValueOnce({} as any); + vi.mocked(reconcileFeedbackDirectoryRelationships).mockRejectedValueOnce(new Error("spicedb down")); + + await expect( + updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, { name: "Updated Name" }) + ).resolves.toBe(true); }); test("blocks removing a workspace that still has feedback sources", async () => { diff --git a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts index 61546395be58..ed0c525d8680 100644 --- a/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts +++ b/apps/web/modules/ee/feedback-directory/lib/feedback-directory.ts @@ -6,6 +6,8 @@ import { Prisma, type PrismaClient } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { reconcileFeedbackDirectoryRelationships } from "@/lib/authzed/feedback-directory"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; import { isDirectoryWorkspaceFkViolation } from "@/lib/feedback-source/service"; import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; @@ -339,6 +341,16 @@ export const createFeedbackDirectory = async ( }, }); + await runPostCommitProjection("create_feedback_directory", () => + reconcileFeedbackDirectoryRelationships({ + assignments: (workspaceIds ?? []).map((workspaceId) => ({ + feedbackDirectoryId: directory.id, + workspaceId, + })), + feedbackDirectoryIds: [directory.id], + }) + ); + return directory.id; } catch (error) { if (isUniqueConstraintError(error)) { @@ -411,6 +423,7 @@ const buildWorkspaceAssignmentPayload = async ( const getArchiveUpdate = async ( prismaClient: FeedbackDirectoryPrismaClient, directoryId: string, + currentWorkspaceIds: string[], isArchived: boolean | undefined ): Promise> => { if (isArchived === true) { @@ -424,11 +437,6 @@ const getArchiveUpdate = async ( } if (isArchived === false) { - const currentWorkspaceIds = await getFeedbackDirectoryWorkspaceIdsWithClient(prismaClient, directoryId); - if (!currentWorkspaceIds) { - throw new ResourceNotFoundError("FeedbackDirectory", directoryId); - } - await assertWorkspacesNotAssignedElsewhere(prismaClient, directoryId, currentWorkspaceIds); return { isArchived: false }; @@ -441,6 +449,7 @@ const getWorkspaceAssignmentUpdate = async ( prismaClient: FeedbackDirectoryPrismaClient, directoryId: string, organizationId: string, + currentWorkspaceIds: string[], workspaceIds: string[] | undefined ): Promise<{ workspaces?: Prisma.FeedbackDirectoryWorkspaceUpdateManyWithoutFeedbackDirectoryNestedInput; @@ -450,8 +459,6 @@ const getWorkspaceAssignmentUpdate = async ( return { removedWorkspaceIds: [] }; } - const currentWorkspaceIds = - (await getFeedbackDirectoryWorkspaceIdsWithClient(prismaClient, directoryId)) ?? []; const assignmentPayload = await buildWorkspaceAssignmentPayload( prismaClient, directoryId, @@ -550,17 +557,22 @@ export const updateFeedbackDirectory = async ( try { const { name, workspaceIds, isArchived } = data; - await prisma.$transaction( + const affectedWorkspaceIds = await prisma.$transaction( async (tx) => { + const previousWorkspaceIds = await getFeedbackDirectoryWorkspaceIdsWithClient(tx, directoryId); + if (!previousWorkspaceIds) { + throw new ResourceNotFoundError("FeedbackDirectory", directoryId); + } if (workspaceIds !== undefined) { await assertWorkspacesNotAssignedElsewhere(tx, directoryId, workspaceIds); } - const archiveUpdate = await getArchiveUpdate(tx, directoryId, isArchived); + const archiveUpdate = await getArchiveUpdate(tx, directoryId, previousWorkspaceIds, isArchived); const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate( tx, directoryId, organizationId, + previousWorkspaceIds, workspaceIds ); @@ -582,12 +594,24 @@ export const updateFeedbackDirectory = async ( where: { id: directoryId }, data: payload, }); + + return [...new Set([...previousWorkspaceIds, ...(workspaceIds ?? previousWorkspaceIds)])]; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, } ); + await runPostCommitProjection("update_feedback_directory", () => + reconcileFeedbackDirectoryRelationships({ + assignments: affectedWorkspaceIds.map((workspaceId) => ({ + feedbackDirectoryId: directoryId, + workspaceId, + })), + feedbackDirectoryIds: [directoryId], + }) + ); + return true; } catch (error) { if (isUniqueConstraintError(error)) { diff --git a/apps/web/modules/ee/feedback-directory/page.tsx b/apps/web/modules/ee/feedback-directory/page.tsx index c6d403a64594..cdb3e8c5dc74 100644 --- a/apps/web/modules/ee/feedback-directory/page.tsx +++ b/apps/web/modules/ee/feedback-directory/page.tsx @@ -1,6 +1,7 @@ import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; -import { getAccessFlags } from "@/lib/membership/utils"; import { getTranslate } from "@/lingodotdev/server"; import { FeedbackDirectoryView } from "@/modules/ee/feedback-directory/components/feedback-directory-view"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -17,11 +18,27 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ organiz await redirectBillingRoleFromRestrictedOrgSettings(params.organizationId); - const { currentUserMembership, organization } = await getOrganizationAuth(params.organizationId); + const { currentUserMembership, organization, session } = await getOrganizationAuth(params.organizationId); - const { isOwner, isManager } = getAccessFlags(currentUserMembership.role); + // ENG-2409: was a second `getAccessFlags(currentUserMembership.role)` on a role this page had + // already been handed, then `!isOwner && !isManager`. `organization.manage` is the same set. + // `membershipRole` below still comes from the row — that is a rendering prop, retained by design. + // + // Run alongside the license lookup rather than before it: the two are independent (one asks about + // the caller's role, the other about the organization's plan), and the flag test this replaced was + // synchronous, so awaiting them in series would have made every load of this page pay for both + // round trips end to end. The *branches* below keep their original order — an unlicensed + // organization must still see the upgrade prompt rather than a no-access message. + const [canManageOrganization, isFeedbackDirectoriesAllowed] = await Promise.all([ + withAuthorizationSurface("page", () => + can({ type: "user", id: session.user.id }, "organization.manage", { + type: "organization", + id: organization.id, + }) + ), + getIsFeedbackDirectoriesEnabled(organization.id), + ]); - const isFeedbackDirectoriesAllowed = await getIsFeedbackDirectoriesEnabled(organization.id); const pageTitle = t("workspace.settings.feedback_directories.title"); if (!isFeedbackDirectoriesAllowed) { @@ -55,7 +72,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ organiz ); } - if (!isOwner && !isManager) { + if (!canManageOrganization) { return ( diff --git a/apps/web/modules/ee/license-check/actions.ts b/apps/web/modules/ee/license-check/actions.ts index a880edf8e17b..f427e102fbe3 100644 --- a/apps/web/modules/ee/license-check/actions.ts +++ b/apps/web/modules/ee/license-check/actions.ts @@ -2,14 +2,9 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { - AuthenticationError, - OperationNotAllowedError, - ResourceNotFoundError, -} from "@formbricks/types/errors"; +import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { cache } from "@/lib/cache"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; @@ -25,6 +20,7 @@ import { fetchLicenseFresh, getCacheKeys, } from "./lib/license"; +import { assertCanRecheckLicense } from "./lib/recheck-authorization"; const ZRecheckLicenseAction = z.object({ workspaceId: ZId, @@ -57,15 +53,7 @@ export const recheckLicenseAction = authenticatedActionClient throw new ResourceNotFoundError("Organization", null); } - // Check user is owner or manager (not member) - const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organization.id); - if (!currentUserMembership) { - throw new AuthenticationError("User not a member of this organization"); - } - - if (currentUserMembership.role === "member") { - throw new OperationNotAllowedError("Only owners and managers can recheck license"); - } + await assertCanRecheckLicense(ctx.user.id, organization.id); // Clear main license cache (preserves previous result cache for grace period) // This prevents instant downgrade if the license server is temporarily unreachable diff --git a/apps/web/modules/ee/license-check/lib/recheck-authorization.integration.test.ts b/apps/web/modules/ee/license-check/lib/recheck-authorization.integration.test.ts new file mode 100644 index 000000000000..1de8f9a38447 --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/recheck-authorization.integration.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { prisma } from "@formbricks/database"; +import { AuthenticationError, OperationNotAllowedError } from "@formbricks/types/errors"; +import type { TOrganizationRole } from "@formbricks/types/memberships"; +import { synchronizeAuthzedIntegrationFixture } from "@/integration/authzed"; +import { resetDb } from "@/integration/reset-db"; +import { assertCanRecheckLicense } from "@/modules/ee/license-check/lib/recheck-authorization"; + +/** + * ENG-1737: the license-recheck gate, decided against real memberships. + * + * This is the one behavior change in the branch — the old gate denied the `member` role by name, so + * `billing` passed it. The unit test beside this file pins the composition with a mocked `can()`; + * this pins the outcome per real organization role, which is the part that actually regressed. + */ +const scenario: { organizationId: string; userIdByRole: Map } = { + organizationId: "", + userIdByRole: new Map(), +}; + +beforeAll(async () => { + await resetDb(); + + const organization = await prisma.organization.create({ data: { name: "License Org" } }); + + const makeUser = async (label: string, role: TOrganizationRole | null) => { + const user = await prisma.user.create({ data: { name: label, email: `${label}@license.test` } }); + if (role) { + await prisma.membership.create({ + data: { userId: user.id, organizationId: organization.id, role, accepted: true }, + }); + } + scenario.userIdByRole.set(label, user.id); + }; + + await makeUser("owner", "owner"); + await makeUser("manager", "manager"); + await makeUser("billing", "billing"); + await makeUser("member", "member"); + await makeUser("outsider", null); + + scenario.organizationId = organization.id; + await synchronizeAuthzedIntegrationFixture(); +}, 120_000); + +describe("assertCanRecheckLicense against a real database", () => { + test.each(["owner", "manager"])("allows %s", async (role) => { + await expect( + assertCanRecheckLicense(scenario.userIdByRole.get(role)!, scenario.organizationId) + ).resolves.toBeUndefined(); + }); + + // `billing` is the regression: it holds a membership, so it passes the membership check, and it is + // not a `member`, so the old role-name test never caught it. + test.each(["billing", "member"])("refuses %s as unable to manage the organization", async (role) => { + await expect( + assertCanRecheckLicense(scenario.userIdByRole.get(role)!, scenario.organizationId) + ).rejects.toThrow(OperationNotAllowedError); + }); + + test("reports a user outside the organization as a non-member", async () => { + await expect( + assertCanRecheckLicense(scenario.userIdByRole.get("outsider")!, scenario.organizationId) + ).rejects.toThrow(AuthenticationError); + }); +}); diff --git a/apps/web/modules/ee/license-check/lib/recheck-authorization.test.ts b/apps/web/modules/ee/license-check/lib/recheck-authorization.test.ts new file mode 100644 index 000000000000..793682abad97 --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/recheck-authorization.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AuthenticationError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; +import { assertCanRecheckLicense } from "./recheck-authorization"; + +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); + +describe("assertCanRecheckLicense", () => { + const userId = "user-1"; + const organizationId = "org-1"; + const actor = { type: "user", id: userId } as const; + const organization = { type: "organization", id: organizationId } as const; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("allows a caller who can manage the organization", async () => { + vi.mocked(can).mockResolvedValue(true); + + await expect(assertCanRecheckLicense(userId, organizationId)).resolves.toBeUndefined(); + + expect(can).toHaveBeenCalledWith(actor, "organization.read", organization); + expect(can).toHaveBeenCalledWith(actor, "organization.manage", organization); + }); + + test("reports a non-member as a non-member, without asking about management", async () => { + vi.mocked(can).mockResolvedValue(false); + + await expect(assertCanRecheckLicense(userId, organizationId)).rejects.toThrow(AuthenticationError); + + expect(can).toHaveBeenCalledTimes(1); + }); + + // The ENG-1737 behavior change. `billing` and `member` both hold a membership and both fail + // `organization.manage`; the old check named `member` and so let `billing` through. + test("refuses a member who cannot manage the organization", async () => { + vi.mocked(can).mockImplementation(async (_actor, action) => action === "organization.read"); + + await expect(assertCanRecheckLicense(userId, organizationId)).rejects.toThrow( + new OperationNotAllowedError("Only owners and managers can recheck license") + ); + }); + + test("propagates an evaluator failure instead of turning it into a denial", async () => { + vi.mocked(can).mockRejectedValue(new Error("database unavailable")); + + await expect(assertCanRecheckLicense(userId, organizationId)).rejects.toThrow("database unavailable"); + }); +}); diff --git a/apps/web/modules/ee/license-check/lib/recheck-authorization.ts b/apps/web/modules/ee/license-check/lib/recheck-authorization.ts new file mode 100644 index 000000000000..396be3c4d6dc --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/recheck-authorization.ts @@ -0,0 +1,31 @@ +import "server-only"; +import { AuthenticationError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; + +/** + * Who may force a license recheck: organization owners and managers. + * + * ENG-1737 moved this off a role-name test. It used to deny the `member` role by name, which let + * `billing` through — that role is neither an owner nor a manager, but it is also not a member, so + * the negative test missed it. `organization.manage` is defined as exactly owner + manager, which is + * what this action's own error message has always claimed. + * + * Membership is established separately from the capability so a caller outside the organization keeps + * reporting as a non-member rather than as an insufficient one. It lives here, outside the action, so + * it can be tested without the `authenticatedActionClient` wrapper. + * + * @throws AuthenticationError when the user holds no membership in the organization. + * @throws OperationNotAllowedError when the user is a member but may not manage the organization. + */ +export const assertCanRecheckLicense = async (userId: string, organizationId: string): Promise => { + const actor = { type: "user", id: userId } as const; + const organization = { type: "organization", id: organizationId } as const; + + if (!(await can(actor, "organization.read", organization))) { + throw new AuthenticationError("User not a member of this organization"); + } + + if (!(await can(actor, "organization.manage", organization))) { + throw new OperationNotAllowedError("Only owners and managers can recheck license"); + } +}; diff --git a/apps/web/modules/ee/quotas/actions.ts b/apps/web/modules/ee/quotas/actions.ts index a071b063760e..90053f3b6fcd 100644 --- a/apps/web/modules/ee/quotas/actions.ts +++ b/apps/web/modules/ee/quotas/actions.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZSurveyQuotaInput } from "@formbricks/types/quota"; +import { assertCan } from "@/lib/authorization"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { getOrganizationIdFromQuotaId, @@ -38,20 +38,9 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId); await checkQuotasEnabled(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromQuotaId(parsedInput.quotaId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromQuotaId(parsedInput.quotaId), }); ctx.auditLoggingCtx.organizationId = organizationId; @@ -73,20 +62,9 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId); await checkQuotasEnabled(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromQuotaId(parsedInput.quotaId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromQuotaId(parsedInput.quotaId), }); ctx.auditLoggingCtx.organizationId = organizationId; @@ -106,20 +84,9 @@ export const createQuotaAction = authenticatedActionClient.inputSchema(ZCreateQu withAuditLogging("created", "quota", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId); await checkQuotasEnabled(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.quota.surveyId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.quota.surveyId), }); ctx.auditLoggingCtx.organizationId = organizationId; @@ -146,20 +113,9 @@ export const getQuotaResponseCountAction = authenticatedActionClient }) => { const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId); await checkQuotasEnabled(organizationId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromQuotaId(parsedInput.quotaId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromQuotaId(parsedInput.quotaId), }); const count = await getQuotaLinkCountByQuotaId(parsedInput.quotaId); diff --git a/apps/web/modules/ee/role-management/actions.test.ts b/apps/web/modules/ee/role-management/actions.test.ts index bc6374943dc9..c0e3c25b3b52 100644 --- a/apps/web/modules/ee/role-management/actions.test.ts +++ b/apps/web/modules/ee/role-management/actions.test.ts @@ -3,6 +3,9 @@ import { OperationNotAllowedError, ValidationError } from "@formbricks/types/err import { updateMembershipAction } from "./actions"; const mocks = vi.hoisted(() => ({ + applyRateLimit: vi.fn(), + assertCan: vi.fn(), + can: vi.fn(), checkAuthorizationUpdated: vi.fn(), getAccessControlPermission: vi.fn(), getMembershipByUserIdOrganizationId: vi.fn(), @@ -11,6 +14,11 @@ const mocks = vi.hoisted(() => ({ updateMembership: vi.fn(), })); +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, + can: mocks.can, +})); + vi.mock("@formbricks/database", () => ({ // The last-owner guard runs the owner-count re-check and the update inside one transaction; // the fake just invokes the callback with a stand-in tx so both still hit the mocks below. @@ -26,6 +34,10 @@ vi.mock("@/lib/constants", () => ({ USER_MANAGEMENT_MINIMUM_ROLE: "manager", })); +vi.mock("@/modules/core/rate-limit/helpers", () => ({ + applyRateLimit: mocks.applyRateLimit, +})); + vi.mock("@/lib/membership/service", () => ({ getMembershipByUserIdOrganizationId: mocks.getMembershipByUserIdOrganizationId, })); @@ -90,6 +102,9 @@ describe("updateMembershipAction", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.applyRateLimit.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); + mocks.can.mockResolvedValue(true); mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); mocks.getAccessControlPermission.mockResolvedValue(true); mocks.getOrganization.mockResolvedValue({ id: organizationId }); diff --git a/apps/web/modules/ee/role-management/actions.ts b/apps/web/modules/ee/role-management/actions.ts index f608c38d9f9f..cc566e6f22ce 100644 --- a/apps/web/modules/ee/role-management/actions.ts +++ b/apps/web/modules/ee/role-management/actions.ts @@ -11,13 +11,14 @@ import { ValidationError, } from "@formbricks/types/errors"; import { ZMembershipUpdateInput } from "@formbricks/types/memberships"; -import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants"; +import { assertCan, can } from "@/lib/authorization"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; -import { getUserManagementAccess } from "@/lib/membership/utils"; import { getOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromInviteId } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { updateInvite } from "@/modules/ee/role-management/lib/invite"; @@ -54,18 +55,11 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI throw new AuthenticationError("User not a member of this organization"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - data: parsedInput.data, - schema: ZInviteUpdateInput, - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, organizationId); if (!IS_FORMBRICKS_CLOUD && parsedInput.data.role === "billing") { throw new ValidationError("Billing role is not allowed"); @@ -103,27 +97,26 @@ export const updateMembershipAction = authenticatedActionClient.inputSchema(ZUpd if (!currentUserMembership) { throw new AuthenticationError("User not a member of this organization"); } - const hasUserManagementAccess = getUserManagementAccess( - currentUserMembership.role, - USER_MANAGEMENT_MINIMUM_ROLE - ); + // `organization.manage_access` *is* this decision in the central vocabulary. The SpiceDB + // evaluator maps `USER_MANAGEMENT_MINIMUM_ROLE` onto the schema (`owner` → write, `manager` → + // manage_access, `disabled` → deny). Asking centrally makes SpiceDB authoritative for this role + // mutation — the highest-risk one in the product. The check + // below it stays `organization.manage`, which is a different and additionally required + // capability, so both remain. + const canManageAccess = await can({ type: "user", id: ctx.user.id }, "organization.manage_access", { + type: "organization", + id: parsedInput.organizationId, + }); - if (!hasUserManagementAccess) { + if (!canManageAccess) { throw new OperationNotAllowedError("User management is not allowed for your role"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - data: parsedInput.data, - schema: ZMembershipUpdateInput, - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.organizationId); if (!IS_FORMBRICKS_CLOUD && parsedInput.data.role === "billing") { throw new ValidationError("Billing role is not allowed"); diff --git a/apps/web/modules/ee/role-management/lib/membership.test.ts b/apps/web/modules/ee/role-management/lib/membership.test.ts index 2ecb93718f17..59309c68c626 100644 --- a/apps/web/modules/ee/role-management/lib/membership.test.ts +++ b/apps/web/modules/ee/role-management/lib/membership.test.ts @@ -4,6 +4,8 @@ import { Prisma } from "@formbricks/database/prisma"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TOrganizationRole } from "@formbricks/types/memberships"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { updateMembership } from "./membership"; vi.mock("@formbricks/database", () => ({ @@ -19,6 +21,13 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/authzed/organization-membership", () => ({ + reconcileOrganizationMembership: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + describe("updateMembership", () => { afterEach(() => { vi.clearAllMocks(); @@ -36,11 +45,8 @@ describe("updateMembership", () => { const mockTeamMemberships = [{ teamId: "team1" }, { teamId: "team2" }]; - const mockOrganizationMembers = [{ userId: "user1" }, { userId: "user2" }]; - vi.mocked(prisma.membership.update).mockResolvedValue(mockMembership); vi.mocked(prisma.teamUser.findMany).mockResolvedValue(mockTeamMemberships as any); - vi.mocked(prisma.membership.findMany).mockResolvedValue(mockOrganizationMembers as any); const result = await updateMembership("user1", "org1", { role: "owner" }); @@ -54,6 +60,13 @@ describe("updateMembership", () => { }, data: { role: "owner" }, }); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith("org1", "user1"); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [ + { teamId: "team1", userId: "user1" }, + { teamId: "team2", userId: "user1" }, + ], + }); }); test("should throw ResourceNotFoundError when membership doesn't exist", async () => { @@ -81,11 +94,8 @@ describe("updateMembership", () => { const mockTeamMemberships = [{ teamId: "team1" }, { teamId: "team2" }]; - const mockOrganizationMembers = [{ userId: "user1" }, { userId: "user2" }]; - vi.mocked(prisma.membership.update).mockResolvedValue(mockMembership); vi.mocked(prisma.teamUser.findMany).mockResolvedValue(mockTeamMemberships as any); - vi.mocked(prisma.membership.findMany).mockResolvedValue(mockOrganizationMembers as any); const result = await updateMembership("user1", "org1", { role: "manager" }); @@ -101,5 +111,68 @@ describe("updateMembership", () => { role: "admin", }, }); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [ + { teamId: "team1", userId: "user1" }, + { teamId: "team2", userId: "user1" }, + ], + }); + }); + + test("includes memberships added while team roles are being updated in reconciliation", async () => { + const mockMembership = { + id: "1", + userId: "user1", + organizationId: "org1", + role: "manager" as TOrganizationRole, + accepted: true, + deprecatedRole: null, + }; + let roleUpdateCompleted = false; + + vi.mocked(prisma.membership.update).mockResolvedValue(mockMembership); + vi.mocked(prisma.teamUser.updateMany).mockImplementation((() => { + roleUpdateCompleted = true; + return Promise.resolve({ count: 2 }); + }) as never); + vi.mocked(prisma.teamUser.findMany).mockImplementation((() => + Promise.resolve(roleUpdateCompleted ? [{ teamId: "team1" }, { teamId: "team2" }] : [])) as never); + + await updateMembership("user1", "org1", { role: "manager" }); + + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [ + { teamId: "team1", userId: "user1" }, + { teamId: "team2", userId: "user1" }, + ], + }); + }); + + test("uses a transaction client without projecting before the outer transaction commits", async () => { + const mockMembership = { + id: "1", + userId: "user1", + organizationId: "org1", + role: "member" as TOrganizationRole, + accepted: true, + deprecatedRole: null, + }; + const tx = { + membership: { + update: vi.fn().mockResolvedValue(mockMembership), + findMany: vi.fn().mockResolvedValue([{ userId: "user1" }]), + }, + teamUser: { + findMany: vi.fn().mockResolvedValue([{ teamId: "team1" }]), + updateMany: vi.fn(), + }, + } as unknown as Prisma.TransactionClient; + + await expect(updateMembership("user1", "org1", { role: "member" }, tx)).resolves.toEqual(mockMembership); + + expect(tx.membership.update).toHaveBeenCalled(); + expect(prisma.membership.update).not.toHaveBeenCalled(); + expect(reconcileOrganizationMembership).not.toHaveBeenCalled(); + expect(reconcileTeamWorkspaceRelationships).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/modules/ee/role-management/lib/membership.ts b/apps/web/modules/ee/role-management/lib/membership.ts index 18af6e0bd378..34588ba14028 100644 --- a/apps/web/modules/ee/role-management/lib/membership.ts +++ b/apps/web/modules/ee/role-management/lib/membership.ts @@ -5,6 +5,9 @@ import { PrismaErrorType } from "@formbricks/database/types/error"; import { ZString } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TMembership, TMembershipUpdateInput, ZMembershipUpdateInput } from "@formbricks/types/memberships"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { validateInputs } from "@/lib/utils/validate"; export const updateMembership = async ( @@ -15,6 +18,8 @@ export const updateMembership = async ( ): Promise => { validateInputs([userId, ZString], [organizationId, ZString], [data, ZMembershipUpdateInput]); const client = tx ?? prisma; + let affectedTeamIds: string[] = []; + let membershipUpdated = false; try { const membership = await client.membership.update({ @@ -26,18 +31,7 @@ export const updateMembership = async ( }, data, }); - - await client.teamUser.findMany({ - where: { - userId, - team: { - organizationId, - }, - }, - select: { - teamId: true, - }, - }); + membershipUpdated = true; if (data.role === "owner" || data.role === "manager") { await client.teamUser.updateMany({ @@ -53,6 +47,19 @@ export const updateMembership = async ( }); } + const teamMemberships = await client.teamUser.findMany({ + where: { + userId, + team: { + organizationId, + }, + }, + select: { + teamId: true, + }, + }); + affectedTeamIds = teamMemberships.map(({ teamId }) => teamId); + await client.membership.findMany({ where: { organizationId, @@ -72,5 +79,19 @@ export const updateMembership = async ( } throw error; + } finally { + // A transaction-scoped call is projected by the durable PostgreSQL outbox only after the outer + // transaction commits. Reading through the global client here could observe the pre-commit role + // and would publish stale relationships. + if (!tx && membershipUpdated) { + await runPostCommitProjection("organization_role_membership_update", () => + reconcileOrganizationMembership(organizationId, userId) + ); + await runPostCommitProjection("organization_role_team_membership_update", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: affectedTeamIds.map((teamId) => ({ teamId, userId })), + }) + ); + } } }; diff --git a/apps/web/modules/ee/sso/lib/sso-provisioning.test.ts b/apps/web/modules/ee/sso/lib/sso-provisioning.test.ts index ecf3984fbb0a..5c38552b64f4 100644 --- a/apps/web/modules/ee/sso/lib/sso-provisioning.test.ts +++ b/apps/web/modules/ee/sso/lib/sso-provisioning.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { getIsFreshInstance } from "@/lib/instance/service"; import { createMembership } from "@/lib/membership/service"; import { capturePostHogEvent, identifyPostHogPerson } from "@/lib/posthog"; @@ -21,6 +23,12 @@ vi.mock("@formbricks/database", () => ({ }, })); vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), warn: vi.fn(), debug: vi.fn() } })); +vi.mock("@/lib/authzed/organization-membership", () => ({ + reconcileOrganizationMembership: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); vi.mock("@/lib/instance/service", () => ({ getIsFreshInstance: vi.fn() })); vi.mock("@/lib/membership/service", () => ({ createMembership: vi.fn() })); vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: vi.fn(), identifyPostHogPerson: vi.fn() })); @@ -284,8 +292,9 @@ describe("provisionSsoUserMemberships", () => { "org-1", "u1", { role: "member", accepted: true }, - expect.anything() + expect.objectContaining({ projection: "deferred", transaction: expect.anything() }) ); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith("org-1", "u1"); expect(createDefaultTeamMembership).not.toHaveBeenCalled(); expect(updateUser).toHaveBeenCalledWith( "u1", @@ -306,6 +315,9 @@ describe("provisionSsoUserMemberships", () => { test("creates a default team membership when requested", async () => { await provisionSsoUserMemberships({ ...baseArgs, assignToDefaultTeam: true }); expect(createDefaultTeamMembership).toHaveBeenCalledWith("u1", expect.anything()); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [{ teamId: "team-123", userId: "u1" }], + }); }); test("skips org writes when there is no organization but still syncs analytics", async () => { @@ -324,6 +336,7 @@ describe("provisionSsoUserMemberships", () => { vi.mocked(createMembership).mockRejectedValue(new Error("db down")); await expect(provisionSsoUserMemberships(baseArgs)).resolves.toBeUndefined(); expect(createMembership).toHaveBeenCalledTimes(2); // initial + one retry + expect(reconcileOrganizationMembership).not.toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledTimes(1); expect(createBrevoCustomer).toHaveBeenCalled(); expect(capturePostHogEvent).toHaveBeenCalled(); @@ -335,6 +348,7 @@ describe("provisionSsoUserMemberships", () => { .mockResolvedValue(undefined as never); await provisionSsoUserMemberships(baseArgs); expect(createMembership).toHaveBeenCalledTimes(2); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith("org-1", "u1"); expect(logger.error).not.toHaveBeenCalled(); }); diff --git a/apps/web/modules/ee/sso/lib/sso-provisioning.ts b/apps/web/modules/ee/sso/lib/sso-provisioning.ts index d9b0ac4fffe1..d5bf368a4c19 100644 --- a/apps/web/modules/ee/sso/lib/sso-provisioning.ts +++ b/apps/web/modules/ee/sso/lib/sso-provisioning.ts @@ -4,6 +4,9 @@ import type { IdentityProvider } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; import type { TUserNotificationSettings } from "@formbricks/types/user"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO, WEBAPP_URL } from "@/lib/constants"; import { getIsFreshInstance } from "@/lib/instance/service"; import { createMembership } from "@/lib/membership/service"; @@ -171,9 +174,17 @@ export const provisionSsoUserMemberships = async ({ for (let attempt = 1; attempt <= MAX_ATTEMPTS && !assigned; attempt++) { try { await prisma.$transaction(async (tx) => { - await createMembership(organizationId, userId, { role: "member", accepted: true }, tx); + await createMembership( + organizationId, + userId, + { role: "member", accepted: true }, + { projection: "deferred", transaction: tx } + ); if (assignToDefaultTeam) { - await createDefaultTeamMembership(userId, tx); + await createDefaultTeamMembership(userId, { + projection: "deferred", + transaction: tx, + }); } const dbUser = await tx.user.findUnique({ where: { id: userId }, @@ -194,6 +205,15 @@ export const provisionSsoUserMemberships = async ({ tx ); }); + await reconcileOrganizationMembership(organizationId, userId); + if (assignToDefaultTeam && DEFAULT_TEAM_ID) { + const defaultTeamId = DEFAULT_TEAM_ID; + await runPostCommitProjection("sso_default_team_membership_create", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: [{ teamId: defaultTeamId, userId }], + }) + ); + } assigned = true; } catch (error) { // The user + account are already committed by Better Auth; never throw here (it would not diff --git a/apps/web/modules/ee/sso/lib/team.ts b/apps/web/modules/ee/sso/lib/team.ts index f5699faa5e7f..828e7bf8d969 100644 --- a/apps/web/modules/ee/sso/lib/team.ts +++ b/apps/web/modules/ee/sso/lib/team.ts @@ -11,6 +11,10 @@ import { validateInputs } from "@/lib/utils/validate"; import { createTeamMembership } from "@/modules/auth/signup/lib/team"; type TSsoTeamDbClient = PrismaClient | Prisma.TransactionClient; +type TDeferredSsoTeamProjection = Readonly<{ + projection: "deferred"; + transaction: Prisma.TransactionClient; +}>; const getDbClient = (tx?: Prisma.TransactionClient): TSsoTeamDbClient => tx ?? prisma; @@ -56,9 +60,10 @@ const getTeam = reactCache(async (teamId: string): Promise => { } }); -export const createDefaultTeamMembership = async (userId: string, tx?: Prisma.TransactionClient) => { +export const createDefaultTeamMembership = async (userId: string, options?: TDeferredSsoTeamProjection) => { try { - const prismaClient = getDbClient(tx); + const transaction = options?.transaction; + const prismaClient = getDbClient(transaction); const defaultTeamId = DEFAULT_TEAM_ID; if (!defaultTeamId) { @@ -66,7 +71,7 @@ export const createDefaultTeamMembership = async (userId: string, tx?: Prisma.Tr return; } - const defaultTeam = tx + const defaultTeam = transaction ? await prismaClient.team.findUnique({ where: { id: defaultTeamId, @@ -82,7 +87,7 @@ export const createDefaultTeamMembership = async (userId: string, tx?: Prisma.Tr const organizationMembership = await getMembershipByUserIdOrganizationId( userId, defaultTeam.organizationId, - tx + transaction ); if (!organizationMembership) { @@ -99,7 +104,7 @@ export const createDefaultTeamMembership = async (userId: string, tx?: Prisma.Tr teamIds: [defaultTeamId], }, userId, - tx + options ); } catch (error) { logger.error( diff --git a/apps/web/modules/ee/sso/lib/tests/team.test.ts b/apps/web/modules/ee/sso/lib/tests/team.test.ts index 113375453faa..2dfc67df1d55 100644 --- a/apps/web/modules/ee/sso/lib/tests/team.test.ts +++ b/apps/web/modules/ee/sso/lib/tests/team.test.ts @@ -34,6 +34,10 @@ const setupMocks = () => { getMembershipByUserIdOrganizationId: vi.fn(), })); + vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), + })); + vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), @@ -112,7 +116,10 @@ describe("Team Management", () => { vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP); - await createDefaultTeamMembership(MOCK_IDS.userId, tx); + await createDefaultTeamMembership(MOCK_IDS.userId, { + projection: "deferred", + transaction: tx, + }); expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith( MOCK_IDS.userId, diff --git a/apps/web/modules/ee/teams/team-list/actions.test.ts b/apps/web/modules/ee/teams/team-list/actions.test.ts new file mode 100644 index 000000000000..3fff2cd0c0ad --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/actions.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { assertCan } from "@/lib/authorization"; +import { createTeamAction, deleteTeamAction, getTeamDetailsAction, updateTeamDetailsAction } from "./actions"; + +const mocks = vi.hoisted(() => ({ + checkRoleManagementPermission: vi.fn(), + createTeam: vi.fn(), + deleteTeam: vi.fn(), + getOrganizationIdFromTeamId: vi.fn(), + getTeamDetails: vi.fn(), + updateTeamDetails: vi.fn(), +})); + +vi.mock("@/lib/authorization", () => ({ + assertCan: vi.fn(), +})); + +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { + inputSchema: vi.fn(() => ({ + action: vi.fn((fn) => fn), + })), + }, +})); + +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromTeamId: mocks.getOrganizationIdFromTeamId, +})); + +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn), +})); + +vi.mock("@/modules/ee/role-management/actions", () => ({ + checkRoleManagementPermission: mocks.checkRoleManagementPermission, +})); + +vi.mock("@/modules/ee/teams/lib/roles", () => ({ + getTeamRoleByTeamIdUserId: vi.fn(), +})); + +vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({ + createTeam: mocks.createTeam, + deleteTeam: mocks.deleteTeam, + getTeamDetails: mocks.getTeamDetails, + updateTeamDetails: mocks.updateTeamDetails, +})); + +describe("team-list authorization", () => { + const organizationId = "org-1"; + const teamId = "team-1"; + const ctx = { user: { id: "user-1" }, auditLoggingCtx: {} }; + const workspaceGrant = { workspaceId: "workspace-1", workspaceName: "Workspace", permission: "read" }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getOrganizationIdFromTeamId.mockResolvedValue(organizationId); + mocks.createTeam.mockResolvedValue(teamId); + mocks.getTeamDetails.mockResolvedValue({ + id: teamId, + name: "Team", + members: [{ userId: "user-1", name: "User", role: "admin" }], + workspaces: [workspaceGrant], + }); + }); + + test("requires organization.manage to create a team", async () => { + await createTeamAction({ + ctx, + parsedInput: { organizationId, name: "Team" }, + } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "organization.manage", { + type: "organization", + id: organizationId, + }); + expect(mocks.checkRoleManagementPermission).toHaveBeenCalledWith(organizationId); + }); + + test.each([ + ["read details", getTeamDetailsAction, { teamId }], + [ + "update", + updateTeamDetailsAction, + { + teamId, + data: { + name: "Updated", + members: [{ userId: "user-1", role: "admin" }], + workspaces: [{ workspaceId: workspaceGrant.workspaceId, permission: workspaceGrant.permission }], + }, + }, + ], + ] as const)("requires team.manage to %s", async (_name, action, parsedInput) => { + await action({ ctx, parsedInput } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "team.manage", { + type: "team", + id: teamId, + }); + }); + + test("requires team.delete to delete a team", async () => { + await deleteTeamAction({ ctx, parsedInput: { teamId } } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "team.delete", { + type: "team", + id: teamId, + }); + }); + + test("preserves entitlement checks after authorization", async () => { + const callOrder: string[] = []; + vi.mocked(assertCan).mockImplementation(async () => { + callOrder.push("authorize"); + }); + mocks.checkRoleManagementPermission.mockImplementation(async () => { + callOrder.push("entitlement"); + }); + + await createTeamAction({ + ctx, + parsedInput: { organizationId, name: "Team" }, + } as never); + + expect(callOrder).toEqual(["authorize", "entitlement"]); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/actions.ts b/apps/web/modules/ee/teams/team-list/actions.ts index c83d7af3ca55..bb05f9f3f32b 100644 --- a/apps/web/modules/ee/teams/team-list/actions.ts +++ b/apps/web/modules/ee/teams/team-list/actions.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; +import { assertCan } from "@/lib/authorization"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromTeamId } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { checkRoleManagementPermission } from "@/modules/ee/role-management/actions"; @@ -24,15 +24,9 @@ const ZCreateTeamAction = z.object({ export const createTeamAction = authenticatedActionClient.inputSchema(ZCreateTeamAction).action( withAuditLogging("created", "team", async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); await checkRoleManagementPermission(parsedInput.organizationId); @@ -55,20 +49,9 @@ export const getTeamDetailsAction = authenticatedActionClient .action(async ({ parsedInput, ctx }) => { const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - teamId: parsedInput.teamId, - type: "team", - minPermission: "admin", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "team.manage", { + type: "team", + id: parsedInput.teamId, }); await checkRoleManagementPermission(organizationId); @@ -84,15 +67,9 @@ export const deleteTeamAction = authenticatedActionClient.inputSchema(ZDeleteTea withAuditLogging("deleted", "team", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "team.delete", { + type: "team", + id: parsedInput.teamId, }); await checkRoleManagementPermission(organizationId); @@ -113,20 +90,9 @@ export const updateTeamDetailsAction = authenticatedActionClient.inputSchema(ZUp withAuditLogging("updated", "team", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromTeamId(parsedInput.teamId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "team", - teamId: parsedInput.teamId, - minPermission: "admin", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "team.manage", { + type: "team", + id: parsedInput.teamId, }); await checkRoleManagementPermission(organizationId); @@ -138,15 +104,9 @@ export const updateTeamDetailsAction = authenticatedActionClient.inputSchema(ZUp // list — so without this gate they could grant their own team `manage` on every workspace in the // organization. Changing workspace access stays owner/manager-only, matching what the UI offers. if (hasWorkspaceAccessChanges(oldObject?.workspaces ?? [], parsedInput.data.workspaces)) { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); } diff --git a/apps/web/modules/ee/teams/team-list/lib/team.test.ts b/apps/web/modules/ee/teams/team-list/lib/team.test.ts index 95b671682a0a..c741e4706938 100644 --- a/apps/web/modules/ee/teams/team-list/lib/team.test.ts +++ b/apps/web/modules/ee/teams/team-list/lib/team.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { TTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team"; import { createTeam, @@ -32,6 +33,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); + const mockTeams = [ { id: "t1", name: "Team 1", organizationId: "org1", createdAt: new Date(), updatedAt: new Date() }, { id: "t2", name: "Team 2", organizationId: "org1", createdAt: new Date(), updatedAt: new Date() }, @@ -166,6 +171,7 @@ describe("createTeam", () => { }); const result = await createTeam("org1", "Team 1"); expect(result).toBe("t1"); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamIds: ["t1"] }); }); test("throws InvalidInputError if team exists", async () => { vi.mocked(prisma.team.findFirst).mockResolvedValueOnce({ @@ -236,6 +242,7 @@ describe("deleteTeam", () => { vi.mocked(prisma.team.delete).mockResolvedValueOnce(mockTeam); const result = await deleteTeam("t1"); expect(result).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ teamIds: ["t1"] }); }); test("throws DatabaseError on Prisma error", async () => { vi.mocked(prisma.team.delete).mockRejectedValueOnce( @@ -276,6 +283,31 @@ describe("updateTeamDetails", () => { }); const result = await updateTeamDetails("t1", data); expect(result).toBe(true); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamIds: ["t1"], + teamMemberships: [ + { teamId: "t1", userId: "u1" }, + { teamId: "t1", userId: "u2" }, + ], + workspaceTeamGrants: [{ teamId: "t1", workspaceId: "p1" }], + }); + }); + test("does not change a successful update result when projection unexpectedly rejects", async () => { + vi.mocked(prisma.team.findUnique) + .mockResolvedValueOnce({ + id: "t1", + organizationId: "org1", + name: "Team 1", + createdAt: new Date(), + updatedAt: new Date(), + }) + .mockResolvedValueOnce(mockTeamDetails); + vi.mocked(prisma.membership.count).mockResolvedValueOnce(1); + vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1); + vi.mocked(prisma.team.update).mockResolvedValueOnce({ id: "t1" } as never); + vi.mocked(reconcileTeamWorkspaceRelationships).mockRejectedValueOnce(new Error("private")); + + await expect(updateTeamDetails("t1", data)).resolves.toBe(true); }); test("throws ResourceNotFoundError if team not found", async () => { vi.mocked(prisma.team.findUnique).mockResolvedValueOnce(null); diff --git a/apps/web/modules/ee/teams/team-list/lib/team.ts b/apps/web/modules/ee/teams/team-list/lib/team.ts index 62d51c0827c4..bda588064992 100644 --- a/apps/web/modules/ee/teams/team-list/lib/team.ts +++ b/apps/web/modules/ee/teams/team-list/lib/team.ts @@ -5,6 +5,8 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { validateInputs } from "@/lib/utils/validate"; import { TOrganizationTeam, @@ -192,6 +194,10 @@ export const createTeam = async (organizationId: string, name: string): Promise< }, }); + await runPostCommitProjection("team_create", () => + reconcileTeamWorkspaceRelationships({ teamIds: [team.id] }) + ); + return team.id; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -283,6 +289,10 @@ export const deleteTeam = async (teamId: string): Promise => { }, }); + await runPostCommitProjection("team_delete", () => + reconcileTeamWorkspaceRelationships({ teamIds: [teamId] }) + ); + return true; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -390,6 +400,26 @@ export const updateTeamDetails = async (teamId: string, data: TTeamSettingsFormS data: payload, }); + const membershipUserIds = new Set([ + ...currentTeamDetails.members.map((member) => member.userId), + ...members.map((member) => member.userId), + ]); + const grantWorkspaceIds = new Set([ + ...currentTeamDetails.workspaces.map((workspace) => workspace.workspaceId), + ...workspaces.map((workspace) => workspace.workspaceId), + ]); + + await runPostCommitProjection("team_details_update", () => + reconcileTeamWorkspaceRelationships({ + teamIds: [teamId], + teamMemberships: [...membershipUserIds].map((userId) => ({ teamId, userId })), + workspaceTeamGrants: [...grantWorkspaceIds].map((workspaceId) => ({ + teamId, + workspaceId, + })), + }) + ); + return true; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/web/modules/ee/unify-feedback/actions.test.ts b/apps/web/modules/ee/unify-feedback/actions.test.ts new file mode 100644 index 000000000000..5c6e463a82dd --- /dev/null +++ b/apps/web/modules/ee/unify-feedback/actions.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; +import { deleteFeedbackRecordAction } from "./actions"; + +const mocks = vi.hoisted(() => { + const action = vi.fn((handler) => handler); + return { + action, + inputSchema: vi.fn(() => ({ action })), + applyRateLimit: vi.fn(), + ensureDeleteAccess: vi.fn(), + getWorkspaceDirectoryIds: vi.fn(), + retrieveFeedbackRecord: vi.fn(), + deleteFeedbackRecord: vi.fn(), + assertRecordBelongsToWorkspace: vi.fn(), + assertFeedbackDirectoryAssignmentAccess: vi.fn(), + }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { inputSchema: mocks.inputSchema }, +})); +vi.mock("@/modules/core/rate-limit/helpers", () => ({ applyRateLimit: mocks.applyRateLimit })); +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_event, _target, handler) => handler), +})); +vi.mock("@/modules/ee/unify-feedback/lib/access", () => ({ + assertFeedbackDirectoryAssignmentAccess: mocks.assertFeedbackDirectoryAssignmentAccess, + assertRecordBelongsToWorkspace: mocks.assertRecordBelongsToWorkspace, + ensureDeleteAccess: mocks.ensureDeleteAccess, + ensureReadAccess: vi.fn(), + getWorkspaceDirectoryIds: mocks.getWorkspaceDirectoryIds, +})); +vi.mock("@/modules/hub/service", () => ({ + deleteFeedbackRecord: mocks.deleteFeedbackRecord, + retrieveFeedbackRecord: mocks.retrieveFeedbackRecord, +})); + +describe("deleteFeedbackRecordAction", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureDeleteAccess.mockResolvedValue("organization-1"); + mocks.getWorkspaceDirectoryIds.mockResolvedValue(["directory-1"]); + mocks.retrieveFeedbackRecord.mockResolvedValue({ + data: { + id: "record-1", + tenant_id: "directory-1", + submission_id: "submission-1", + source_type: "survey", + source_id: "source-1", + field_id: "field-1", + field_type: "text", + collected_at: "2026-08-16T00:00:00.000Z", + }, + error: null, + }); + mocks.deleteFeedbackRecord.mockResolvedValue({ data: { id: "record-1" }, error: null }); + }); + + test("rate limits the mutation before deleting the record", async () => { + const ctx = { user: { id: "user-1" }, auditLoggingCtx: {} }; + + await deleteFeedbackRecordAction({ + ctx, + parsedInput: { recordId: "record-1", workspaceId: "workspace-1" }, + } as any); + + expect(mocks.applyRateLimit).toHaveBeenCalledWith( + rateLimitConfigs.actions.feedbackRecordDeletion, + "user-1" + ); + expect(mocks.deleteFeedbackRecord).toHaveBeenCalledWith("record-1"); + expect(mocks.applyRateLimit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.deleteFeedbackRecord.mock.invocationCallOrder[0] + ); + }); +}); diff --git a/apps/web/modules/ee/unify-feedback/actions.ts b/apps/web/modules/ee/unify-feedback/actions.ts index 630b75e87263..297704fcb76b 100644 --- a/apps/web/modules/ee/unify-feedback/actions.ts +++ b/apps/web/modules/ee/unify-feedback/actions.ts @@ -3,8 +3,11 @@ import { ResourceNotFoundError } from "@formbricks/types/errors"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { + assertFeedbackDirectoryAssignmentAccess, assertRecordBelongsToWorkspace, ensureDeleteAccess, ensureReadAccess, @@ -42,6 +45,11 @@ export const retrieveFeedbackRecordAction = authenticatedActionClient recordResult.data.tenant_id, parsedInput.recordId ); + await assertFeedbackDirectoryAssignmentAccess( + ctx.user.id, + recordResult.data.tenant_id, + parsedInput.workspaceId + ); return recordResult.data; } @@ -53,6 +61,7 @@ export const deleteFeedbackRecordAction = authenticatedActionClient withAuditLogging("deleted", "feedbackRecord", async ({ ctx, parsedInput }) => { // Set before the access check so a refused or failed attempt is still attributable. ctx.auditLoggingCtx.feedbackRecordId = parsedInput.recordId; + await applyRateLimit(rateLimitConfigs.actions.feedbackRecordDeletion, ctx.user.id); const [organizationId, workspaceDirectoryIds] = await Promise.all([ ensureDeleteAccess(ctx.user.id, parsedInput.workspaceId), @@ -70,6 +79,11 @@ export const deleteFeedbackRecordAction = authenticatedActionClient currentRecordResult.data.tenant_id, parsedInput.recordId ); + await assertFeedbackDirectoryAssignmentAccess( + ctx.user.id, + currentRecordResult.data.tenant_id, + parsedInput.workspaceId + ); const deleteResult = await deleteFeedbackRecord(parsedInput.recordId); if (!deleteResult.data || deleteResult.error) { diff --git a/apps/web/modules/ee/unify-feedback/lib/access.test.ts b/apps/web/modules/ee/unify-feedback/lib/access.test.ts index 931c57dbb1eb..81360083331e 100644 --- a/apps/web/modules/ee/unify-feedback/lib/access.test.ts +++ b/apps/web/modules/ee/unify-feedback/lib/access.test.ts @@ -4,27 +4,27 @@ import { OperationNotAllowedError, ResourceNotFoundError, } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { assertCan } from "@/lib/authorization"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { + assertFeedbackDirectoryAssignmentAccess, assertRecordBelongsToWorkspace, ensureDeleteAccess, ensureReadAccess, + getAuthorizedWorkspaceFeedbackDirectories, getWorkspaceDirectoryIds, } from "./access"; vi.mock("server-only", () => ({})); +vi.mock("@/lib/authorization", () => ({ assertCan: vi.fn() })); + vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ getFeedbackDirectoriesByWorkspaceId: vi.fn(), })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: vi.fn(), -})); - vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: vi.fn(), })); @@ -66,6 +66,45 @@ describe("getWorkspaceDirectoryIds", () => { }); }); +describe("getAuthorizedWorkspaceFeedbackDirectories", () => { + test("checks every exact directory assignment before returning the source rows", async () => { + const directories = [ + { id: sharedDirectoryId, name: "Shared" }, + { id: otherOrgDirectoryId, name: "Support" }, + ]; + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue(directories); + vi.mocked(assertCan).mockResolvedValue(undefined); + + await expect(getAuthorizedWorkspaceFeedbackDirectories(userId, workspaceId)).resolves.toEqual( + directories + ); + expect(assertCan).toHaveBeenCalledTimes(2); + expect(assertCan).toHaveBeenNthCalledWith( + 1, + { type: "user", id: userId }, + "feedbackDirectoryAssignment.read", + { type: "feedbackDirectoryAssignment", feedbackDirectoryId: sharedDirectoryId, workspaceId } + ); + expect(assertCan).toHaveBeenNthCalledWith( + 2, + { type: "user", id: userId }, + "feedbackDirectoryAssignment.read", + { type: "feedbackDirectoryAssignment", feedbackDirectoryId: otherOrgDirectoryId, workspaceId } + ); + }); + + test("propagates an exact assignment refusal", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([ + { id: sharedDirectoryId, name: "Shared" }, + ]); + vi.mocked(assertCan).mockRejectedValue(new AuthorizationError("Not authorized")); + + await expect(getAuthorizedWorkspaceFeedbackDirectories(userId, workspaceId)).rejects.toThrow( + AuthorizationError + ); + }); +}); + describe("assertRecordBelongsToWorkspace", () => { test("passes when the record's tenant is a directory assigned to the workspace", () => { expect(() => @@ -105,7 +144,7 @@ describe("license gating", () => { vi.resetAllMocks(); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); + vi.mocked(assertCan).mockResolvedValue(undefined); }); test.each([ @@ -115,7 +154,7 @@ describe("license gating", () => { vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(false); await expect(ensureAccess(userId, workspaceId)).rejects.toThrow(OperationNotAllowedError); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(assertCan).not.toHaveBeenCalled(); }); }); @@ -127,7 +166,7 @@ describe("ensureDeleteAccess", () => { vi.resetAllMocks(); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); + vi.mocked(assertCan).mockResolvedValue(undefined); }); // Re-adding a workspaceTeam entry to this access list is what reopens ENG-1770, so the list itself is @@ -135,10 +174,9 @@ describe("ensureDeleteAccess", () => { test("requires an organization owner or manager, with no workspace-team fallback", async () => { await ensureDeleteAccess(userId, workspaceId); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId, - organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, }); }); @@ -147,30 +185,40 @@ describe("ensureDeleteAccess", () => { }); test("propagates the refusal for a caller who is not an owner or manager", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("Not authorized")); + vi.mocked(assertCan).mockRejectedValue(new AuthorizationError("Not authorized")); await expect(ensureDeleteAccess(userId, workspaceId)).rejects.toThrow(AuthorizationError); }); }); +describe("assertFeedbackDirectoryAssignmentAccess", () => { + test("checks exact directory and workspace read access", async () => { + vi.mocked(assertCan).mockResolvedValue(undefined); + + await assertFeedbackDirectoryAssignmentAccess(userId, sharedDirectoryId, workspaceId); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: userId }, "feedbackDirectoryAssignment.read", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId: sharedDirectoryId, + workspaceId, + }); + }); +}); + describe("ensureReadAccess", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue(organizationId); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); + vi.mocked(assertCan).mockResolvedValue(undefined); }); test("also admits workspace readers, since reading the shared dataset is the point of sharing it", async () => { await ensureReadAccess(userId, workspaceId); - expect(checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId, - organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", minPermission: "read", workspaceId }, - ], + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: userId }, "workspace.read", { + type: "workspace", + id: workspaceId, }); }); }); diff --git a/apps/web/modules/ee/unify-feedback/lib/access.ts b/apps/web/modules/ee/unify-feedback/lib/access.ts index 284d19772935..938d42e2f83a 100644 --- a/apps/web/modules/ee/unify-feedback/lib/access.ts +++ b/apps/web/modules/ee/unify-feedback/lib/access.ts @@ -1,6 +1,6 @@ import "server-only"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { assertCan } from "@/lib/authorization"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -20,22 +20,8 @@ export const ensureUnifyEnabled = async (workspaceId: string): Promise = * point of sharing it. */ export const ensureReadAccess = async (userId: string, workspaceId: string): Promise => { - const organizationId = await ensureUnifyEnabled(workspaceId); - await checkAuthorizationUpdated({ - userId, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId, - }, - ], - }); + await ensureUnifyEnabled(workspaceId); + await assertCan({ type: "user", id: userId }, "workspace.read", { type: "workspace", id: workspaceId }); }; /** @@ -54,19 +40,40 @@ export const ensureReadAccess = async (userId: string, workspaceId: string): Pro */ export const ensureDeleteAccess = async (userId: string, workspaceId: string): Promise => { const organizationId = await ensureUnifyEnabled(workspaceId); - await checkAuthorizationUpdated({ - userId, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, }); return organizationId; }; +export const assertFeedbackDirectoryAssignmentAccess = async ( + userId: string, + feedbackDirectoryId: string, + workspaceId: string +): Promise => + assertCan({ type: "user", id: userId }, "feedbackDirectoryAssignment.read", { + type: "feedbackDirectoryAssignment", + feedbackDirectoryId, + workspaceId, + }); + +/** + * Return the active datasets assigned to a workspace after issuing one exact assignment check per dataset. + * The PostgreSQL lookup remains authoritative; the checks give shadow mode coverage for server-rendered + * dataset, source, chart, and taxonomy entry points without introducing a second filtering model. + */ +export const getAuthorizedWorkspaceFeedbackDirectories = async ( + userId: string, + workspaceId: string +): Promise<{ id: string; name: string }[]> => { + const directories = await getFeedbackDirectoriesByWorkspaceId(workspaceId); + await Promise.all( + directories.map(({ id }) => assertFeedbackDirectoryAssignmentAccess(userId, id, workspaceId)) + ); + return directories; +}; + /** Ids of the feedback directories (Hub tenants) assigned to a workspace. */ export const getWorkspaceDirectoryIds = async (workspaceId: string): Promise> => { const directories = await getFeedbackDirectoriesByWorkspaceId(workspaceId); diff --git a/apps/web/modules/ee/unify-feedback/page.tsx b/apps/web/modules/ee/unify-feedback/page.tsx index d65bbedaf899..44af119650cd 100644 --- a/apps/web/modules/ee/unify-feedback/page.tsx +++ b/apps/web/modules/ee/unify-feedback/page.tsx @@ -3,10 +3,10 @@ import { logger } from "@formbricks/logger"; import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getFeedbackSourcesWithMappings } from "@/lib/feedback-source/service"; import { getTranslate } from "@/lingodotdev/server"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { FeedbackDataEmptyState } from "@/modules/ee/unify-feedback/components/feedback-data-empty-state"; import { UnifyConfigNavigation } from "@/modules/ee/unify-feedback/components/unify-config-navigation"; +import { getAuthorizedWorkspaceFeedbackDirectories } from "@/modules/ee/unify-feedback/lib/access"; import { getContactIdsByUserIds } from "@/modules/ee/unify-feedback/lib/contacts"; import { listFeedbackRecords } from "@/modules/hub/service"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; @@ -71,7 +71,7 @@ export default async function UnifyFeedbackRecordsPage( } const [frds, feedbackSources] = await Promise.all([ - getFeedbackDirectoriesByWorkspaceId(params.workspaceId), + getAuthorizedWorkspaceFeedbackDirectories(session.user.id, params.workspaceId), getFeedbackSourcesWithMappings(params.workspaceId), ]); diff --git a/apps/web/modules/ee/unify-feedback/sources/actions.ts b/apps/web/modules/ee/unify-feedback/sources/actions.ts index 1a96afd6fd4b..d9f957c315f2 100644 --- a/apps/web/modules/ee/unify-feedback/sources/actions.ts +++ b/apps/web/modules/ee/unify-feedback/sources/actions.ts @@ -3,9 +3,9 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { getSurveys } from "@/lib/survey/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { transformToUnifySurvey } from "./lib"; @@ -23,24 +23,9 @@ export const getSurveysForUnifyAction = authenticatedActionClient if (!isFeedbackDirectoriesAllowed) { throw new OperationNotAllowedError("Unify Feedback is not enabled for this organization"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - // "member" must not appear here: the access items are OR'd, and every org member satisfies an - // organization item, which would make the workspaceTeam check below dead and let any member - // list surveys in workspaces they have no team access to. Members reach this through their - // team permission instead. - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: parsedInput.workspaceId, }); const surveys = await getSurveys(parsedInput.workspaceId); diff --git a/apps/web/modules/ee/unify-feedback/sources/page.tsx b/apps/web/modules/ee/unify-feedback/sources/page.tsx index d5afdc0c5a69..998845c06dc2 100644 --- a/apps/web/modules/ee/unify-feedback/sources/page.tsx +++ b/apps/web/modules/ee/unify-feedback/sources/page.tsx @@ -3,10 +3,10 @@ import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/ import { getFeedbackSourcesWithMappings } from "@/lib/feedback-source/service"; import { getSurveys } from "@/lib/survey/service"; import { getTranslate } from "@/lingodotdev/server"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { FeedbackDataEmptyState } from "@/modules/ee/unify-feedback/components/feedback-data-empty-state"; import { UnifyConfigNavigation } from "@/modules/ee/unify-feedback/components/unify-config-navigation"; +import { getAuthorizedWorkspaceFeedbackDirectories } from "@/modules/ee/unify-feedback/lib/access"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; @@ -74,7 +74,7 @@ export const UnifyFeedbackSourcesPage = async ( const [feedbackSources, surveys, directories] = await Promise.all([ getFeedbackSourcesWithMappings(workspaceId), getSurveys(workspaceId), - getFeedbackDirectoriesByWorkspaceId(workspaceId), + getAuthorizedWorkspaceFeedbackDirectories(session.user.id, workspaceId), ]); if (directories.length === 0) { diff --git a/apps/web/modules/ee/unify-feedback/topics-subtopics/page.tsx b/apps/web/modules/ee/unify-feedback/topics-subtopics/page.tsx index 175cf33317b8..3f1a322fcdf6 100644 --- a/apps/web/modules/ee/unify-feedback/topics-subtopics/page.tsx +++ b/apps/web/modules/ee/unify-feedback/topics-subtopics/page.tsx @@ -1,10 +1,11 @@ import { notFound } from "next/navigation"; +import { can } from "@/lib/authorization"; import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; -import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import { FeedbackDataEmptyState } from "@/modules/ee/unify-feedback/components/feedback-data-empty-state"; import { UnifyConfigNavigation } from "@/modules/ee/unify-feedback/components/unify-config-navigation"; +import { getAuthorizedWorkspaceFeedbackDirectories } from "@/modules/ee/unify-feedback/lib/access"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt"; @@ -61,7 +62,7 @@ export const UnifyTopicsSubtopicsPage = async ( ); } - const directories = await getFeedbackDirectoriesByWorkspaceId(params.workspaceId); + const directories = await getAuthorizedWorkspaceFeedbackDirectories(session.user.id, params.workspaceId); if (directories.length === 0) { return ( @@ -82,7 +83,10 @@ export const UnifyTopicsSubtopicsPage = async ( // A directory's taxonomy is one tree shared by every workspace the directory is assigned to, and it // carries no workspace of its own — so changing it (generate, rename, remove) is an org-level act // and stays with owners and managers (ENG-1770). Everyone else gets the read-only view. - const canWrite = isOwner || isManager; + const canWrite = await can({ type: "user", id: session.user.id }, "organization.manage", { + type: "organization", + id: organization.id, + }); return ( diff --git a/apps/web/modules/ee/whitelabel/email-customization/actions.ts b/apps/web/modules/ee/whitelabel/email-customization/actions.ts index eb534c43bca8..d554b4eefbb1 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/actions.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/actions.ts @@ -3,9 +3,11 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { getOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; import { @@ -37,16 +39,11 @@ export const updateOrganizationEmailLogoUrlAction = authenticatedActionClient .inputSchema(ZUpdateOrganizationEmailLogoUrlAction) .action( withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.organizationId); await checkWhiteLabelPermission(parsedInput.organizationId); ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; @@ -63,11 +60,11 @@ export const removeOrganizationEmailLogoUrlAction = authenticatedActionClient .inputSchema(ZRemoveOrganizationEmailLogoUrlAction) .action( withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.organizationId); await checkWhiteLabelPermission(parsedInput.organizationId); ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; @@ -89,10 +86,9 @@ export const sendTestEmailAction = authenticatedActionClient throw new ResourceNotFoundError("Organization", parsedInput.organizationId); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: organization.id, - access: [{ type: "organization", roles: ["owner", "manager"] }], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organization.id, }); await checkWhiteLabelPermission(organization.id); diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts b/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts index 90f97957522e..abcebabfc6ed 100644 --- a/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts +++ b/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts @@ -2,8 +2,10 @@ import { z } from "zod"; import { ZId, ZStorageUrl } from "@formbricks/types/common"; +import { assertCan } from "@/lib/authorization"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { checkWhiteLabelPermission } from "@/modules/ee/whitelabel/email-customization/actions"; import { updateOrganizationFaviconUrl } from "@/modules/ee/whitelabel/favicon-customization/lib/organization"; @@ -19,16 +21,11 @@ export const updateOrganizationFaviconUrlAction = authenticatedActionClient withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => { const { organizationId, faviconUrl } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, organizationId); await checkWhiteLabelPermission(organizationId); @@ -49,11 +46,11 @@ export const removeOrganizationFaviconUrlAction = authenticatedActionClient withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => { const { organizationId } = parsedInput; - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, organizationId); await checkWhiteLabelPermission(organizationId); diff --git a/apps/web/modules/ee/whitelabel/remove-branding/actions.ts b/apps/web/modules/ee/whitelabel/remove-branding/actions.ts index 0c041fd61190..07e3111abb8e 100644 --- a/apps/web/modules/ee/whitelabel/remove-branding/actions.ts +++ b/apps/web/modules/ee/whitelabel/remove-branding/actions.ts @@ -3,11 +3,13 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils"; import { updateWorkspaceBranding } from "@/modules/ee/whitelabel/remove-branding/lib/workspace"; @@ -25,21 +27,11 @@ export const updateWorkspaceBrandingAction = authenticatedActionClient withAuditLogging("updated", "workspace", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "manage", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.manage", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); if ( parsedInput.data.inAppSurveyBranding !== undefined || diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts index 0b9ce2353900..b61fd2591164 100644 --- a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts @@ -66,6 +66,12 @@ vi.mock("@formbricks/database", () => ({ // Prisma's known-request-error shape the claim path checks for a P2002 unique-constraint conflict. vi.mock("@formbricks/database/prisma", () => ({ + // The workflow runner's merged import graph reaches the AuthZed projectors, which map these + // Prisma enums to relation names at module scope. Values mirror the Prisma schema. + ApiKeyPermission: { manage: "manage", read: "read", write: "write" }, + OrganizationRole: { billing: "billing", manager: "manager", member: "member", owner: "owner" }, + TeamUserRole: { admin: "admin", contributor: "contributor" }, + WorkspaceTeamPermission: { manage: "manage", read: "read", readWrite: "readWrite" }, Prisma: { PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { code: string; diff --git a/apps/web/modules/envoy-auth/service.test.ts b/apps/web/modules/envoy-auth/service.test.ts index fca2beeae112..3b0dec99045e 100644 --- a/apps/web/modules/envoy-auth/service.test.ts +++ b/apps/web/modules/envoy-auth/service.test.ts @@ -10,7 +10,7 @@ const { mockVerifyFeedbackRecordsGatewayToken, mockGetFeedbackDirectoryAuthContext, mockGetFeedbackRecordTenant, - mockCheckAuthorizationUpdated, + mockCan, mockUserFindUnique, mockGetIsFeedbackDirectoriesEnabled, } = vi.hoisted(() => ({ @@ -21,7 +21,7 @@ const { mockVerifyFeedbackRecordsGatewayToken: vi.fn(), mockGetFeedbackDirectoryAuthContext: vi.fn(), mockGetFeedbackRecordTenant: vi.fn(), - mockCheckAuthorizationUpdated: vi.fn(), + mockCan: vi.fn(), mockUserFindUnique: vi.fn(), mockGetIsFeedbackDirectoriesEnabled: vi.fn(), })); @@ -64,8 +64,8 @@ vi.mock("@/modules/hub/service", () => ({ getFeedbackRecordTenant: mockGetFeedbackRecordTenant, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mockCheckAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + can: mockCan, })); vi.mock("@formbricks/logger", () => ({ @@ -116,7 +116,7 @@ describe("authorizeEnvoyRequest", () => { data: { tenantId: feedbackDirectoryId }, error: null, }); - mockCheckAuthorizationUpdated.mockResolvedValue(true); + mockCan.mockResolvedValue(true); mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true }); mockGetIsFeedbackDirectoriesEnabled.mockResolvedValue(true); }); @@ -144,7 +144,10 @@ describe("authorizeEnvoyRequest", () => { expect(response.status).toBe(200); expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBe("x-api-key,authorization,cookie"); - expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled(); + expect(mockCan).toHaveBeenCalledWith({ type: "apiKey", id: "key_1" }, "feedbackDirectory.write", { + type: "feedbackDirectory", + id: feedbackDirectoryId, + }); }); test("returns 400 when bulkDelete is missing tenant_id", async () => { @@ -292,15 +295,9 @@ describe("authorizeEnvoyRequest", () => { ); expect(response.status).toBe(200); - expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: "org_1", - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + expect(mockCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: "org_1", }); }); @@ -318,20 +315,9 @@ describe("authorizeEnvoyRequest", () => { ); expect(response.status).toBe(200); - expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_2", - organizationId: "org_1", - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: "workspace_1", - minPermission: "read", - }, - ], + expect(mockCan).toHaveBeenCalledWith({ type: "user", id: "user_2" }, "feedbackDirectory.read", { + type: "feedbackDirectory", + id: feedbackDirectoryId, }); }); diff --git a/apps/web/modules/hub/feedback-records-gateway.test.ts b/apps/web/modules/hub/feedback-records-gateway.test.ts index d6c62f2f762a..a38acddbf2bd 100644 --- a/apps/web/modules/hub/feedback-records-gateway.test.ts +++ b/apps/web/modules/hub/feedback-records-gateway.test.ts @@ -1,8 +1,9 @@ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { logger } from "@formbricks/logger"; import type { TAuthenticationApiKey } from "@formbricks/types/auth"; -import { AuthorizationError } from "@formbricks/types/errors"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import type { TGatewayAuthenticatedPrincipal } from "@/modules/gateway-auth/lib/request"; @@ -29,8 +30,9 @@ vi.mock("@/app/lib/api/request-body", () => ({ RequestBodyTooLargeError: class RequestBodyTooLargeError extends Error {}, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: vi.fn(), +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); +vi.mock("@/lib/authorization/context", () => ({ + withAuthorizationSurface: vi.fn((_surface, callback) => callback()), })); vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ @@ -83,8 +85,6 @@ const authorize = async ( }); }; -const accessArg = () => vi.mocked(checkAuthorizationUpdated).mock.calls.at(-1)?.[0]; - describe("feedbackRecordsGatewayAuthorizer", () => { beforeEach(() => { vi.resetAllMocks(); @@ -96,7 +96,7 @@ describe("feedbackRecordsGatewayAuthorizer", () => { }); vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); vi.mocked(getFeedbackRecordTenant).mockResolvedValue({ data: { tenantId: directoryId }, error: null }); - vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true); + vi.mocked(can).mockResolvedValue(true); }); // ENG-1770: records in a shared directory carry no workspace, so a workspace permission cannot @@ -112,16 +112,15 @@ describe("feedbackRecordsGatewayAuthorizer", () => { const decision = await authorize(method, path, userPrincipal); expect(decision.status).toBe("allow"); - expect(accessArg()).toEqual({ - userId: "user-1", - organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + expect(can).toHaveBeenLastCalledWith({ type: "user", id: "user-1" }, "organization.manage", { + type: "organization", + id: organizationId, }); } ); test("denies a delete when the caller is not an organization owner or manager", async () => { - vi.mocked(checkAuthorizationUpdated).mockRejectedValue(new AuthorizationError("Not authorized")); + vi.mocked(can).mockResolvedValue(false); const decision = await authorize("DELETE", `/api/v3/feedbackRecords/${recordId}`, userPrincipal); @@ -133,14 +132,9 @@ describe("feedbackRecordsGatewayAuthorizer", () => { const decision = await authorize("GET", `/api/v3/feedbackRecords/${recordId}`, userPrincipal); expect(decision.status).toBe("allow"); - expect(accessArg()).toEqual({ - userId: "user-1", - organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", workspaceId: workspaceA, minPermission: "read" }, - { type: "workspaceTeam", workspaceId: workspaceB, minPermission: "read" }, - ], + expect(can).toHaveBeenLastCalledWith({ type: "user", id: "user-1" }, "feedbackDirectory.read", { + type: "feedbackDirectory", + id: directoryId, }); }); @@ -150,11 +144,10 @@ describe("feedbackRecordsGatewayAuthorizer", () => { }); expect(decision.status).toBe("allow"); - expect(accessArg()?.access).toEqual([ - { type: "organization", roles: ["owner", "manager"] }, - { type: "workspaceTeam", workspaceId: workspaceA, minPermission: "readWrite" }, - { type: "workspaceTeam", workspaceId: workspaceB, minPermission: "readWrite" }, - ]); + expect(can).toHaveBeenLastCalledWith({ type: "user", id: "user-1" }, "feedbackDirectory.write", { + type: "feedbackDirectory", + id: directoryId, + }); }); }); @@ -261,7 +254,7 @@ describe("feedbackRecordsGatewayAuthorizer", () => { const decision = await authorize("GET", `/api/v3/feedbackRecords/${recordId}`, userPrincipal); expect(decision.status).toBe("deny"); - expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); + expect(can).not.toHaveBeenCalled(); }); // The directory-not-found half of the combined guard (!feedbackDirectory || @@ -274,4 +267,27 @@ describe("feedbackRecordsGatewayAuthorizer", () => { expect(decision.status).toBe("deny"); expect(decision.status === "deny" && decision.response.status).toBe(403); }); + + test("assigns authenticated gateway checks to the feedback gateway rollout surface", async () => { + await authorize("GET", `/api/v3/feedbackRecords/${recordId}`, userPrincipal); + + expect(withAuthorizationSurface).toHaveBeenCalledWith("feedback_gateway", expect.any(Function)); + }); + + test("does not put directory or record identifiers in authorization logs", async () => { + await authorize("GET", `/api/v3/feedbackRecords/${recordId}`, userPrincipal); + + expect(JSON.stringify(vi.mocked(logger.info).mock.calls)).not.toContain(directoryId); + expect(JSON.stringify(vi.mocked(logger.info).mock.calls)).not.toContain(recordId); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain(directoryId); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain(recordId); + }); + + test("propagates central evaluator failures instead of converting them to denial", async () => { + vi.mocked(can).mockRejectedValue(new Error("evaluator unavailable")); + + await expect(authorize("GET", `/api/v3/feedbackRecords/${recordId}`, userPrincipal)).rejects.toThrow( + "evaluator unavailable" + ); + }); }); diff --git a/apps/web/modules/hub/feedback-records-gateway.ts b/apps/web/modules/hub/feedback-records-gateway.ts index ed402b5d32a2..ef3d8feba922 100644 --- a/apps/web/modules/hub/feedback-records-gateway.ts +++ b/apps/web/modules/hub/feedback-records-gateway.ts @@ -3,14 +3,14 @@ import { NextRequest } from "next/server"; import { z } from "zod"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; -import { AuthorizationError } from "@formbricks/types/errors"; import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; +import { getFeedbackDirectoryAuthorizationAction } from "@/lib/authorization/permission-action"; import { verifyFeedbackRecordsGatewayToken } from "@/lib/jwt"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth"; import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; -import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; import { TGatewayAuthenticatedPrincipal, TGatewayRequestAuthorizer, @@ -63,20 +63,6 @@ const RECORD_MUTATING_OPERATIONS = new Set([ "bulkDelete", ]); -/** - * What a session principal's workspace team membership must grant for a given route permission. - * - * Only consulted for non-mutating operations — mutations drop the workspace-team fallback entirely - * (ENG-1770) — so the `manage` entry is unreachable today, every `manage` route being a mutation. It is - * spelled out anyway because a Record forces the next permission value added to the union to be mapped - * deliberately, where a ternary would silently collapse it onto `readWrite`. - */ -const GATEWAY_PERMISSION_TO_TEAM_PERMISSION: Record = { - read: "read", - write: "readWrite", - manage: "manage", -}; - const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TParsedGatewayRoute | null => { const normalizedPath = normalizeFeedbackRecordsPath(pathname); if (!normalizedPath) { @@ -227,19 +213,13 @@ const resolveTenantId = async ( const tenantLookup = await getFeedbackRecordTenant(route.recordId!); if (tenantLookup.error) { if (tenantLookup.error.status === 404) { - logger.warn( - { requestId, recordId: route.recordId }, - "Feedback record tenant lookup returned not found" - ); + logger.warn({ requestId }, "Feedback record tenant lookup returned not found"); return { errorResponse: buildGatewayStatusResponse(403, "Forbidden"), }; } - logger.warn( - { requestId, recordId: route.recordId, hubStatus: tenantLookup.error.status }, - "Feedback record tenant lookup failed" - ); + logger.warn({ requestId, hubStatus: tenantLookup.error.status }, "Feedback record tenant lookup failed"); return { errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"), }; @@ -247,10 +227,7 @@ const resolveTenantId = async ( const tenantId = parseTenantId(tenantLookup.data?.tenantId ?? null); if (!tenantId) { - logger.warn( - { requestId, recordId: route.recordId }, - "Feedback record tenant lookup returned invalid tenant" - ); + logger.warn({ requestId }, "Feedback record tenant lookup returned invalid tenant"); return { errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"), }; @@ -279,47 +256,35 @@ const authorizeFeedbackRecordsGatewayRequest = async ( } if (principal.type === "apiKey") { - return hasApiKeyImplicitFeedbackDirectoryAccess( + const legacySafeguardsAllow = hasApiKeyImplicitFeedbackDirectoryAccess( principal.authentication, feedbackDirectory.organizationId, feedbackDirectory.workspaceIds, requiredPermission, isRecordMutation - ) - ? { allowed: true } - : { allowed: false }; - } + ); + if (!legacySafeguardsAllow) return { allowed: false }; - try { - const minPermission = GATEWAY_PERMISSION_TO_TEAM_PERMISSION[requiredPermission]; + const allowed = await can( + { type: "apiKey", id: principal.authentication.apiKeyId }, + getFeedbackDirectoryAuthorizationAction(requiredPermission), + { type: "feedbackDirectory", id: feedbackDirectoryId } + ); + return { allowed }; + } - await checkAuthorizationUpdated({ - userId: principal.userId, - organizationId: feedbackDirectory.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - // Mutating an existing record is owners/managers only, so no workspace-team fallback. - ...(isRecordMutation - ? [] - : feedbackDirectory.workspaceIds.map((workspaceId) => ({ - type: "workspaceTeam" as const, - workspaceId, - minPermission, - }))), - ], - }); - - return { allowed: true }; - } catch (error) { - if (error instanceof AuthorizationError) { - return { allowed: false }; - } + const allowed = isRecordMutation + ? await can({ type: "user", id: principal.userId }, "organization.manage", { + type: "organization", + id: feedbackDirectory.organizationId, + }) + : await can( + { type: "user", id: principal.userId }, + getFeedbackDirectoryAuthorizationAction(requiredPermission), + { type: "feedbackDirectory", id: feedbackDirectoryId } + ); - throw error; - } + return { allowed }; }; export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = { @@ -328,57 +293,56 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = { getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders, verifyToken: verifyFeedbackRecordsGatewayToken, }, - authorize: async ({ request, originalRequest, principal, requestId }) => { - const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname); - if (!route) { - return { - status: "deny", - response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"), - }; - } + authorize: async ({ request, originalRequest, principal, requestId }) => + withAuthorizationSurface("feedback_gateway", async () => { + const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname); + if (!route) { + return { + status: "deny", + response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"), + }; + } - const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId); - if ("errorResponse" in tenantResolution) { - return { - status: "deny", - response: tenantResolution.errorResponse, - }; - } + const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId); + if ("errorResponse" in tenantResolution) { + return { + status: "deny", + response: tenantResolution.errorResponse, + }; + } + + const authorizationResult = await authorizeFeedbackRecordsGatewayRequest( + principal, + tenantResolution.tenantId, + route.requiredPermission, + route.operation + ); + if (!authorizationResult.allowed) { + logger.info( + { + requestId, + principalType: principal.type, + operation: route.operation, + verdict: "deny", + }, + "Feedback records gateway authorization denied" + ); + return { + status: "deny", + response: buildGatewayStatusResponse(403, "Forbidden"), + }; + } - const authorizationResult = await authorizeFeedbackRecordsGatewayRequest( - principal, - tenantResolution.tenantId, - route.requiredPermission, - route.operation - ); - if (!authorizationResult.allowed) { logger.info( { requestId, - principalType: principal.type, operation: route.operation, - feedbackDirectoryId: tenantResolution.tenantId, - verdict: "deny", + principalType: principal.type, + verdict: "allow", }, - "Feedback records gateway authorization denied" + "Feedback records gateway authorization allowed" ); - return { - status: "deny", - response: buildGatewayStatusResponse(403, "Forbidden"), - }; - } - logger.info( - { - requestId, - operation: route.operation, - principalType: principal.type, - feedbackDirectoryId: tenantResolution.tenantId, - verdict: "allow", - }, - "Feedback records gateway authorization allowed" - ); - - return allowGatewayRequest(); - }, + return allowGatewayRequest(); + }), }; diff --git a/apps/web/modules/integrations/webhooks/actions.ts b/apps/web/modules/integrations/webhooks/actions.ts index 7b88a89c5e6d..5d5a2cf77a2a 100644 --- a/apps/web/modules/integrations/webhooks/actions.ts +++ b/apps/web/modules/integrations/webhooks/actions.ts @@ -3,10 +3,10 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { generateWebhookSecret } from "@/lib/crypto"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromWebhookId, getOrganizationIdFromWorkspaceId, @@ -16,6 +16,8 @@ import { getWebhook, getWebhookWithSecret, } from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { createWebhook, @@ -34,21 +36,11 @@ const ZCreateWebhookAction = z.object({ export const createWebhookAction = authenticatedActionClient.inputSchema(ZCreateWebhookAction).action( withAuditLogging("created", "webhook", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); const webhook = await createWebhook( parsedInput.workspaceId, parsedInput.webhookInput, @@ -79,21 +71,12 @@ const ZDeleteWebhookAction = z.object({ export const deleteWebhookAction = authenticatedActionClient.inputSchema(ZDeleteWebhookAction).action( withAuditLogging("deleted", "webhook", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWebhookId(parsedInput.id); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromWebhookId(parsedInput.id), - }, - ], + const workspaceId = await getWorkspaceIdFromWebhookId(parsedInput.id); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.webhookId = parsedInput.id; @@ -112,21 +95,12 @@ const ZUpdateWebhookAction = z.object({ export const updateWebhookAction = authenticatedActionClient.inputSchema(ZUpdateWebhookAction).action( withAuditLogging("updated", "webhook", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWebhookId(parsedInput.webhookId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromWebhookId(parsedInput.webhookId), - }, - ], + const workspaceId = await getWorkspaceIdFromWebhookId(parsedInput.webhookId); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.webhookId = parsedInput.webhookId; @@ -154,20 +128,9 @@ export const testEndpointAction = authenticatedActionClient let secret: string | undefined; if (parsedInput.webhookId) { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWebhookId(parsedInput.webhookId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromWebhookId(parsedInput.webhookId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromWebhookId(parsedInput.webhookId), }); const webhookResult = await getWebhookWithSecret(parsedInput.webhookId); @@ -178,20 +141,9 @@ export const testEndpointAction = authenticatedActionClient secret = webhookResult.data.secret ?? undefined; } else { // No webhook yet: authorize against the workspace the webhook is being created in. - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); // New webhook, use the provided secret or generate a new one diff --git a/apps/web/modules/mcp/auth.test.ts b/apps/web/modules/mcp/auth.test.ts index 7c9010928669..e8311856a5a1 100644 --- a/apps/web/modules/mcp/auth.test.ts +++ b/apps/web/modules/mcp/auth.test.ts @@ -12,9 +12,10 @@ import { handleAuthenticatedMcpRequest, } from "./auth"; -const { verifyBearerTokenMock, userFindUniqueMock } = vi.hoisted(() => ({ +const { verifyBearerTokenMock, userFindUniqueMock, warnMock } = vi.hoisted(() => ({ verifyBearerTokenMock: vi.fn(), userFindUniqueMock: vi.fn(), + warnMock: vi.fn(), })); vi.mock("@better-auth/oauth-provider/resource-client", () => ({ @@ -66,6 +67,7 @@ vi.mock("@/modules/auth/lib/oauth-urls", async (importOriginal) => ({ ...(await importOriginal()), getAuthIssuerUrl: vi.fn(() => "https://app.example.com/api/auth"), getMcpOrigin: vi.fn(() => "https://app.example.com"), + getMcpOAuthJwksUrl: vi.fn(() => "http://formbricks:3000/api/auth/jwks"), getMcpProtectedResourceMetadataUrl: vi.fn( () => "https://app.example.com/.well-known/oauth-protected-resource/api/mcp" ), @@ -76,7 +78,7 @@ vi.mock("@/modules/auth/lib/oauth-urls", async (importOriginal) => ({ vi.mock("@formbricks/logger", () => ({ logger: { withContext: vi.fn(() => ({ - warn: vi.fn(), + warn: warnMock, error: vi.fn(), })), }, @@ -340,7 +342,7 @@ describe("authenticateMcpRequest", () => { issuer: "https://app.example.com/api/auth", typ: "at+jwt", }, - jwksUrl: "https://app.example.com/api/auth/jwks", + jwksUrl: "http://formbricks:3000/api/auth/jwks", }); expect(userFindUniqueMock).toHaveBeenCalledWith({ where: { id: "user_1" }, @@ -535,7 +537,10 @@ describe("authenticateMcpRequest", () => { }); test("rejects invalid OAuth bearer tokens with an OAuth challenge", async () => { - verifyBearerTokenMock.mockRejectedValue(new Error("Invalid token")); + const invalidTokenError = Object.assign(new Error("Invalid token"), { + code: "ERR_JWS_SIGNATURE_VERIFICATION_FAILED", + }); + verifyBearerTokenMock.mockRejectedValue(invalidTokenError); const result = await authenticateMcpRequest( createRequest("http://localhost/api/mcp", { @@ -560,6 +565,44 @@ describe("authenticateMcpRequest", () => { }); } expect(applyIPRateLimit).toHaveBeenCalledWith(expect.objectContaining({ namespace: "api:mcp:auth" })); + expect(warnMock).toHaveBeenCalledWith( + { + errorCode: "ERR_JWS_SIGNATURE_VERIFICATION_FAILED", + errorName: "Error", + failureSource: "token_verification", + statusCode: 401, + }, + "MCP OAuth authentication failed" + ); + }); + + test("distinguishes an unavailable JWKS endpoint without logging the raw error", async () => { + verifyBearerTokenMock.mockRejectedValue( + new TypeError("fetch failed", { + cause: Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" }), + }) + ); + + const result = await authenticateMcpRequest( + createRequest("http://localhost/api/mcp", { + authorization: "Bearer oauth_access_token", + }) + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(401); + } + expect(warnMock).toHaveBeenCalledWith( + { + errorCode: "ECONNREFUSED", + errorName: "TypeError", + failureSource: "jwks_fetch", + statusCode: 401, + }, + "MCP OAuth authentication failed" + ); + expect(JSON.stringify(warnMock.mock.calls)).not.toContain("connection refused"); }); test("returns 429 when OAuth requests are rate limited", async () => { @@ -620,7 +663,7 @@ describe("handleAuthenticatedMcpRequest", () => { }); }); -// GHSA-p2fr-6hmx-4528. Everywhere else in this file `verifyAccessToken` is stubbed with a payload, +// GHSA-p2fr-6hmx-4528. Everywhere else in this file `verifyBearerToken` is stubbed with a payload, // which cannot show whether a token is really accepted — the audience rule lives in jose's semantics, // not in a fixture. Here the stub does what the real resource client does (hand the token to jose with // the production `verifyOptions`), and the tokens are genuinely signed, so these cases exercise the diff --git a/apps/web/modules/mcp/auth.ts b/apps/web/modules/mcp/auth.ts index 37094a42dc93..438c9a9118a9 100644 --- a/apps/web/modules/mcp/auth.ts +++ b/apps/web/modules/mcp/auth.ts @@ -15,6 +15,7 @@ import { problemUnauthorized, } from "@/app/api/v3/lib/response"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { parseApiKeyV2 } from "@/lib/crypto"; import { authenticateApiKeyFromHeaders, getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth"; import { auth } from "@/modules/auth/lib/auth"; @@ -22,6 +23,7 @@ import { MCP_CHALLENGE_SCOPE, MCP_RESOURCE_SCOPES, getAuthIssuerUrl, + getMcpOAuthJwksUrl, getMcpOrigin, getMcpProtectedResourceMetadataUrl, getMcpResourceUrl, @@ -47,6 +49,37 @@ const JWT_ACCESS_TOKEN_TYPE = "at+jwt"; const oauthResourceClient = oauthProviderResourceClient(auth); +const JWKS_FAILURE_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "ENETUNREACH", + "ENOTFOUND", + "ETIMEDOUT", + "ERR_JWKS_MULTIPLE_MATCHING_KEYS", + "ERR_JWKS_NO_MATCHING_KEY", + "ERR_JWKS_TIMEOUT", +]); + +const getErrorCode = (error: unknown): string | undefined => { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + + return typeof error.code === "string" ? error.code : undefined; +}; + +const getMcpOAuthFailureDetails = (error: unknown) => { + const errorName = error instanceof Error ? error.name : "UnknownError"; + const cause = error instanceof Error ? error.cause : undefined; + const errorCode = getErrorCode(error) ?? getErrorCode(cause); + const failureSource = + errorName === "TypeError" || (errorCode !== undefined && JWKS_FAILURE_CODES.has(errorCode)) + ? "jwks_fetch" + : "token_verification"; + + return { errorCode, errorName, failureSource }; +}; + export type TMcpAuthInfo = AuthInfo & { extra: { formbricksAuthentication: TV3Authentication; @@ -427,14 +460,15 @@ async function authenticateMcpOAuthBearer( // purpose is binding token audiences. typ: JWT_ACCESS_TOKEN_TYPE, }, - jwksUrl: `${getAuthIssuerUrl()}/jwks`, + jwksUrl: getMcpOAuthJwksUrl(), }); - } catch { + } catch (error) { return await rejectUnauthenticatedMcpRequest({ requestId, instance, log, logMessage: "MCP OAuth authentication failed", + logContext: getMcpOAuthFailureDetails(error), }); } @@ -623,6 +657,6 @@ export async function handleAuthenticatedMcpRequest( } (request as Request & { auth?: AuthInfo }).auth = authResult.authInfo; - const response = await handler(request); + const response = await withAuthorizationSurface("mcp", () => handler(request)); return withMcpResponseHeaders(response, authResult.requestId); } diff --git a/apps/web/modules/organization/lib/utils.test.ts b/apps/web/modules/organization/lib/utils.test.ts index 6704a1503ab2..5b54d1506519 100644 --- a/apps/web/modules/organization/lib/utils.test.ts +++ b/apps/web/modules/organization/lib/utils.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TMembership } from "@formbricks/types/memberships"; import { TOrganization } from "@formbricks/types/organizations"; +import { can } from "@/lib/authorization"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getOrganization } from "@/lib/organization/service"; import { getSession } from "@/modules/auth/lib/session"; @@ -28,8 +29,20 @@ vi.mock("@/modules/auth/lib/session", () => ({ getSession: vi.fn(), })); vi.mock("react", () => ({ cache: (fn: Function) => fn })); +// ENG-2409: the tenancy gate now asks `can(organization.read)`. Mocked rather than left real so the +// two arms of the throw can be driven independently — under the legacy evaluator they are the same +// condition (`read` is granted to exactly the roles that have a membership row), so with the real +// evaluator a deleted `can()` call would still throw via the membership arm and no test would fail. +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); +vi.mock("@/lib/authorization/context", () => ({ + withAuthorizationSurface: (_surface: string, callback: () => unknown) => callback(), +})); describe("getOrganizationAuth", () => { + beforeEach(() => { + vi.mocked(can).mockResolvedValue(true); + }); + const mockSession = { user: { id: "user-1" }, expires: new Date().toISOString() }; const mockOrg = { id: "org-1" } as TOrganization; const mockMembership: TMembership = { @@ -72,4 +85,30 @@ describe("getOrganizationAuth", () => { vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); await expect(getOrganizationAuth("org-1")).rejects.toThrow(ResourceNotFoundError); }); + + // ENG-2409: the gate half of that throw, isolated. Unreachable under the legacy evaluator, where + // `organization.read` and "has a membership row" are the same condition — but reachable under + // SpiceDB enforcement if projection drifts, and this is what proves the decision now actually + // depends on `can()` rather than on the row. + test("throws when authorization denies even though a membership row exists", async () => { + vi.mocked(getSession).mockResolvedValue(mockSession); + vi.mocked(getOrganization).mockResolvedValue(mockOrg); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + vi.mocked(can).mockResolvedValue(false); + + await expect(getOrganizationAuth("org-1")).rejects.toThrow(ResourceNotFoundError); + }); + + test("asks the central interface for organization.read on the acting user", async () => { + vi.mocked(getSession).mockResolvedValue(mockSession); + vi.mocked(getOrganization).mockResolvedValue(mockOrg); + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); + + await getOrganizationAuth("org-1"); + + expect(can).toHaveBeenCalledExactlyOnceWith({ type: "user", id: "user-1" }, "organization.read", { + type: "organization", + id: "org-1", + }); + }); }); diff --git a/apps/web/modules/organization/lib/utils.ts b/apps/web/modules/organization/lib/utils.ts index 5c733ccbb79c..f039e08d5eea 100644 --- a/apps/web/modules/organization/lib/utils.ts +++ b/apps/web/modules/organization/lib/utils.ts @@ -1,5 +1,7 @@ import { cache as reactCache } from "react"; import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getAccessFlags } from "@/lib/membership/utils"; import { getOrganization } from "@/lib/organization/service"; @@ -12,6 +14,16 @@ import { TOrganizationAuth } from "../types/organization-auth"; * * Usage: * const { session, organization, ... } = await getOrganizationAuth(params.organizationId); + * + * Deliberately gates on membership only (`organization.read`), never on a product permission — + * unlike `getWorkspaceAuth`, which redirects the billing role away from product data. The + * asymmetry is required, not an oversight: `modules/ee/billing/page.tsx` is the billing role's + * own page, so a billing exclusion here would lock that role out of the one surface it exists to + * reach. Callers that need to exclude billing do so themselves, via + * `redirectBillingRoleFromRestrictedOrgSettings`. + * + * The role flags below stay for rendering (isReadOnly, isDeleteDisabled, membershipRole) — retained + * by design, see lib/authorization/README.md. What ENG-2409 moved is the *gate*, not the flags. */ export const getOrganizationAuth = reactCache(async (organizationId: string): Promise => { const t = await getTranslate(); @@ -27,12 +39,35 @@ export const getOrganizationAuth = reactCache(async (organizationId: string): Pr throw new ResourceNotFoundError(t("common.organization"), organizationId); } - const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); - if (!currentUserMembership) { + // ENG-2409: the tenancy gate. This was `if (!currentUserMembership) throw`, a decision made by + // reading a row rather than by asking the central interface. Keeping it as an explicit + // `organization.read` decision makes SpiceDB authoritative for this gate. + // + // `organization.read` is the same set. The schema grants it to owner + manager + member + billing + // (schema.zed:69) — every membership role and nobody else — so "holds this permission" and "has a + // membership row" describe the same principals. + // + // Run alongside the membership read rather than after it. SpiceDB decides the capability while + // PostgreSQL still supplies the role flags rendered by the page. + const [hasOrganizationRead, currentUserMembership] = await Promise.all([ + withAuthorizationSurface("page", () => + can({ type: "user", id: session.user.id }, "organization.read", { + type: "organization", + id: organization.id, + }) + ), + getMembershipByUserIdOrganizationId(session.user.id, organization.id), + ]); + + // The membership is still required, and not only for the flags below: SpiceDB + // could allow while the row is absent (projection drift), and `TOrganizationAuth` promises a + // non-null membership to every caller. Keeping both conditions on one throw preserves the exact + // error this has always raised while making the authorization half of it comparable. + if (!hasOrganizationRead || !currentUserMembership) { throw new ResourceNotFoundError(t("common.membership"), null); } - const { isMember, isOwner, isManager, isBilling } = getAccessFlags(currentUserMembership?.role); + const { isMember, isOwner, isManager, isBilling } = getAccessFlags(currentUserMembership.role); return { organization, diff --git a/apps/web/modules/organization/settings/api-keys/actions.test.ts b/apps/web/modules/organization/settings/api-keys/actions.test.ts new file mode 100644 index 000000000000..e4599714b0ac --- /dev/null +++ b/apps/web/modules/organization/settings/api-keys/actions.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { assertCan } from "@/lib/authorization"; +import { createApiKeyAction, deleteApiKeyAction, updateApiKeyAction } from "./actions"; + +const mocks = vi.hoisted(() => ({ + capturePostHogEvent: vi.fn(), + createApiKey: vi.fn(), + deleteApiKey: vi.fn(), + getOrganizationIdFromApiKeyId: vi.fn(), + updateApiKey: vi.fn(), +})); + +vi.mock("@/lib/authorization", () => ({ + assertCan: vi.fn(), +})); + +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { + inputSchema: vi.fn(() => ({ + action: vi.fn((fn) => fn), + })), + }, +})); + +vi.mock("@/lib/posthog", () => ({ + capturePostHogEvent: mocks.capturePostHogEvent, +})); + +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromApiKeyId: mocks.getOrganizationIdFromApiKeyId, +})); + +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn), +})); + +vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({ + createApiKey: mocks.createApiKey, + deleteApiKey: mocks.deleteApiKey, + updateApiKey: mocks.updateApiKey, +})); + +describe("API-key settings authorization", () => { + const organizationId = "org-1"; + const apiKeyId = "key-1"; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getOrganizationIdFromApiKeyId.mockResolvedValue(organizationId); + mocks.createApiKey.mockResolvedValue({ id: apiKeyId }); + mocks.deleteApiKey.mockResolvedValue({ id: apiKeyId }); + mocks.updateApiKey.mockResolvedValue({ id: apiKeyId }); + }); + + test("requires organization.manage_api_keys to create a key", async () => { + await createApiKeyAction({ + ctx: { user: { id: "user-1" }, auditLoggingCtx: {} }, + parsedInput: { + organizationId, + apiKeyData: { label: "Automation" }, + }, + } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "organization.manage_api_keys", { + type: "organization", + id: organizationId, + }); + expect(mocks.createApiKey).toHaveBeenCalled(); + }); + + test.each([ + ["delete", deleteApiKeyAction, { id: apiKeyId }], + ["update", updateApiKeyAction, { apiKeyId, apiKeyData: { label: "Updated" } }], + ] as const)("requires apiKey.manage to %s a key", async (_name, action, parsedInput) => { + await action({ + ctx: { user: { id: "user-1" }, auditLoggingCtx: {} }, + parsedInput, + } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "apiKey.manage", { + type: "apiKey", + id: apiKeyId, + }); + }); + + test("does not mutate a key when authorization fails", async () => { + vi.mocked(assertCan).mockRejectedValue(new Error("not authorized")); + + await expect( + deleteApiKeyAction({ + ctx: { user: { id: "user-1" }, auditLoggingCtx: {} }, + parsedInput: { id: apiKeyId }, + } as never) + ).rejects.toThrow("not authorized"); + + expect(mocks.deleteApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/organization/settings/api-keys/actions.ts b/apps/web/modules/organization/settings/api-keys/actions.ts index 3f2a98781db6..933da00098f1 100644 --- a/apps/web/modules/organization/settings/api-keys/actions.ts +++ b/apps/web/modules/organization/settings/api-keys/actions.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; +import { assertCan } from "@/lib/authorization"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromApiKeyId } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { @@ -21,15 +21,9 @@ const ZDeleteApiKeyAction = z.object({ export const deleteApiKeyAction = authenticatedActionClient.inputSchema(ZDeleteApiKeyAction).action( withAuditLogging("deleted", "apiKey", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromApiKeyId(parsedInput.id); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "apiKey.manage", { + type: "apiKey", + id: parsedInput.id, }); ctx.auditLoggingCtx.organizationId = organizationId; @@ -48,15 +42,9 @@ const ZCreateApiKeyAction = z.object({ export const createApiKeyAction = authenticatedActionClient.inputSchema(ZCreateApiKeyAction).action( withAuditLogging("created", "apiKey", async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_api_keys", { + type: "organization", + id: parsedInput.organizationId, }); ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; @@ -82,15 +70,9 @@ const ZUpdateApiKeyAction = z.object({ export const updateApiKeyAction = authenticatedActionClient.inputSchema(ZUpdateApiKeyAction).action( withAuditLogging("updated", "apiKey", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromApiKeyId(parsedInput.apiKeyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "apiKey.manage", { + type: "apiKey", + id: parsedInput.apiKeyId, }); ctx.auditLoggingCtx.organizationId = organizationId; diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts index d6e49e0c1fdd..09f791ca2744 100644 --- a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts +++ b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts @@ -7,6 +7,8 @@ import { logger } from "@formbricks/logger"; import { TOrganizationAccess } from "@formbricks/types/api-key"; import { ZId } from "@formbricks/types/common"; import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { reconcileApiKeyRelationships } from "@/lib/authzed/api-key"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; import { CONTROL_HASH } from "@/lib/constants"; import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto"; import { validateInputs } from "@/lib/utils/validate"; @@ -143,6 +145,10 @@ export const deleteApiKey = async (id: string): Promise => { }, }); + await runPostCommitProjection("api_key_delete_relationship_reconciliation", () => + reconcileApiKeyRelationships({ apiKeyIds: [id] }) + ); + return deletedApiKeyData; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -219,6 +225,10 @@ export const createApiKey = async ( }, }); + await runPostCommitProjection("api_key_create_relationship_reconciliation", () => + reconcileApiKeyRelationships({ apiKeyIds: [result.id] }) + ); + // Return the new v2 format: fbk_{secret} return { ...result, actualKey: `fbk_${secret}` }; } catch (error) { diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts b/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts index fed8a0892c92..32b7330a3a5f 100644 --- a/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts +++ b/apps/web/modules/organization/settings/api-keys/lib/api-keys.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { ApiKey, ApiKeyPermission, Prisma } from "@formbricks/database/prisma"; import { DatabaseError, OperationNotAllowedError } from "@formbricks/types/errors"; +import { reconcileApiKeyRelationships } from "@/lib/authzed/api-key"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; import { TApiKeyWithEnvironmentPermission } from "../types/api-keys"; import { createApiKey, @@ -57,6 +59,20 @@ vi.mock("./workspaces", () => ({ getWorkspacesByOrganizationId: vi.fn(), })); +vi.mock("@/lib/authzed/api-key", () => ({ + reconcileApiKeyRelationships: vi.fn(), +})); + +vi.mock("@/lib/authzed/projection-boundary", () => ({ + runPostCommitProjection: vi.fn(async (_operation: string, projection: () => Promise) => { + try { + await projection(); + } catch { + // Post-commit projection failures must never replace a successful source mutation. + } + }), +})); + vi.mock("crypto", async () => { const actual = await vi.importActual("crypto"); return { @@ -392,6 +408,20 @@ describe("API Key Management", () => { id: mockApiKey.id, }, }); + expect(runPostCommitProjection).toHaveBeenCalledWith( + "api_key_delete_relationship_reconciliation", + expect.any(Function) + ); + expect(reconcileApiKeyRelationships).toHaveBeenCalledWith({ + apiKeyIds: [mockApiKey.id], + }); + }); + + test("preserves a successful deletion when projection fails", async () => { + vi.mocked(prisma.apiKey.delete).mockResolvedValueOnce(mockApiKey); + vi.mocked(reconcileApiKeyRelationships).mockRejectedValueOnce(new Error("projection failed")); + + await expect(deleteApiKey(mockApiKey.id)).resolves.toEqual(mockApiKey); }); test("throws DatabaseError on prisma error", async () => { @@ -402,6 +432,7 @@ describe("API Key Management", () => { vi.mocked(prisma.apiKey.delete).mockRejectedValueOnce(errToThrow); await expect(deleteApiKey(mockApiKey.id)).rejects.toThrow(DatabaseError); + expect(reconcileApiKeyRelationships).not.toHaveBeenCalled(); }); test("throws error if prisma throws an error", async () => { @@ -409,6 +440,7 @@ describe("API Key Management", () => { vi.mocked(prisma.apiKey.delete).mockRejectedValueOnce(errToThrow); await expect(deleteApiKey(mockApiKey.id)).rejects.toThrow(errToThrow); + expect(reconcileApiKeyRelationships).not.toHaveBeenCalled(); }); }); @@ -454,6 +486,13 @@ describe("API Key Management", () => { apiKeyWorkspaces: true, }, }); + expect(runPostCommitProjection).toHaveBeenCalledWith( + "api_key_create_relationship_reconciliation", + expect.any(Function) + ); + expect(reconcileApiKeyRelationships).toHaveBeenCalledWith({ + apiKeyIds: [mockApiKey.id], + }); }); test("creates an API key with environment permissions successfully", async () => { @@ -488,6 +527,16 @@ describe("API Key Management", () => { expect(prisma.apiKey.create).not.toHaveBeenCalled(); }); + test("preserves the one-time API key when projection fails", async () => { + vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey); + vi.mocked(reconcileApiKeyRelationships).mockRejectedValueOnce(new Error("projection failed")); + + await expect(createApiKey("org123", "user123", mockApiKeyData)).resolves.toEqual({ + ...mockApiKey, + actualKey: "fbk_testSecret123", + }); + }); + test("rejects create input with duplicate workspaceId", async () => { await expect( createApiKey("org123", "user123", { @@ -510,6 +559,7 @@ describe("API Key Management", () => { vi.mocked(prisma.apiKey.create).mockRejectedValueOnce(errToThrow); await expect(createApiKey("org123", "user123", mockApiKeyData)).rejects.toThrow(DatabaseError); + expect(reconcileApiKeyRelationships).not.toHaveBeenCalled(); }); test("throws error if prisma throws an error", async () => { @@ -518,6 +568,7 @@ describe("API Key Management", () => { vi.mocked(prisma.apiKey.create).mockRejectedValueOnce(errToThrow); await expect(createApiKey("org123", "user123", mockApiKeyData)).rejects.toThrow(errToThrow); + expect(reconcileApiKeyRelationships).not.toHaveBeenCalled(); }); }); @@ -530,6 +581,7 @@ describe("API Key Management", () => { expect(result).toEqual(updatedApiKey); expect(prisma.apiKey.update).toHaveBeenCalled(); + expect(reconcileApiKeyRelationships).not.toHaveBeenCalled(); }); test("throws DatabaseError on prisma error", async () => { diff --git a/apps/web/modules/organization/settings/api-keys/lib/utils.test.ts b/apps/web/modules/organization/settings/api-keys/lib/utils.test.ts deleted file mode 100644 index 4be306e6949b..000000000000 --- a/apps/web/modules/organization/settings/api-keys/lib/utils.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { TAPIKeyWorkspacePermission } from "@formbricks/types/auth"; -import { hasPermission, hasWorkspacePermission } from "./utils"; - -describe("hasPermission", () => { - const wsId = "workspace1"; - test("returns true for manage permission (all methods)", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "manage", - }, - ]; - expect(hasPermission(permissions, wsId, "GET")).toBe(true); - expect(hasPermission(permissions, wsId, "POST")).toBe(true); - expect(hasPermission(permissions, wsId, "PUT")).toBe(true); - expect(hasPermission(permissions, wsId, "PATCH")).toBe(true); - expect(hasPermission(permissions, wsId, "DELETE")).toBe(true); - }); - - test("returns true for write permission (read/write), false for delete", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "write", - }, - ]; - expect(hasPermission(permissions, wsId, "GET")).toBe(true); - expect(hasPermission(permissions, wsId, "POST")).toBe(true); - expect(hasPermission(permissions, wsId, "PUT")).toBe(true); - expect(hasPermission(permissions, wsId, "PATCH")).toBe(true); - expect(hasPermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("returns true for read permission (GET), false for others", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "read", - }, - ]; - expect(hasPermission(permissions, wsId, "GET")).toBe(true); - expect(hasPermission(permissions, wsId, "POST")).toBe(false); - expect(hasPermission(permissions, wsId, "PUT")).toBe(false); - expect(hasPermission(permissions, wsId, "PATCH")).toBe(false); - expect(hasPermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("returns false if no permissions or workspace entry", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: "other-workspace", - workspaceName: "Other", - permission: "manage", - }, - ]; - expect(hasPermission(undefined as any, wsId, "GET")).toBe(false); - expect(hasPermission([], wsId, "GET")).toBe(false); - expect(hasPermission(permissions, wsId, "GET")).toBe(false); - }); - - test("returns false for unknown permission", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "unknown" as any, - }, - ]; - expect(hasPermission(permissions, wsId, "GET")).toBe(false); - }); -}); - -describe("hasWorkspacePermission", () => { - const wsId = "workspace1"; - - test("returns true for manage permission (all methods)", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "manage", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PUT")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PATCH")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(true); - }); - - test("returns true for write permission (read/write), false for delete", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "write", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PUT")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PATCH")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("returns true for read permission (GET only)", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "read", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(false); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("uses workspace permission", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "read", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(false); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("returns false if no permissions for the workspace", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: "other-workspace", - workspaceName: "Other", - permission: "manage", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(false); - }); - - test("returns false for undefined/empty permissions", () => { - expect(hasWorkspacePermission(undefined as any, wsId, "GET")).toBe(false); - expect(hasWorkspacePermission([], wsId, "GET")).toBe(false); - }); - - test("returns false for unknown permission value", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "unknown" as any, - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(false); - }); -}); - -describe("hasWorkspacePermission", () => { - const wsId = "workspace1"; - - test("returns true for manage permission (all methods)", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "manage", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PUT")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PATCH")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(true); - }); - - test("returns true for write permission (read/write), false for delete", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "write", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PUT")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "PATCH")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("returns true for read permission (GET only)", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "read", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(false); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("uses workspace permission", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "read", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(true); - expect(hasWorkspacePermission(permissions, wsId, "POST")).toBe(false); - expect(hasWorkspacePermission(permissions, wsId, "DELETE")).toBe(false); - }); - - test("returns false if no permissions for the workspace", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: "other-workspace", - workspaceName: "Other", - permission: "manage", - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(false); - }); - - test("returns false for undefined/empty permissions", () => { - expect(hasWorkspacePermission(undefined as any, wsId, "GET")).toBe(false); - expect(hasWorkspacePermission([], wsId, "GET")).toBe(false); - }); - - test("returns false for unknown permission value", () => { - const permissions: TAPIKeyWorkspacePermission[] = [ - { - workspaceId: wsId, - workspaceName: "Workspace One", - permission: "unknown" as any, - }, - ]; - expect(hasWorkspacePermission(permissions, wsId, "GET")).toBe(false); - }); -}); diff --git a/apps/web/modules/organization/settings/api-keys/lib/utils.ts b/apps/web/modules/organization/settings/api-keys/lib/utils.ts deleted file mode 100644 index 82cffcbe26c7..000000000000 --- a/apps/web/modules/organization/settings/api-keys/lib/utils.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { OrganizationAccessType } from "@formbricks/types/api-key"; -import { TAPIKeyWorkspacePermission, TAuthenticationApiKey } from "@formbricks/types/auth"; - -// Permission level required for different HTTP methods -const methodPermissionMap = { - GET: "read", // Read operations need at least read permission - POST: "write", // Create operations need at least write permission - PUT: "write", // Update operations need at least write permission - PATCH: "write", // Partial update operations need at least write permission - DELETE: "manage", // Delete operations need manage permission -}; - -// Check if API key has sufficient permission for the requested workspace and method -export const hasPermission = ( - permissions: TAPIKeyWorkspacePermission[], - workspaceId: string, - method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" -): boolean => { - if (!permissions) return false; - - // Find the workspace permission entry for this workspace - const workspacePermission = permissions.find((permission) => permission.workspaceId === workspaceId); - - if (!workspacePermission) return false; - - // Get required permission level for this method - const requiredPermission = methodPermissionMap[method]; - - // Check if the API key has sufficient permission - switch (workspacePermission.permission) { - case "manage": - // Manage permission can do everything - return true; - case "write": - // Write permission can do write and read operations - return requiredPermission === "write" || requiredPermission === "read"; - case "read": - // Read permission can only do read operations - return requiredPermission === "read"; - default: - return false; - } -}; - -// Check if API key has sufficient permission for the requested workspace and method. -export const hasWorkspacePermission = ( - permissions: TAPIKeyWorkspacePermission[], - workspaceId: string, - method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" -): boolean => { - if (!permissions) return false; - - const workspacePermission = permissions.find((p) => p.workspaceId === workspaceId); - if (!workspacePermission) return false; - - const requiredPermission = methodPermissionMap[method]; - - switch (workspacePermission.permission) { - case "manage": - return true; - case "write": - return requiredPermission === "write" || requiredPermission === "read"; - case "read": - return requiredPermission === "read"; - default: - return false; - } -}; - -export const hasOrganizationAccess = ( - authentication: TAuthenticationApiKey, - accessType: OrganizationAccessType -): boolean => { - const organizationAccess = authentication.organizationAccess?.accessControl; - - switch (accessType) { - case OrganizationAccessType.Read: - return organizationAccess?.read === true || organizationAccess?.write === true; - case OrganizationAccessType.Write: - return organizationAccess?.write === true; - default: - return false; - } -}; diff --git a/apps/web/modules/organization/settings/api-keys/page.tsx b/apps/web/modules/organization/settings/api-keys/page.tsx index 196ed1e8ada1..1064b5f6c6ee 100644 --- a/apps/web/modules/organization/settings/api-keys/page.tsx +++ b/apps/web/modules/organization/settings/api-keys/page.tsx @@ -1,4 +1,6 @@ import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard"; +import { assertCan } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; @@ -15,17 +17,32 @@ export const APIKeysPage = async (props: Readonly<{ params: Promise<{ organizati await redirectBillingRoleFromRestrictedOrgSettings(params.organizationId); - const { currentUserMembership, organization, session } = await getOrganizationAuth(params.organizationId); + const { organization, session } = await getOrganizationAuth(params.organizationId); + + // ENG-2409: was `currentUserMembership.role === "owner" || "manager"` followed by a bare + // `throw new Error(...)`. `organization.manage_api_keys` is the same set, and it is the exact + // question `createApiKeyAction` already asks — so the page and the mutation behind it cannot drift + // if that permission is ever split from `organization.manage`. + // + // Two deliberate changes beyond the routing: + // - `assertCan` throws `AuthorizationError`, which is in EXPECTED_ERROR_NAMES. The bare `Error` + // was not, so every unauthorized load of this page was being reported to Sentry as an + // unexpected exception. `app/error.tsx` renders both identically, so nothing user-visible moves. + // - The gate now runs BEFORE the workspace/locale fetch below rather than after it. Authorizing + // after reading the data it protects was harmless here (nothing was rendered) but is the wrong + // order to leave in place. + await withAuthorizationSurface("page", () => + assertCan({ type: "user", id: session.user.id }, "organization.manage_api_keys", { + type: "organization", + id: organization.id, + }) + ); const [workspaces, locale] = await Promise.all([ getWorkspacesByOrganizationId(organization.id), getUserLocale(session.user.id), ]); - const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager"; - - if (!canAccessApiKeys) throw new Error(t("common.not_authorized")); - return ( diff --git a/apps/web/modules/organization/settings/teams/actions.ts b/apps/web/modules/organization/settings/teams/actions.ts index ffd592f2894e..33a300147640 100644 --- a/apps/web/modules/organization/settings/teams/actions.ts +++ b/apps/web/modules/organization/settings/teams/actions.ts @@ -7,15 +7,19 @@ import { logger } from "@formbricks/logger"; import { ZId, ZUuid } from "@formbricks/types/common"; import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors"; import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships"; +import { assertCan, can } from "@/lib/authorization"; import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { createInviteToken } from "@/lib/jwt"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getAccessFlags } from "@/lib/membership/utils"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromInviteId } from "@/lib/utils/helper"; -import { assertRateLimitAvailable, recordRateLimitUsage } from "@/modules/core/rate-limit/helpers"; +import { + applyRateLimit, + assertRateLimitAvailable, + recordRateLimitUsage, +} from "@/modules/core/rate-limit/helpers"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getBulkInvitePermission, getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -34,6 +38,9 @@ import { type TBulkInviteResult, getInviteFailureReason } from "./lib/invite-fai // Hard cap on a single bulk import to bound payload size and email fan-out. const BULK_INVITE_MAX_INVITEES = 500; +// Cap on the teams one invite may name. See ZInviteUserAction. +const INVITE_MAX_TEAMS = 100; + const ZDeleteInviteAction = z.object({ inviteId: ZUuid, }); @@ -42,16 +49,11 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, organizationId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.inviteId = parsedInput.inviteId; ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) }; @@ -67,16 +69,11 @@ export const createInviteTokenAction = authenticatedActionClient.inputSchema(ZCr withAuditLogging("updated", "invite", async ({ parsedInput, ctx }) => { const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, organizationId); // Get old expiresAt for audit logging before update const oldInvite = await prisma.invite.findUnique({ @@ -112,16 +109,11 @@ const ZDeleteMembershipAction = z.object({ export const deleteMembershipAction = authenticatedActionClient.inputSchema(ZDeleteMembershipAction).action( withAuditLogging("deleted", "membership", async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.organizationId); if (parsedInput.userId === ctx.user.id) { throw new OperationNotAllowedError("You cannot delete yourself from the organization"); @@ -183,16 +175,11 @@ export const resendInviteAction = authenticatedActionClient.inputSchema(ZResendI throw new ValidationError("Invite does not belong to the organization"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.organizationId); const invite = await getInvite(parsedInput.inviteId); @@ -242,12 +229,61 @@ const validateTeamAdminInvitePermissions = ( } }; +/** + * The capability half of "may this person send this invite", routed through the central interface. + * + * Organization owners and managers are decided once at the organization. Team admins reach this + * action too, and before ENG-1737 their capability was established only by `getTeamsWhereUserIsAdmin`. + * `team.manage` is that capability, asked once per requested team so the answer + * covers exactly the teams the invite would write to. + * + * Distinct ids, checked one at a time, stopping at the first refusal: asking per team makes the + * number of authorization decisions follow the request body, so repeats are collapsed (naming a team + * twice asks the same question twice) and the array is capped in the schema. Sequential rather than + * `Promise.all` for the same reason — a request naming many teams should not fan out that many + * concurrent checks, and the answer is available as soon as one team is refused. + * + * The narrower rules stay with the caller, in `validateTeamAdminInvitePermissions`: which invitation + * role a team admin may grant, and that they must name at least one team, are policy about the + * request's content rather than capabilities the vocabulary expresses. + */ +const assertInviterMayInvite = async ({ + isOrgOwnerOrManager, + organizationId, + teamIds, + userId, +}: Readonly<{ + isOrgOwnerOrManager: boolean; + organizationId: string; + teamIds: ReadonlyArray; + userId: string; +}>): Promise => { + if (isOrgOwnerOrManager) { + await assertCan({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, + }); + return; + } + + const actor = { type: "user", id: userId } as const; + + for (const teamId of new Set(teamIds)) { + if (!(await can(actor, "team.manage", { type: "team", id: teamId }))) { + throw new OperationNotAllowedError("Team admins can only add users to teams where they are admin"); + } + } +}; + const ZInviteUserAction = z.object({ organizationId: ZId, email: z.string(), name: z.string().trim().min(1, "Name is required"), role: ZOrganizationRole, - teamIds: z.array(ZId), + // Bounded because a team admin's authorization is now decided per named team: without a cap the + // number of authorization decisions would follow the request body. The UI offers the teams the + // inviter can see, so this is far above any real selection. + teamIds: z.array(ZId).max(INVITE_MAX_TEAMS, `An invite is limited to ${INVITE_MAX_TEAMS} teams`), }); export const inviteUserAction = authenticatedActionClient.inputSchema(ZInviteUserAction).action( @@ -282,19 +318,12 @@ export const inviteUserAction = authenticatedActionClient.inputSchema(ZInviteUse throw new AuthenticationError("Not authorized to invite members"); } - if (isOrgOwnerOrManager) { - // Standard org-level auth check - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], - }); - } + await assertInviterMayInvite({ + isOrgOwnerOrManager, + organizationId: parsedInput.organizationId, + teamIds: parsedInput.teamIds, + userId: ctx.user.id, + }); // Validate team admin restrictions validateTeamAdminInvitePermissions( @@ -387,10 +416,9 @@ export const bulkInviteUsersAction = authenticatedActionClient.inputSchema(ZBulk throw new AuthenticationError("User not a member of this organization"); } - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [{ type: "organization", roles: ["owner", "manager"] }], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: organizationId, }); // Entitlement gate: bulk invite is a paid feature. Mitigates the invite-spam abuse vector by @@ -478,16 +506,11 @@ const ZLeaveOrganizationAction = z.object({ export const leaveOrganizationAction = authenticatedActionClient.inputSchema(ZLeaveOrganizationAction).action( withAuditLogging("deleted", "membership", async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing", "member"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.read", { + type: "organization", + id: parsedInput.organizationId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.organizationId); const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); diff --git a/apps/web/modules/organization/settings/teams/lib/membership.test.ts b/apps/web/modules/organization/settings/teams/lib/membership.test.ts index e67e74a1d4f3..05aeea143d7a 100644 --- a/apps/web/modules/organization/settings/teams/lib/membership.test.ts +++ b/apps/web/modules/organization/settings/teams/lib/membership.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; import { DatabaseError, UnknownError } from "@formbricks/types/errors"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { deleteMembership, getMembersByOrganizationId, @@ -28,6 +31,12 @@ vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 })); vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() })); vi.mock("react", () => ({ cache: (fn: Function) => fn })); vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } })); +vi.mock("@/lib/authzed/organization-membership", () => ({ + reconcileOrganizationMembership: vi.fn(), +})); +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); const organizationId = "org-1"; const userId = "user-1"; @@ -108,12 +117,68 @@ describe("getOrganizationOwnerCount", () => { describe("deleteMembership", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(prisma.$transaction).mockImplementation(async (transaction) => { + if (typeof transaction !== "function") { + throw new Error("Expected an interactive transaction"); + } + + return transaction(prisma as never); + }); }); test("deletes membership and returns deleted team memberships", async () => { vi.mocked(prisma.teamUser.findMany).mockResolvedValue([mockTeamMembership]); - vi.mocked(prisma.$transaction).mockResolvedValue([{}, {}]); const result = await deleteMembership(userId, organizationId); expect(result[0].teamId).toBe(teamId); + expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + expect(prisma.teamUser.deleteMany).toHaveBeenCalledWith({ + where: { + userId, + team: { + organizationId, + }, + }, + }); + expect(prisma.membership.delete).toHaveBeenCalledWith({ + where: { + userId_organizationId: { + organizationId, + userId, + }, + }, + }); + expect(reconcileOrganizationMembership).toHaveBeenCalledWith(organizationId, userId); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + teamMemberships: [{ teamId, userId }], + }); + }); + test("retries a serializable transaction conflict up to a successful attempt", async () => { + const transactionConflict = new Prisma.PrismaClientKnownRequestError("transaction conflict", { + code: PrismaErrorType.TransactionConflict, + clientVersion: "1.0.0", + }); + vi.mocked(prisma.$transaction).mockRejectedValueOnce(transactionConflict); + vi.mocked(prisma.teamUser.findMany).mockResolvedValue([mockTeamMembership]); + + await expect(deleteMembership(userId, organizationId)).resolves.toEqual([mockTeamMembership]); + + expect(prisma.$transaction).toHaveBeenCalledTimes(2); + expect(reconcileOrganizationMembership).toHaveBeenCalledTimes(1); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledTimes(1); + }); + test("stops retrying a serializable transaction conflict after three attempts", async () => { + const transactionConflict = new Prisma.PrismaClientKnownRequestError("transaction conflict", { + code: PrismaErrorType.TransactionConflict, + clientVersion: "1.0.0", + }); + vi.mocked(prisma.$transaction).mockRejectedValue(transactionConflict); + + await expect(deleteMembership(userId, organizationId)).rejects.toThrow(DatabaseError); + + expect(prisma.$transaction).toHaveBeenCalledTimes(3); + expect(reconcileOrganizationMembership).not.toHaveBeenCalled(); + expect(reconcileTeamWorkspaceRelationships).not.toHaveBeenCalled(); }); test("throws DatabaseError on prisma error", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("db", { diff --git a/apps/web/modules/organization/settings/teams/lib/membership.ts b/apps/web/modules/organization/settings/teams/lib/membership.ts index 4f6b8a29cde1..6b7f6133cdbb 100644 --- a/apps/web/modules/organization/settings/teams/lib/membership.ts +++ b/apps/web/modules/organization/settings/teams/lib/membership.ts @@ -2,14 +2,46 @@ import "server-only"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; +import { PrismaErrorType } from "@formbricks/database/types/error"; import { logger } from "@formbricks/logger"; import { ZOptionalNumber, ZString } from "@formbricks/types/common"; import { DatabaseError, UnknownError } from "@formbricks/types/errors"; import { TMember, TMembership } from "@formbricks/types/memberships"; +import { reconcileOrganizationMembership } from "@/lib/authzed/organization-membership"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { ITEMS_PER_PAGE } from "@/lib/constants"; import { validateInputs } from "@/lib/utils/validate"; import { TOrganizationMember } from "@/modules/ee/teams/team-list/types/team"; +const MAX_SERIALIZABLE_TRANSACTION_ATTEMPTS = 3; + +const runSerializableTransactionWithRetry = async ( + transaction: (tx: Prisma.TransactionClient) => Promise +): Promise => { + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_SERIALIZABLE_TRANSACTION_ATTEMPTS; attempt++) { + try { + return await prisma.$transaction(transaction, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }); + } catch (error) { + lastError = error; + const shouldRetry = + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === PrismaErrorType.TransactionConflict && + attempt < MAX_SERIALIZABLE_TRANSACTION_ATTEMPTS; + + if (!shouldRetry) { + throw error; + } + } + } + + throw lastError; +}; + export const getMembershipByOrganizationId = reactCache( async (organizationId: string, page?: number): Promise => { validateInputs([organizationId, ZString], [page, ZOptionalNumber]); @@ -117,33 +149,42 @@ export const deleteMembership = async ( validateInputs([userId, ZString], [organizationId, ZString]); try { - const deletedTeamMemberships = await prisma.teamUser.findMany({ - where: { - userId, - team: { - organizationId, + const deletedTeamMemberships = await runSerializableTransactionWithRetry(async (tx) => { + const teamMemberships = await tx.teamUser.findMany({ + where: { + userId, + team: { + organizationId, + }, }, - }, - }); + }); - await prisma.$transaction([ - prisma.teamUser.deleteMany({ + await tx.teamUser.deleteMany({ where: { userId, team: { organizationId, }, }, - }), - prisma.membership.delete({ + }); + await tx.membership.delete({ where: { userId_organizationId: { organizationId, userId, }, }, - }), - ]); + }); + + return teamMemberships; + }); + + await reconcileOrganizationMembership(organizationId, userId); + await runPostCommitProjection("organization_membership_team_cleanup", () => + reconcileTeamWorkspaceRelationships({ + teamMemberships: deletedTeamMemberships.map(({ teamId }) => ({ teamId, userId })), + }) + ); return deletedTeamMemberships; } catch (error) { diff --git a/apps/web/modules/settings/lib/navigation-data.ts b/apps/web/modules/settings/lib/navigation-data.ts index 52b238037552..d264cd14f9bc 100644 --- a/apps/web/modules/settings/lib/navigation-data.ts +++ b/apps/web/modules/settings/lib/navigation-data.ts @@ -107,7 +107,7 @@ export const getSettingsLayoutData = async ( getAccessControlPermission(organization.id), getEnterpriseLicense(), getOrganizationWorkspacesLimit(organization.id), - getWorkspacesByUserId(userId, membership), + getWorkspacesByUserId(userId, organization.id), ]); const responseCount = IS_FORMBRICKS_CLOUD ? await getMonthlyOrganizationResponseCount(organization.id) : 0; diff --git a/apps/web/modules/settings/lib/redirect-billing-role.test.ts b/apps/web/modules/settings/lib/redirect-billing-role.test.ts new file mode 100644 index 000000000000..d27f07385bdb --- /dev/null +++ b/apps/web/modules/settings/lib/redirect-billing-role.test.ts @@ -0,0 +1,52 @@ +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { can } from "@/lib/authorization"; +import { getOrganizationAuth } from "@/modules/organization/lib/utils"; +import { redirectBillingRoleFromRestrictedOrgSettings } from "./redirect-billing-role"; +import { getOrganizationBillingPath } from "./routes"; + +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); +vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() })); + +const ORGANIZATION_ID = "org-1"; +const USER_ID = "user-1"; + +describe("redirectBillingRoleFromRestrictedOrgSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getOrganizationAuth).mockResolvedValue({ + session: { user: { id: USER_ID }, expires: new Date().toISOString() }, + } as Awaited>); + }); + + test("lets centrally authorized users through to the settings page", async () => { + vi.mocked(can).mockResolvedValue(true); + + await redirectBillingRoleFromRestrictedOrgSettings(ORGANIZATION_ID); + + expect(can).toHaveBeenCalledExactlyOnceWith({ type: "user", id: USER_ID }, "organization.read_access", { + type: "organization", + id: ORGANIZATION_ID, + }); + expect(redirect).not.toHaveBeenCalled(); + }); + + test("redirects users denied by the central authorization interface", async () => { + vi.mocked(can).mockResolvedValue(false); + + await redirectBillingRoleFromRestrictedOrgSettings(ORGANIZATION_ID); + + // `redirect` is globally mocked and does not throw (vitestSetup.ts), so assert on the call. + expect(redirect).toHaveBeenCalledExactlyOnceWith(getOrganizationBillingPath(ORGANIZATION_ID, false)); + }); + + test("propagates central authorization operational failures", async () => { + const operationalError = new Error("authorization unavailable"); + vi.mocked(can).mockRejectedValue(operationalError); + + await expect(redirectBillingRoleFromRestrictedOrgSettings(ORGANIZATION_ID)).rejects.toBe( + operationalError + ); + expect(redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/settings/lib/redirect-billing-role.ts b/apps/web/modules/settings/lib/redirect-billing-role.ts index 35e6bfe2a3b8..194515823ba2 100644 --- a/apps/web/modules/settings/lib/redirect-billing-role.ts +++ b/apps/web/modules/settings/lib/redirect-billing-role.ts @@ -1,4 +1,6 @@ import { redirect } from "next/navigation"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationBillingPath } from "@/modules/settings/lib/routes"; @@ -6,10 +8,35 @@ import { getOrganizationBillingPath } from "@/modules/settings/lib/routes"; // Org-scoped equivalent of redirectBillingRoleFromRestrictedSettings (the workspace-scoped guard). // Bounces a billing-role member away from a restricted org settings page to their billing/enterprise // home. getOrganizationAuth is React-cached, so calling it here in addition to the page is free. +// +// ENG-2409: was `isBilling -> redirect`, a role-name test that never reached the central interface, +// so the widest gate in the organization settings area (five pages) produced no parity evidence. +// +// `organization.read_access` is the same set. It expands to owner + manager + member — every +// membership role except billing — and getOrganizationAuth has already thrown for a caller with no +// membership at all, so `!read_access` and `isBilling` select exactly the same principals here. +// +// The name is admittedly a stretch: read_access is documented around access-control resources, and +// four of the five pages it guards are not that. It is used anyway because it is the only permission +// whose expansion is "holds a product-eligible membership role", which is what this guard means, and +// because ORGANIZATION_ACTION_BY_ROLE_SET already maps that role set to it. The alternative with the +// right expansion, `product_member`, is deliberately absent from the permission map — it exists only +// to intersect into `team#member`, and giving it a second job would mean a future edit to it +// silently changed team-derived workspace access. export const redirectBillingRoleFromRestrictedOrgSettings = async (organizationId: string): Promise => { - const { isBilling } = await getOrganizationAuth(organizationId); + const { session } = await getOrganizationAuth(organizationId); - if (isBilling) { + const hasOrganizationReadAccess = await withAuthorizationSurface("page", () => + can({ type: "user", id: session.user.id }, "organization.read_access", { + type: "organization", + id: organizationId, + }) + ); + + // Deliberately outside the surface callback: redirect() throws a Next control-flow error, and + // keeping it out here means the drain scheduled by withAuthorizationSurface never has to survive + // a throw from inside its own callback. + if (!hasOrganizationReadAccess) { redirect(getOrganizationBillingPath(organizationId, IS_FORMBRICKS_CLOUD)); } }; diff --git a/apps/web/modules/setup/organization/[organizationId]/invite/actions.ts b/apps/web/modules/setup/organization/[organizationId]/invite/actions.ts index c7e5c8e09146..73d5bed01457 100644 --- a/apps/web/modules/setup/organization/[organizationId]/invite/actions.ts +++ b/apps/web/modules/setup/organization/[organizationId]/invite/actions.ts @@ -27,7 +27,7 @@ export const inviteOrganizationMemberAction = authenticatedActionClient throw new AuthenticationError("Invite disabled"); } - // Owner-only — see `SETUP_INVITE_ROLES` for why this path is narrower than the org settings + // Owner-only — see `SETUP_INVITE_ACTION` for why this path is narrower than the org settings // invite path. await checkSetupInviteAuthorization(ctx.user.id, parsedInput.organizationId); diff --git a/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.test.ts b/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.test.ts index dda79c2aa50b..82d850f3fd98 100644 --- a/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.test.ts +++ b/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.test.ts @@ -1,76 +1,53 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthorizationError } from "@formbricks/types/errors"; -import { TOrganizationRole } from "@formbricks/types/memberships"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; +import { assertCan, can } from "@/lib/authorization"; import { checkSetupInviteAuthorization, hasSetupInviteAccess } from "./authorization"; -// Only the storage boundary is mocked: the real `checkAuthorizationUpdated` role matching runs, so -// these assertions describe the actual authorization outcome per role rather than the arguments a -// mock was called with. -vi.mock("@/lib/membership/service", () => ({ - getMembershipByUserIdOrganizationId: vi.fn(), -})); - -vi.mock("@/modules/ee/teams/lib/roles", () => ({ - getTeamRoleByTeamIdUserId: vi.fn(), - getWorkspacePermissionByUserId: vi.fn(), +vi.mock("@/lib/authorization", () => ({ + assertCan: vi.fn(), + can: vi.fn(), })); const userId = "test-user-id"; const organizationId = "test-organization-id"; +const actor = { id: userId, type: "user" } as const; +const resource = { id: organizationId, type: "organization" } as const; -const mockRole = (role: TOrganizationRole | null) => { - vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(role ? ({ role } as any) : null); -}; - -// ENG-2169: the onboarding invite path persists an owner invite and takes no role input, so anything -// other than an owner must be rejected here — a manager passing this check could mint an owner. -const deniedRoles: TOrganizationRole[] = ["manager", "member", "billing"]; +beforeEach(() => { + vi.clearAllMocks(); +}); describe("checkSetupInviteAuthorization", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - test("allows an organization owner", async () => { - mockRole("owner"); + test("requires the owner-only organization write capability", async () => { + vi.mocked(assertCan).mockResolvedValue(undefined); - await expect(checkSetupInviteAuthorization(userId, organizationId)).resolves.not.toThrow(); - }); - - test.each(deniedRoles)("rejects a %s", async (role) => { - mockRole(role); + await expect(checkSetupInviteAuthorization(userId, organizationId)).resolves.toBeUndefined(); - await expect(checkSetupInviteAuthorization(userId, organizationId)).rejects.toThrow(AuthorizationError); + expect(assertCan).toHaveBeenCalledExactlyOnceWith(actor, "organization.write", resource); }); - test("rejects a user without a membership in the organization", async () => { - mockRole(null); + test("preserves the central denial contract", async () => { + vi.mocked(assertCan).mockRejectedValue(new AuthorizationError("Not authorized")); - await expect(checkSetupInviteAuthorization(userId, organizationId)).rejects.toThrow(AuthorizationError); + await expect(checkSetupInviteAuthorization(userId, organizationId)).rejects.toBeInstanceOf( + AuthorizationError + ); }); }); describe("hasSetupInviteAccess", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - test("is true for an organization owner", async () => { - mockRole("owner"); - - await expect(hasSetupInviteAccess(userId, organizationId)).resolves.toBe(true); - }); + test.each([true, false])("returns the central decision (%s)", async (decision) => { + vi.mocked(can).mockResolvedValue(decision); - test.each(deniedRoles)("is false for a %s", async (role) => { - mockRole(role); + await expect(hasSetupInviteAccess(userId, organizationId)).resolves.toBe(decision); - await expect(hasSetupInviteAccess(userId, organizationId)).resolves.toBe(false); + expect(can).toHaveBeenCalledExactlyOnceWith(actor, "organization.write", resource); }); - test("is false for a user without a membership in the organization", async () => { - mockRole(null); + test("propagates an operational evaluator failure", async () => { + const failure = new Error("authorization unavailable"); + vi.mocked(can).mockRejectedValue(failure); - await expect(hasSetupInviteAccess(userId, organizationId)).resolves.toBe(false); + await expect(hasSetupInviteAccess(userId, organizationId)).rejects.toBe(failure); }); }); diff --git a/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.ts b/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.ts index aac32e74e036..d5dea69e1f31 100644 --- a/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.ts +++ b/apps/web/modules/setup/organization/[organizationId]/invite/lib/authorization.ts @@ -1,40 +1,38 @@ -import { TOrganizationRole } from "@formbricks/types/memberships"; -import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; +import { assertCan, can } from "@/lib/authorization"; /** - * The organization roles allowed on the onboarding invite path (ENG-2169). + * The capability required on the onboarding invite path (ENG-2169). * * Deliberately narrower than the org settings invite path, where managers may invite members: this - * path takes no role input and `inviteUser` always persists an owner invite, so adding "manager" - * here lets a manager mint an owner without an existing owner's approval. Nothing legitimate is - * lost — the only entry to this screen is the redirect right after `createOrganizationAction`, - * which makes the creator an owner. + * path takes no role input and `inviteUser` always persists an owner invite, so admitting a manager + * here lets them mint an owner without an existing owner's approval. Nothing legitimate is lost — + * the only entry to this screen is the redirect right after `createOrganizationAction`, which makes + * the creator an owner. * - * Both the page gate and the action authorization derive from this list so the two cannot drift. + * `organization.write` is the vocabulary's owner-only capability (`permission write: user = owner` + * in the schema). Naming it here rather than a role list is what keeps the page gate and the action + * gate the same question: both now ask the central interface, so neither can drift from the other + * or from the schema. + * + * ENG-2409: this replaced a `SETUP_INVITE_ROLES = ["owner"]` list that the page tested by reading a + * membership row and the action tested through the deprecated action-client adapter. The adapter + * already mapped the role set `["owner"]` onto `organization.write`, so the action's decision is + * unchanged; what changed is that the page's decision now goes through the authoritative central + * interface too. */ -export const SETUP_INVITE_ROLES: TOrganizationRole[] = ["owner"]; +export const SETUP_INVITE_ACTION = "organization.write" as const; /** Throws `AuthorizationError` unless the user may invite through the onboarding path. */ export const checkSetupInviteAuthorization = async ( userId: string, organizationId: string ): Promise => { - await checkAuthorizationUpdated({ - userId, - organizationId, - access: [ - { - type: "organization", - roles: SETUP_INVITE_ROLES, - }, - ], + await assertCan({ type: "user", id: userId }, SETUP_INVITE_ACTION, { + type: "organization", + id: organizationId, }); }; /** Non-throwing variant for the page gate, which renders a 404 instead of surfacing an error. */ -export const hasSetupInviteAccess = async (userId: string, organizationId: string): Promise => { - const membership = await getMembershipByUserIdOrganizationId(userId, organizationId); - - return membership ? SETUP_INVITE_ROLES.includes(membership.role) : false; -}; +export const hasSetupInviteAccess = async (userId: string, organizationId: string): Promise => + can({ type: "user", id: userId }, SETUP_INVITE_ACTION, { type: "organization", id: organizationId }); diff --git a/apps/web/modules/setup/organization/[organizationId]/invite/page.tsx b/apps/web/modules/setup/organization/[organizationId]/invite/page.tsx index 628978ff70e1..628e73e0fc84 100644 --- a/apps/web/modules/setup/organization/[organizationId]/invite/page.tsx +++ b/apps/web/modules/setup/organization/[organizationId]/invite/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; import { AuthenticationError } from "@formbricks/types/errors"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; import { getSession } from "@/modules/auth/lib/session"; @@ -27,9 +28,21 @@ export const InvitePage = async (props: InvitePageProps) => { const session = await getSession(); if (!session) throw new AuthenticationError(t("common.session_not_found")); - // Not the security boundary — `inviteOrganizationMemberAction` is — but this shares the action's - // role list so a manager gets a 404 instead of a form that fails on submit. - if (!(await hasSetupInviteAccess(session.user.id, params.organizationId))) return notFound(); + // Not the security boundary — `inviteOrganizationMemberAction` is — but this asks the action's + // exact question (`organization.write`, owner-only) so a manager gets a 404 rather than a form + // that fails on submit. Owner-only is deliberate: `inviteUser` always persists an OWNER invite + // (ENG-2169). + // + // The surface is back (ENG-2409). It was dropped when main replaced `verifyUserRoleAccess` with a + // `hasSetupInviteAccess` that read a membership row directly — wrapping that would have declared a + // surface over no `can()` call at all and emitted a zero-check observation. Now that the helper + // routes through the central interface, the wrapper attributes the authoritative decision to the + // page surface. + const mayInvite = await withAuthorizationSurface("page", () => + hasSetupInviteAccess(session.user.id, params.organizationId) + ); + + if (!mayInvite) return notFound(); return ; }; diff --git a/apps/web/modules/survey/editor/actions.ts b/apps/web/modules/survey/editor/actions.ts index 7d1793e8a810..ee8495375c7f 100644 --- a/apps/web/modules/survey/editor/actions.ts +++ b/apps/web/modules/survey/editor/actions.ts @@ -6,6 +6,7 @@ import { ZActionClassInput } from "@formbricks/types/action-classes"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TSurvey, TSurveyVariable, ZSurvey } from "@formbricks/types/surveys/types"; +import { assertCan } from "@/lib/authorization"; import { IS_FORMBRICKS_SURVEYS_CONFIGURED, POSTHOG_KEY, @@ -14,7 +15,6 @@ import { } from "@/lib/constants"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromSurveyId, getOrganizationIdFromWorkspaceId, @@ -195,21 +195,12 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu const survey = parsedInput as TSurvey; const organizationId = await getOrganizationIdFromSurveyId(survey.id); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(survey.id), - minPermission: "readWrite", - }, - ], + const workspaceId = await getWorkspaceIdFromSurveyId(survey.id); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); if (survey.recaptcha?.enabled) { await checkSpamProtectionPermission(organizationId); @@ -252,21 +243,12 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action( withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.id), - minPermission: "readWrite", - }, - ], + const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.id); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); if (parsedInput.recaptcha?.enabled) { await checkSpamProtectionPermission(organizationId); @@ -351,20 +333,9 @@ const ZRefetchWorkspaceAction = z.object({ export const refetchWorkspaceAction = authenticatedActionClient .inputSchema(ZRefetchWorkspaceAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.workspaceId, }); return await getWorkspace(parsedInput.workspaceId); @@ -377,20 +348,9 @@ const ZGetWorkspaceLanguagesAction = z.object({ export const getWorkspaceLanguagesAction = authenticatedActionClient .inputSchema(ZGetWorkspaceLanguagesAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: parsedInput.workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: parsedInput.workspaceId, }); return await getWorkspaceLanguages(parsedInput.workspaceId); @@ -501,21 +461,11 @@ export const createActionClassAction = authenticatedActionClient.inputSchema(ZCr withAuditLogging("created", "actionClass", async ({ ctx, parsedInput }) => { const workspaceId = parsedInput.action.workspaceId; const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; const result = await createActionClass(workspaceId, parsedInput.action); diff --git a/apps/web/modules/survey/lib/survey-auth.test.ts b/apps/web/modules/survey/lib/survey-auth.test.ts index 77e1c015fdaf..d5f09d9f4841 100644 --- a/apps/web/modules/survey/lib/survey-auth.test.ts +++ b/apps/web/modules/survey/lib/survey-auth.test.ts @@ -4,7 +4,7 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import type { Session } from "@formbricks/types/auth"; import { DatabaseError } from "@formbricks/types/errors"; -import { hasUserWorkspaceAccessForAction } from "@/lib/workspace/auth"; +import { can } from "@/lib/authorization"; import { getSession } from "@/modules/auth/lib/session"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; import { TWorkspaceAuth } from "@/modules/workspaces/types/workspace-auth"; @@ -26,8 +26,8 @@ vi.mock("@formbricks/logger", () => ({ }, })); -vi.mock("@/lib/workspace/auth", () => ({ - hasUserWorkspaceAccessForAction: vi.fn(), +vi.mock("@/lib/authorization", () => ({ + can: vi.fn(), })); vi.mock("@/modules/auth/lib/session", () => ({ @@ -52,7 +52,7 @@ vi.mock("react", () => ({ const mockPrismaSurvey = prisma.survey as Mocked; const mockGetWorkspaceAuth = vi.mocked(getWorkspaceAuth); const mockGetSession = vi.mocked(getSession); -const mockHasWorkspaceAccess = vi.mocked(hasUserWorkspaceAccessForAction); +const mockCan = vi.mocked(can); const ATTACKER_USER_ID = "user_attacker"; const ATTACKER_WORKSPACE_ID = "workspace_attacker"; @@ -86,7 +86,7 @@ describe("canReadSurveyInWorkspace", () => { beforeEach(() => { vi.clearAllMocks(); mockGetSession.mockResolvedValue(buildSession(ATTACKER_USER_ID)); - mockHasWorkspaceAccess.mockResolvedValue(true); + mockCan.mockResolvedValue(true); }); test("is true for a survey in a workspace the caller can read", async () => { @@ -97,7 +97,10 @@ describe("canReadSurveyInWorkspace", () => { where: { id: SURVEY_ID }, select: { workspaceId: true }, }); - expect(mockHasWorkspaceAccess).toHaveBeenCalledWith(ATTACKER_USER_ID, VICTIM_WORKSPACE_ID, "GET"); + expect(mockCan).toHaveBeenCalledWith({ type: "user", id: ATTACKER_USER_ID }, "workspace.read", { + type: "workspace", + id: VICTIM_WORKSPACE_ID, + }); }); test("is false when the survey does not exist", async () => { @@ -110,12 +113,12 @@ describe("canReadSurveyInWorkspace", () => { mockSurveyLookup({ workspaceId: VICTIM_WORKSPACE_ID }); await expect(canReadSurveyInWorkspace(ATTACKER_WORKSPACE_ID, SURVEY_ID)).resolves.toBe(false); - expect(mockHasWorkspaceAccess).not.toHaveBeenCalled(); + expect(mockCan).not.toHaveBeenCalled(); }); test("is false when the caller has no access to the workspace", async () => { mockSurveyLookup({ workspaceId: VICTIM_WORKSPACE_ID }); - mockHasWorkspaceAccess.mockResolvedValueOnce(false); + mockCan.mockResolvedValueOnce(false); await expect(canReadSurveyInWorkspace(VICTIM_WORKSPACE_ID, SURVEY_ID)).resolves.toBe(false); }); diff --git a/apps/web/modules/survey/lib/survey-auth.ts b/apps/web/modules/survey/lib/survey-auth.ts index ea7d91b0936a..17ac037f8d21 100644 --- a/apps/web/modules/survey/lib/survey-auth.ts +++ b/apps/web/modules/survey/lib/survey-auth.ts @@ -5,7 +5,7 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { DatabaseError } from "@formbricks/types/errors"; -import { hasUserWorkspaceAccessForAction } from "@/lib/workspace/auth"; +import { can } from "@/lib/authorization"; import { getSession } from "@/modules/auth/lib/session"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; import { TWorkspaceAuth } from "@/modules/workspaces/types/workspace-auth"; @@ -60,7 +60,10 @@ export const canReadSurveyInWorkspace = async (workspaceId: string, surveyId: st return false; } - return hasUserWorkspaceAccessForAction(session.user.id, workspaceId, "GET"); + return can({ type: "user", id: session.user.id }, "workspace.read", { + type: "workspace", + id: workspaceId, + }); }; /** diff --git a/apps/web/modules/survey/list/actions.ts b/apps/web/modules/survey/list/actions.ts index eb444a0627df..4010575aedd2 100644 --- a/apps/web/modules/survey/list/actions.ts +++ b/apps/web/modules/survey/list/actions.ts @@ -2,17 +2,15 @@ import { z } from "zod"; import { OperationNotAllowedError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { - getOrganizationIdFromSurveyId, - getOrganizationIdFromWorkspaceId, - getWorkspaceIdFromSurveyId, -} from "@/lib/utils/helper"; +import { getOrganizationIdFromWorkspaceId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; import { generateSurveySingleUseLinkParams, generateSurveySingleUseLinkParamsList, } from "@/lib/utils/single-use-surveys"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { copySurveyToOtherWorkspace } from "@/modules/survey/list/lib/survey"; @@ -35,38 +33,17 @@ export const copySurveyToOtherWorkspaceAction = authenticatedActionClient } // authorization check for source workspace - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: sourceOrganizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: sourceWorkspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: sourceWorkspaceId, }); // authorization check for target workspace - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: targetOrganizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: parsedInput.targetWorkspaceId, - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: parsedInput.targetWorkspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.targetWorkspaceId); ctx.auditLoggingCtx.organizationId = sourceOrganizationId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; @@ -95,20 +72,9 @@ const ZGenerateSingleUseIdAction = z export const generateSingleUseIdsAction = authenticatedActionClient .inputSchema(ZGenerateSingleUseIdAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - minPermission: "readWrite", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), }); if (parsedInput.singleUseId) { diff --git a/apps/web/modules/survey/list/lib/workspace.test.ts b/apps/web/modules/survey/list/lib/workspace.test.ts index d179d7fc302d..494e5f2638b0 100644 --- a/apps/web/modules/survey/list/lib/workspace.test.ts +++ b/apps/web/modules/survey/list/lib/workspace.test.ts @@ -3,6 +3,10 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { + lookupAuthorizedOrganizationIds, + lookupAuthorizedWorkspaceIds, +} from "@/lib/authorization/resource-list"; import { TWorkspaceWithLanguages } from "@/modules/survey/list/types/surveys"; import { TUserWorkspace } from "@/modules/survey/list/types/workspaces"; import { doesWorkspaceExist, getUserWorkspaces, getWorkspace, getWorkspaceWithLanguages } from "./workspace"; @@ -16,12 +20,14 @@ vi.mock("@formbricks/database", () => ({ findUnique: vi.fn(), findMany: vi.fn(), }, - membership: { - findFirst: vi.fn(), - }, }, })); +vi.mock("@/lib/authorization/resource-list", () => ({ + lookupAuthorizedOrganizationIds: vi.fn(), + lookupAuthorizedWorkspaceIds: vi.fn(), +})); + vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), @@ -176,73 +182,31 @@ describe("Workspace module", () => { }); describe("getUserWorkspaces", () => { - test("should return user workspaces for manager role", async () => { - const mockOrgMembership = { - userId: "user-id", - organizationId: "org-id", - role: "manager", - }; - - const mockWorkspaces: TUserWorkspace[] = [ + test("returns authoritative workspace ids scoped to the selected organization", async () => { + const mockWorkspaces = [ { id: "workspace-1", name: "Workspace 1" }, { id: "workspace-2", name: "Workspace 2" }, - ] as any; + ] satisfies TUserWorkspace[]; - vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership as any); - vi.mocked(prisma.workspace.findMany).mockResolvedValueOnce(mockWorkspaces as any); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValueOnce(["org-id"]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValueOnce(["workspace-1", "workspace-2"]); + vi.mocked(prisma.workspace.findMany).mockResolvedValueOnce(mockWorkspaces as never); const result = await getUserWorkspaces("user-id", "org-id"); expect(result).toEqual(mockWorkspaces); - expect(prisma.membership.findFirst).toHaveBeenCalledWith({ - where: { - userId: "user-id", - organizationId: "org-id", - }, + expect(lookupAuthorizedOrganizationIds).toHaveBeenCalledExactlyOnceWith({ + id: "user-id", + type: "user", }); - expect(prisma.workspace.findMany).toHaveBeenCalledWith({ - where: { - organizationId: "org-id", - }, - select: { - id: true, - name: true, - }, + expect(lookupAuthorizedWorkspaceIds).toHaveBeenCalledExactlyOnceWith({ + id: "user-id", + type: "user", }); - }); - - test("should return user workspaces for member role with workspace team filter", async () => { - const mockOrgMembership = { - userId: "user-id", - organizationId: "org-id", - role: "member", - }; - - const mockWorkspaces: TUserWorkspace[] = [ - { id: "workspace-1", name: "Workspace 1" }, - { id: "workspace-2", name: "Workspace 2" }, - ] as any; - - vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership as any); - vi.mocked(prisma.workspace.findMany).mockResolvedValueOnce(mockWorkspaces as any); - - const result = await getUserWorkspaces("user-id", "org-id"); - - expect(result).toEqual(mockWorkspaces); expect(prisma.workspace.findMany).toHaveBeenCalledWith({ where: { + id: { in: ["workspace-1", "workspace-2"] }, organizationId: "org-id", - workspaceTeams: { - some: { - team: { - teamUsers: { - some: { - userId: "user-id", - }, - }, - }, - }, - }, }, select: { id: true, @@ -252,42 +216,42 @@ describe("Workspace module", () => { }); test("should throw ValidationError when user is not a member of the organization", async () => { - vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(null); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValueOnce([]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValueOnce([]); await expect(getUserWorkspaces("user-id", "org-id")).rejects.toThrow(ValidationError); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); }); test("should handle DatabaseError when Prisma throws known request error", async () => { - const mockOrgMembership = { - userId: "user-id", - organizationId: "org-id", - role: "admin", - }; - const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { clientVersion: "1.0.0", code: "P2002", }); - vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership as any); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValueOnce(["org-id"]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValueOnce(["workspace-1"]); vi.mocked(prisma.workspace.findMany).mockRejectedValueOnce(prismaError); await expect(getUserWorkspaces("user-id", "org-id")).rejects.toThrow(DatabaseError); }); test("should rethrow unknown errors", async () => { - const mockOrgMembership = { - userId: "user-id", - organizationId: "org-id", - role: "admin", - }; - const error = new Error("Unknown error"); - vi.mocked(prisma.membership.findFirst).mockResolvedValueOnce(mockOrgMembership as any); + vi.mocked(lookupAuthorizedOrganizationIds).mockResolvedValueOnce(["org-id"]); + vi.mocked(lookupAuthorizedWorkspaceIds).mockResolvedValueOnce(["workspace-1"]); vi.mocked(prisma.workspace.findMany).mockRejectedValueOnce(error); await expect(getUserWorkspaces("user-id", "org-id")).rejects.toThrow("Unknown error"); }); + + test("propagates AuthZed lookup failures", async () => { + const unavailable = new Error("AuthZed unavailable"); + vi.mocked(lookupAuthorizedOrganizationIds).mockRejectedValueOnce(unavailable); + + await expect(getUserWorkspaces("user-id", "org-id")).rejects.toBe(unavailable); + expect(prisma.workspace.findMany).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/web/modules/survey/list/lib/workspace.ts b/apps/web/modules/survey/list/lib/workspace.ts index 690c897ac010..43533dd48ad4 100644 --- a/apps/web/modules/survey/list/lib/workspace.ts +++ b/apps/web/modules/survey/list/lib/workspace.ts @@ -5,6 +5,10 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors"; +import { + lookupAuthorizedOrganizationIds, + lookupAuthorizedWorkspaceIds, +} from "@/lib/authorization/resource-list"; import { validateInputs } from "@/lib/utils/validate"; import { TWorkspaceWithLanguages } from "@/modules/survey/list/types/surveys"; import { TUserWorkspace } from "@/modules/survey/list/types/workspaces"; @@ -75,40 +79,21 @@ export const getWorkspaceWithLanguages = reactCache( export const getUserWorkspaces = reactCache( async (userId: string, organizationId: string): Promise => { - try { - const orgMembership = await prisma.membership.findFirst({ - where: { - userId, - organizationId, - }, - }); + const actor = { type: "user", id: userId } as const; + const [authorizedOrganizationIds, authorizedWorkspaceIds] = await Promise.all([ + lookupAuthorizedOrganizationIds(actor), + lookupAuthorizedWorkspaceIds(actor), + ]); - if (!orgMembership) { - throw new ValidationError("User is not a member of this organization"); - } - - let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; - - if (orgMembership.role === "member") { - workspaceWhereClause = { - workspaceTeams: { - some: { - team: { - teamUsers: { - some: { - userId, - }, - }, - }, - }, - }, - }; - } + if (!authorizedOrganizationIds.includes(organizationId)) { + throw new ValidationError("User is not a member of this organization"); + } + try { const workspaces = await prisma.workspace.findMany({ where: { + id: { in: [...authorizedWorkspaceIds] }, organizationId, - ...workspaceWhereClause, }, select: { id: true, diff --git a/apps/web/modules/survey/multi-language-surveys/lib/actions.ts b/apps/web/modules/survey/multi-language-surveys/lib/actions.ts index 5cc22759e2f5..ceb9e6193046 100644 --- a/apps/web/modules/survey/multi-language-surveys/lib/actions.ts +++ b/apps/web/modules/survey/multi-language-surveys/lib/actions.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZLanguageInput, ZLanguageUpdate } from "@formbricks/types/workspace"; +import { assertCan } from "@/lib/authorization"; import { createLanguage, deleteLanguage, @@ -12,12 +13,9 @@ import { } from "@/lib/language/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { - getOrganizationIdFromLanguageId, - getOrganizationIdFromWorkspaceId, - getWorkspaceIdFromLanguageId, -} from "@/lib/utils/helper"; +import { getOrganizationIdFromWorkspaceId, getWorkspaceIdFromLanguageId } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; const ZCreateLanguageAction = z.object({ @@ -29,23 +27,11 @@ export const createLanguageAction = authenticatedActionClient.inputSchema(ZCreat withAuditLogging("created", "language", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - schema: ZLanguageInput, - data: parsedInput.languageInput, - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "manage", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.manage", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); const result = await createLanguage(parsedInput.workspaceId, parsedInput.languageInput); ctx.auditLoggingCtx.organizationId = organizationId; @@ -82,21 +68,11 @@ export const deleteLanguageAction = authenticatedActionClient.inputSchema(ZDelet const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "manage", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.manage", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.languageId = parsedInput.languageId; @@ -113,22 +89,9 @@ const ZGetSurveysUsingGivenLanguageAction = z.object({ export const getSurveysUsingGivenLanguageAction = authenticatedActionClient .inputSchema(ZGetSurveysUsingGivenLanguageAction) .action(async ({ ctx, parsedInput }) => { - const organizationId = await getOrganizationIdFromLanguageId(parsedInput.languageId); - - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromLanguageId(parsedInput.languageId), - minPermission: "manage", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.manage", { + type: "workspace", + id: await getWorkspaceIdFromLanguageId(parsedInput.languageId), }); return await getSurveysUsingGivenLanguage(parsedInput.languageId); @@ -152,23 +115,11 @@ export const updateLanguageAction = authenticatedActionClient.inputSchema(ZUpdat const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - schema: ZLanguageUpdate, - data: parsedInput.languageInput, - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "manage", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.manage", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.languageId = parsedInput.languageId; diff --git a/apps/web/modules/survey/slug/actions.ts b/apps/web/modules/survey/slug/actions.ts index b7bf16dc68ff..f2a5aa381660 100644 --- a/apps/web/modules/survey/slug/actions.ts +++ b/apps/web/modules/survey/slug/actions.ts @@ -3,10 +3,12 @@ import { z } from "zod"; import { OperationNotAllowedError } from "@formbricks/types/errors"; import { ZSurveySlug } from "@formbricks/types/surveys/types"; +import { assertCan } from "@/lib/authorization"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; +import { getWorkspaceIdFromSurveyId } from "@/lib/utils/helper"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { updateSurveySlug } from "@/modules/survey/lib/slug"; const ZUpdateSurveySlugAction = z.object({ @@ -21,19 +23,12 @@ export const updateSurveySlugAction = authenticatedActionClient throw new OperationNotAllowedError("Pretty URLs are only available on self-hosted instances"); } - const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - minPermission: "readWrite", - }, - ], + const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); return await updateSurveySlug(parsedInput.surveyId, parsedInput.slug); }); @@ -49,19 +44,12 @@ export const removeSurveySlugAction = authenticatedActionClient throw new OperationNotAllowedError("Pretty URLs are only available on self-hosted instances"); } - const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { type: "organization", roles: ["owner", "manager"] }, - { - type: "workspaceTeam", - workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId), - minPermission: "readWrite", - }, - ], + const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, workspaceId); return await updateSurveySlug(parsedInput.surveyId, null); }); diff --git a/apps/web/modules/traefik-auth/service.test.ts b/apps/web/modules/traefik-auth/service.test.ts index 70c9bd2f7be0..9f2fd1c59bb4 100644 --- a/apps/web/modules/traefik-auth/service.test.ts +++ b/apps/web/modules/traefik-auth/service.test.ts @@ -10,7 +10,7 @@ const { mockVerifyFeedbackRecordsGatewayToken, mockGetFeedbackDirectoryAuthContext, mockGetFeedbackRecordTenant, - mockCheckAuthorizationUpdated, + mockCan, mockUserFindUnique, mockGetIsFeedbackDirectoriesEnabled, } = vi.hoisted(() => ({ @@ -21,7 +21,7 @@ const { mockVerifyFeedbackRecordsGatewayToken: vi.fn(), mockGetFeedbackDirectoryAuthContext: vi.fn(), mockGetFeedbackRecordTenant: vi.fn(), - mockCheckAuthorizationUpdated: vi.fn(), + mockCan: vi.fn(), mockUserFindUnique: vi.fn(), mockGetIsFeedbackDirectoriesEnabled: vi.fn(), })); @@ -64,8 +64,8 @@ vi.mock("@/modules/hub/service", () => ({ getFeedbackRecordTenant: mockGetFeedbackRecordTenant, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mockCheckAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + can: mockCan, })); vi.mock("@formbricks/logger", () => ({ @@ -125,7 +125,7 @@ describe("authorizeTraefikRequest", () => { data: { tenantId: feedbackDirectoryId }, error: null, }); - mockCheckAuthorizationUpdated.mockResolvedValue(true); + mockCan.mockResolvedValue(true); mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true }); mockGetIsFeedbackDirectoriesEnabled.mockResolvedValue(true); }); @@ -204,15 +204,9 @@ describe("authorizeTraefikRequest", () => { expect(response.status).toBe(200); expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId); - expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({ - userId: "user_1", - organizationId: "org_1", - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + expect(mockCan).toHaveBeenCalledWith({ type: "user", id: "user_1" }, "organization.manage", { + type: "organization", + id: "org_1", }); }); diff --git a/apps/web/modules/utils/hooks/actions.ts b/apps/web/modules/utils/hooks/actions.ts index 3e6aad214151..235d24272db8 100644 --- a/apps/web/modules/utils/hooks/actions.ts +++ b/apps/web/modules/utils/hooks/actions.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; +import { assertCan } from "@/lib/authorization"; import { getOrganization } from "@/lib/organization/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; const ZGetOrganizationBillingInfoAction = z.object({ organizationId: ZId, @@ -13,15 +13,9 @@ const ZGetOrganizationBillingInfoAction = z.object({ export const getOrganizationBillingInfoAction = authenticatedActionClient .inputSchema(ZGetOrganizationBillingInfoAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager", "billing"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage_billing", { + type: "organization", + id: parsedInput.organizationId, }); const organization = await getOrganization(parsedInput.organizationId); diff --git a/apps/web/modules/workspaces/lib/utils.test.ts b/apps/web/modules/workspaces/lib/utils.test.ts index 1e2b30065e6e..cbc85eb782d9 100644 --- a/apps/web/modules/workspaces/lib/utils.test.ts +++ b/apps/web/modules/workspaces/lib/utils.test.ts @@ -2,16 +2,20 @@ import { redirect } from "next/navigation"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import type { TMembership, TOrganizationRole } from "@formbricks/types/memberships"; +import { can } from "@/lib/authorization"; import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getOrganization } from "@/lib/organization/service"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { getUser } from "@/lib/user/service"; +import { canUserNavigateWorkspace } from "@/lib/workspace/auth"; import { getWorkspace } from "@/lib/workspace/service"; import { getSession } from "@/modules/auth/lib/session"; +import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; +import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getWorkspacePermissionByUserId } from "@/modules/ee/teams/lib/roles"; -import { getWorkspaceAuth } from "./utils"; +import { getWorkspaceAuth, getWorkspaceLayoutData, workspaceIdLayoutChecks } from "./utils"; -const mocks = vi.hoisted(() => ({ isFormbricksCloud: false })); +const mocks = vi.hoisted(() => ({ isFormbricksCloud: false, workspaceFindUnique: vi.fn() })); // Real getAccessFlags and getTeamPermissionFlags are used on purpose so the tests exercise the // actual role -> isBilling mapping (the redirect branch) and the permission -> isReadOnly mapping. @@ -23,7 +27,12 @@ vi.mock("@/lib/constants", async (importOriginal) => ({ IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud, })); vi.mock("@/lib/workspace/service", () => ({ getWorkspace: vi.fn() })); -vi.mock("@/lib/workspace/auth", () => ({ hasUserWorkspaceAccess: vi.fn() })); +vi.mock("@/lib/authorization", () => ({ can: vi.fn() })); +vi.mock("@/lib/workspace/auth", () => ({ canUserNavigateWorkspace: vi.fn() })); +vi.mock("@formbricks/database", () => ({ prisma: { workspace: { findUnique: mocks.workspaceFindUnique } } })); +vi.mock("@/lib/user/service", () => ({ getUser: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn() })); vi.mock("@/lib/organization/service", () => ({ getOrganization: vi.fn(), getMonthlyOrganizationResponseCount: vi.fn(), @@ -56,7 +65,7 @@ const primeAuth = (role: TOrganizationRole) => { userId, accepted: true, } as TMembership); - vi.mocked(hasUserWorkspaceAccess).mockResolvedValue(true); + vi.mocked(can).mockResolvedValue(true); vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath); }; @@ -120,10 +129,15 @@ describe("getWorkspaceAuth workspace-access gate + isReadOnly (ENG-1769)", () => // The core fix: an org member with no WorkspaceTeam grant for this workspace // must be rejected instead of being admitted (and mislabeled as a writer). test("throws AuthorizationError when the user has no workspace access", async () => { - vi.mocked(hasUserWorkspaceAccess).mockResolvedValue(false); + vi.mocked(can).mockResolvedValue(false); vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); await expect(getWorkspaceAuth(workspaceId)).rejects.toThrow(AuthorizationError); - expect(hasUserWorkspaceAccess).toHaveBeenCalledWith(userId, workspaceId); + // The narrow read permission, not the navigation check: billing has already been + // redirected above, so this choke point must not re-admit it. + expect(can).toHaveBeenCalledWith({ type: "user", id: userId }, "workspace.read", { + type: "workspace", + id: workspaceId, + }); }); test("marks a member with a read grant as read-only", async () => { @@ -176,3 +190,110 @@ describe("getWorkspaceAuth workspace-access gate + isReadOnly (ENG-1769)", () => expect(auth.workspacePermission).toBe("read"); }); }); + +// The layout helpers gate navigation rather than data, so unlike getWorkspaceAuth they must keep +// admitting the billing role — that is how it reaches the billing screens. These ids are real +// cuid2s because getWorkspaceLayoutData validates them. +describe("layout navigation gates (ENG-1737)", () => { + const layoutWorkspaceId = "cl9ebqhxk00003b600tymydho"; + const layoutOrganizationId = "cl9ebqhxk00013b60vqhmydho"; + const layoutUserId = "cl9ebqhxk00023b60kzlmydho"; + + const organizationRelation = { + id: layoutOrganizationId, + createdAt: new Date(0), + updatedAt: new Date(0), + name: "Org", + billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: null, stripe: null }, + isAISmartToolsEnabled: false, + whitelabel: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue({ + user: { id: layoutUserId }, + expires: new Date(0).toISOString(), + } as Awaited>); + vi.mocked(getUser).mockResolvedValue({ id: layoutUserId } as Awaited>); + vi.mocked(canUserNavigateWorkspace).mockResolvedValue(true); + mocks.workspaceFindUnique.mockResolvedValue({ organization: organizationRelation }); + }); + + describe("workspaceIdLayoutChecks", () => { + test("asks the navigation question about the resolved workspace and its organization", async () => { + const result = await workspaceIdLayoutChecks(layoutWorkspaceId); + + expect(canUserNavigateWorkspace).toHaveBeenCalledWith(layoutUserId, { + id: layoutWorkspaceId, + organizationId: layoutOrganizationId, + }); + expect(result.organization).toMatchObject({ id: layoutOrganizationId }); + }); + + test("throws AuthorizationError when the user may not navigate there", async () => { + vi.mocked(canUserNavigateWorkspace).mockResolvedValue(false); + + await expect(workspaceIdLayoutChecks(layoutWorkspaceId)).rejects.toThrow(AuthorizationError); + }); + + // Existence is now answered before access, so a missing workspace reports itself as missing + // instead of as a denial — matching getWorkspaceAuth. + test("throws ResourceNotFoundError for a workspace that does not exist", async () => { + mocks.workspaceFindUnique.mockResolvedValue(null); + + await expect(workspaceIdLayoutChecks(layoutWorkspaceId)).rejects.toThrow(ResourceNotFoundError); + expect(canUserNavigateWorkspace).not.toHaveBeenCalled(); + }); + + test("returns early without deciding access when there is no session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const result = await workspaceIdLayoutChecks(layoutWorkspaceId); + + expect(result.session).toBeNull(); + expect(canUserNavigateWorkspace).not.toHaveBeenCalled(); + }); + }); + + describe("getWorkspaceLayoutData", () => { + beforeEach(() => { + mocks.workspaceFindUnique.mockResolvedValue({ + id: layoutWorkspaceId, + organizationId: layoutOrganizationId, + organization: { ...organizationRelation, memberships: [{ userId: layoutUserId, role: "member" }] }, + }); + vi.mocked(getAccessControlPermission).mockResolvedValue(false); + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); + vi.mocked(getEnterpriseLicense).mockResolvedValue({ active: false } as Awaited< + ReturnType + >); + }); + + test("asks the navigation question about the resolved workspace and its organization", async () => { + await getWorkspaceLayoutData(layoutWorkspaceId, layoutUserId); + + expect(canUserNavigateWorkspace).toHaveBeenCalledWith(layoutUserId, { + id: layoutWorkspaceId, + organizationId: layoutOrganizationId, + }); + }); + + test("throws AuthorizationError when the user may not navigate there", async () => { + vi.mocked(canUserNavigateWorkspace).mockResolvedValue(false); + + await expect(getWorkspaceLayoutData(layoutWorkspaceId, layoutUserId)).rejects.toThrow( + AuthorizationError + ); + }); + + test("throws ResourceNotFoundError for a workspace that does not exist", async () => { + mocks.workspaceFindUnique.mockResolvedValue(null); + + await expect(getWorkspaceLayoutData(layoutWorkspaceId, layoutUserId)).rejects.toThrow( + ResourceNotFoundError + ); + expect(canUserNavigateWorkspace).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/modules/workspaces/lib/utils.ts b/apps/web/modules/workspaces/lib/utils.ts index ff392ec9734a..6f84c57647b4 100644 --- a/apps/web/modules/workspaces/lib/utils.ts +++ b/apps/web/modules/workspaces/lib/utils.ts @@ -10,6 +10,8 @@ import { DatabaseError, ResourceNotFoundError, } from "@formbricks/types/errors"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; @@ -17,7 +19,7 @@ import { getAccessFlags } from "@/lib/membership/utils"; import { getMonthlyOrganizationResponseCount, getOrganization } from "@/lib/organization/service"; import { getUser } from "@/lib/user/service"; import { validateInputs } from "@/lib/utils/validate"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserNavigateWorkspace } from "@/lib/workspace/auth"; import { getWorkspace } from "@/lib/workspace/service"; import { getTranslate } from "@/lingodotdev/server"; import { getSession } from "@/modules/auth/lib/session"; @@ -35,8 +37,16 @@ import { TWorkspaceAuth, TWorkspaceLayoutData } from "@/modules/workspaces/types * route. Billing-role members are redirected to billing/enterprise screens; any org * member without a WorkspaceTeam grant (and who is not an owner/manager) is rejected * with an AuthorizationError instead of being silently admitted as a writer. + * + * Opened as the `page` authorization surface (ENG-2388). Every product page funnels through here, so + * this wrapper attributes authoritative decisions and checks-per-request telemetry to page traffic. */ -export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise => { +export const getWorkspaceAuth = reactCache( + async (workspaceId: string): Promise => + withAuthorizationSurface("page", () => resolveWorkspaceAuth(workspaceId)) +); + +const resolveWorkspaceAuth = async (workspaceId: string): Promise => { const t = await getTranslate(); const [workspace, session] = await Promise.all([getWorkspace(workspaceId), getSession()]); @@ -65,7 +75,7 @@ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise< // Billing-role members are scoped to billing/enterprise screens only. They must never reach // workspace product data (contacts PII, survey summaries/responses, dashboards). This is the // single choke point every product page flows through, so gating here closes all of them at - // once and keeps this helper aligned with hasUserWorkspaceAccessForAction, which already denies + // once and keeps this helper aligned with the central `workspace.read` contract, which also denies // billing. Individual pages that also guard billing inline remain correct (defense in depth). if (isBilling) { redirect(getBillingFallbackPath(organization.id, IS_FORMBRICKS_CLOUD)); @@ -75,8 +85,18 @@ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise< // getWorkspaceAuth is safe to reuse anywhere. An org member with no WorkspaceTeam // grant for this workspace has no access and must be rejected — not silently // treated as a writer. Runs alongside the permission lookup to avoid extra latency. + // + // `workspace.read` and not the broader navigation check: the billing redirect above + // already returned, so the only roles that reach this line are owner, manager, and + // member, for which the two are identical. Asking for the narrower permission means + // the choke point no longer depends on the redirect running first to keep the billing + // role out of product data — if that ordering were ever disturbed, billing would be + // refused here rather than admitted. const [hasWorkspaceAccess, workspacePermission] = await Promise.all([ - hasUserWorkspaceAccess(session.user.id, workspace.id), + can({ type: "user", id: session.user.id }, "workspace.read", { + type: "workspace", + id: workspace.id, + }), getWorkspacePermissionByUserId(session.user.id, workspace.id), ]); @@ -107,12 +127,19 @@ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise< hasManageAccess, isReadOnly, }; -}); +}; /** * Lightweight layout checks for workspace routes (survey editor, onboarding). + * + * Opened as the `page` surface for the same reason as `getWorkspaceAuth` (ENG-2388): the navigation + * gate below already routes through `can()` via `canUserNavigateWorkspace`, but a layout render is + * its own async context, so it needs its own surface to be comparable. */ -export const workspaceIdLayoutChecks = async (workspaceId: string) => { +export const workspaceIdLayoutChecks = async (workspaceId: string) => + withAuthorizationSurface("page", () => resolveWorkspaceIdLayoutChecks(workspaceId)); + +const resolveWorkspaceIdLayoutChecks = async (workspaceId: string) => { const t = await getTranslate(); const session = await getSession(); @@ -125,11 +152,10 @@ export const workspaceIdLayoutChecks = async (workspaceId: string) => { return { t, session, user: null, organization: null }; } - const hasAccess = await hasUserWorkspaceAccess(session.user.id, workspaceId); - if (!hasAccess) { - throw new AuthorizationError(t("common.not_authorized")); - } - + // Resolved before the access check because the navigation gate is expressed against the + // owning organization as well as the workspace. Answering "does it exist" first also + // matches getWorkspaceAuth above, so a missing workspace reports itself as missing here + // too instead of as a denial. const workspace = await prisma.workspace.findUnique({ where: { id: workspaceId }, select: { @@ -158,6 +184,17 @@ export const workspaceIdLayoutChecks = async (workspaceId: string) => { throw new ResourceNotFoundError(t("common.workspace"), workspaceId); } + // Navigation, not data: these layouts are also what a billing-role member has to pass + // through on the way to the billing screens, so this keeps admitting that role. The + // pages themselves gate their data on workspace.read. + const hasAccess = await canUserNavigateWorkspace(session.user.id, { + id: workspaceId, + organizationId: workspace.organization.id, + }); + if (!hasAccess) { + throw new AuthorizationError(t("common.not_authorized")); + } + return { t, session, user, organization: workspace.organization }; }; @@ -266,71 +303,91 @@ export const getWorkspaceWithRelations = reactCache(async (workspaceId: string, /** * Fetches all data required for workspace layout rendering. * Resolves the production environment automatically. + * + * Opened as the `page` surface for the same reason as its two siblings above (ENG-2388). This one + * backs the top-level `/workspaces/[workspaceId]` layout, so it is the highest-traffic authorization + * gate of the three — its `canUserNavigateWorkspace` call reaches `can()` on essentially every + * product navigation. The surface keeps authoritative decision and request-amplification telemetry + * attributable without participating in engine selection. */ export const getWorkspaceLayoutData = reactCache( - async (workspaceId: string, userId: string): Promise => { - validateInputs([workspaceId, ZId]); - validateInputs([userId, ZId]); + async (workspaceId: string, userId: string): Promise => + withAuthorizationSurface("page", () => resolveWorkspaceLayoutData(workspaceId, userId)) +); - const t = await getTranslate(); - const session = await getSession(); +const resolveWorkspaceLayoutData = async ( + workspaceId: string, + userId: string +): Promise => { + validateInputs([workspaceId, ZId]); + validateInputs([userId, ZId]); - if (!session?.user) { - throw new AuthenticationError(t("common.not_authenticated")); - } + const t = await getTranslate(); + const session = await getSession(); - if (session.user.id !== userId) { - throw new AuthenticationError("User ID mismatch with session"); - } + if (!session?.user) { + throw new AuthenticationError(t("common.not_authenticated")); + } - const user = await getUser(userId); - if (!user) { - throw new AuthenticationError(t("common.not_authenticated")); - } + if (session.user.id !== userId) { + throw new AuthenticationError("User ID mismatch with session"); + } - const hasAccess = await hasUserWorkspaceAccess(userId, workspaceId); - if (!hasAccess) { - throw new AuthorizationError(t("common.not_authorized")); - } + const user = await getUser(userId); + if (!user) { + throw new AuthenticationError(t("common.not_authenticated")); + } - const relationData = await getWorkspaceWithRelations(workspaceId, userId); - if (!relationData) { - throw new ResourceNotFoundError(t("common.workspace"), workspaceId); - } + // Resolved first so the navigation gate below can name the owning organization. This is + // the same request-memoized read the rest of this function already relied on, so it costs + // nothing extra; it only moves. + const relationData = await getWorkspaceWithRelations(workspaceId, userId); + if (!relationData) { + throw new ResourceNotFoundError(t("common.workspace"), workspaceId); + } - const { workspace, organization, membership } = relationData; + const { workspace, organization, membership } = relationData; - if (!membership) { - throw new AuthorizationError(t("common.membership_not_found")); - } + // Navigation, not data — the layout shell a billing-role member passes through on the way + // to billing. Product data on the pages inside stays gated on workspace.read. + const hasAccess = await canUserNavigateWorkspace(userId, { + id: workspace.id, + organizationId: organization.id, + }); + if (!hasAccess) { + throw new AuthorizationError(t("common.not_authorized")); + } - const [isAccessControlAllowed, workspacePermission, license] = await Promise.all([ - getAccessControlPermission(organization.id), - getWorkspacePermissionByUserId(userId, workspace.id), - getEnterpriseLicense(), - ]); + if (!membership) { + throw new AuthorizationError(t("common.membership_not_found")); + } - let responseCount = 0; - if (IS_FORMBRICKS_CLOUD) { - responseCount = await getMonthlyOrganizationResponseCount(organization.id); - } + const [isAccessControlAllowed, workspacePermission, license] = await Promise.all([ + getAccessControlPermission(organization.id), + getWorkspacePermissionByUserId(userId, workspace.id), + getEnterpriseLicense(), + ]); - return { - session, - user, - workspace, - organization: { - ...organization, - billing: { - ...organization.billing, - stripe: organization.billing.stripe ?? undefined, - }, - }, - membership, - isAccessControlAllowed, - workspacePermission, - license, - responseCount, - }; + let responseCount = 0; + if (IS_FORMBRICKS_CLOUD) { + responseCount = await getMonthlyOrganizationResponseCount(organization.id); } -); + + return { + session, + user, + workspace, + organization: { + ...organization, + billing: { + ...organization.billing, + stripe: organization.billing.stripe ?? undefined, + }, + }, + membership, + isAccessControlAllowed, + workspacePermission, + license, + responseCount, + }; +}; diff --git a/apps/web/modules/workspaces/settings/(setup)/app-connection/actions.ts b/apps/web/modules/workspaces/settings/(setup)/app-connection/actions.ts index 2bd39fcb1d49..682d617085aa 100644 --- a/apps/web/modules/workspaces/settings/(setup)/app-connection/actions.ts +++ b/apps/web/modules/workspaces/settings/(setup)/app-connection/actions.ts @@ -5,9 +5,9 @@ import { ZActionClassInput } from "@formbricks/types/action-classes"; import { ZId } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service"; +import { assertCan } from "@/lib/authorization"; import { getSurveysByActionClassId } from "@/lib/survey/service"; import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromActionClassId, getWorkspaceIdFromActionClassId } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getLatestStableFbRelease } from "./lib/github"; @@ -19,20 +19,9 @@ const ZDeleteActionClassAction = z.object({ export const deleteActionClassAction = authenticatedActionClient.inputSchema(ZDeleteActionClassAction).action( withAuditLogging("deleted", "actionClass", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromActionClassId(parsedInput.actionClassId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromActionClassId(parsedInput.actionClassId), }); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId; @@ -55,20 +44,9 @@ export const updateActionClassAction = authenticatedActionClient.inputSchema(ZUp const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "readWrite", - workspaceId: await getWorkspaceIdFromActionClassId(parsedInput.actionClassId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.write", { + type: "workspace", + id: await getWorkspaceIdFromActionClassId(parsedInput.actionClassId), }); ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId; @@ -91,20 +69,9 @@ const ZGetActiveInactiveSurveysAction = z.object({ export const getActiveInactiveSurveysAction = authenticatedActionClient .inputSchema(ZGetActiveInactiveSurveysAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId), - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - minPermission: "read", - workspaceId: await getWorkspaceIdFromActionClassId(parsedInput.actionClassId), - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.read", { + type: "workspace", + id: await getWorkspaceIdFromActionClassId(parsedInput.actionClassId), }); const surveys = await getSurveysByActionClassId(parsedInput.actionClassId); diff --git a/apps/web/modules/workspaces/settings/actions.test.ts b/apps/web/modules/workspaces/settings/actions.test.ts new file mode 100644 index 000000000000..a40c26aa52e6 --- /dev/null +++ b/apps/web/modules/workspaces/settings/actions.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; +import { getTeamsByOrganizationIdAction, updateWorkspaceAction } from "./actions"; + +const mocks = vi.hoisted(() => ({ + getOrganization: vi.fn(), + getOrganizationIdFromWorkspaceId: vi.fn(), + getRemoveBrandingPermission: vi.fn(), + getTeamsByOrganizationId: vi.fn(), + getWorkspace: vi.fn(), + updateWorkspace: vi.fn(), +})); + +vi.mock("@/lib/authorization", () => ({ + assertCan: vi.fn(), +})); + +vi.mock("@/lib/utils/action-client", () => ({ + authenticatedActionClient: { + inputSchema: vi.fn(() => ({ + action: vi.fn((fn) => fn), + })), + }, +})); + +vi.mock("@/lib/organization/service", () => ({ + getOrganization: mocks.getOrganization, +})); + +vi.mock("@/lib/posthog", () => ({ + capturePostHogEvent: vi.fn(), +})); + +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId, +})); + +vi.mock("@/lib/workspace/service", () => ({ + getWorkspace: mocks.getWorkspace, +})); + +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getRemoveBrandingPermission: mocks.getRemoveBrandingPermission, +})); + +vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({ + getTeamsByOrganizationId: mocks.getTeamsByOrganizationId, +})); + +vi.mock("@/modules/workspaces/settings/lib/workspace", () => ({ + updateWorkspace: mocks.updateWorkspace, +})); + +describe("workspace settings authorization", () => { + const organizationId = "org-1"; + const workspaceId = "workspace-1"; + const ctx = { user: { id: "user-1" }, auditLoggingCtx: {} }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue(organizationId); + mocks.getWorkspace.mockResolvedValue({ id: workspaceId, name: "Old name" }); + mocks.updateWorkspace.mockResolvedValue({ id: workspaceId, name: "New name" }); + mocks.getTeamsByOrganizationId.mockResolvedValue([]); + }); + + test("requires workspace.manage for workspace updates", async () => { + await updateWorkspaceAction({ + ctx, + parsedInput: { workspaceId, data: { name: "New name" } }, + } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "workspace.manage", { + type: "workspace", + id: workspaceId, + }); + expect(mocks.updateWorkspace).toHaveBeenCalledWith(workspaceId, { name: "New name" }); + }); + + test("does not update the workspace when authorization fails", async () => { + const authorizationError = new AuthorizationError("Not authorized"); + vi.mocked(assertCan).mockRejectedValueOnce(authorizationError); + + await expect( + updateWorkspaceAction({ + ctx, + parsedInput: { workspaceId, data: { name: "New name" } }, + } as never) + ).rejects.toBe(authorizationError); + + expect(mocks.getWorkspace).not.toHaveBeenCalled(); + expect(mocks.updateWorkspace).not.toHaveBeenCalled(); + }); + + test("requires organization.manage to list teams for workspace settings", async () => { + await getTeamsByOrganizationIdAction({ + ctx, + parsedInput: { organizationId }, + } as never); + + expect(assertCan).toHaveBeenCalledWith({ type: "user", id: "user-1" }, "organization.manage", { + type: "organization", + id: organizationId, + }); + }); + + test("does not list teams when authorization fails", async () => { + const authorizationError = new AuthorizationError("Not authorized"); + vi.mocked(assertCan).mockRejectedValueOnce(authorizationError); + + await expect( + getTeamsByOrganizationIdAction({ + ctx, + parsedInput: { organizationId }, + } as never) + ).rejects.toBe(authorizationError); + + expect(mocks.getTeamsByOrganizationId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/workspaces/settings/actions.ts b/apps/web/modules/workspaces/settings/actions.ts index 4a7fb1fda77c..bdd92ba48b72 100644 --- a/apps/web/modules/workspaces/settings/actions.ts +++ b/apps/web/modules/workspaces/settings/actions.ts @@ -4,12 +4,14 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace"; +import { assertCan } from "@/lib/authorization"; import { getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { getWorkspace } from "@/lib/workspace/service"; +import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils"; import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team"; @@ -24,23 +26,11 @@ export const updateWorkspaceAction = authenticatedActionClient.inputSchema(ZUpda withAuditLogging("updated", "workspace", async ({ ctx, parsedInput }) => { const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId); - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId, - access: [ - { - schema: ZWorkspaceUpdateInput, - data: parsedInput.data, - type: "organization", - roles: ["owner", "manager"], - }, - { - type: "workspaceTeam", - workspaceId: parsedInput.workspaceId, - minPermission: "manage", - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "workspace.manage", { + type: "workspace", + id: parsedInput.workspaceId, }); + await applyRateLimit(rateLimitConfigs.actions.stateMutation, parsedInput.workspaceId); if ( parsedInput.data.inAppSurveyBranding !== undefined || @@ -113,15 +103,9 @@ const ZGetTeamsByOrganizationIdAction = z.object({ export const getTeamsByOrganizationIdAction = authenticatedActionClient .inputSchema(ZGetTeamsByOrganizationIdAction) .action(async ({ ctx, parsedInput }) => { - await checkAuthorizationUpdated({ - userId: ctx.user.id, - organizationId: parsedInput.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: ctx.user.id }, "organization.manage", { + type: "organization", + id: parsedInput.organizationId, }); const teams = await getTeamsByOrganizationId(parsedInput.organizationId); return teams; diff --git a/apps/web/modules/workspaces/settings/general/lib/delete-workspace.test.ts b/apps/web/modules/workspaces/settings/general/lib/delete-workspace.test.ts index 06dcdc0bdc22..b0af3ce0a6e6 100644 --- a/apps/web/modules/workspaces/settings/general/lib/delete-workspace.test.ts +++ b/apps/web/modules/workspaces/settings/general/lib/delete-workspace.test.ts @@ -13,10 +13,9 @@ import { import { WORKSPACE_DELETE_CONFIRMATION_ERROR } from "./delete-workspace-confirmation"; const mocks = vi.hoisted(() => ({ - checkAuthorizationUpdated: vi.fn(), - deleteWorkspace: vi.fn(), + assertCan: vi.fn(), + deleteWorkspaceIfNotLast: vi.fn(), getWorkspace: vi.fn(), - getUserWorkspaces: vi.fn(), getWorkspaces: vi.fn(), getPostDeletionDestination: vi.fn(), cookieSet: vi.fn(), @@ -29,16 +28,15 @@ vi.mock("next/headers", () => ({ vi.mock("@/lib/workspace/service", () => ({ getWorkspace: mocks.getWorkspace, - getUserWorkspaces: mocks.getUserWorkspaces, getWorkspaces: mocks.getWorkspaces, })); -vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ - checkAuthorizationUpdated: mocks.checkAuthorizationUpdated, +vi.mock("@/lib/authorization", () => ({ + assertCan: mocks.assertCan, })); vi.mock("@/modules/workspaces/settings/lib/workspace", () => ({ - deleteWorkspace: mocks.deleteWorkspace, + deleteWorkspaceIfNotLast: mocks.deleteWorkspaceIfNotLast, })); vi.mock("./post-workspace-deletion-redirect", () => ({ @@ -69,12 +67,11 @@ const callDeleteWorkspaceWithConfirmation = (input = {}) => describe("deleteWorkspaceWithConfirmation", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.checkAuthorizationUpdated.mockResolvedValue(undefined); + mocks.assertCan.mockResolvedValue(undefined); mocks.getWorkspace.mockResolvedValue(baseWorkspace); - mocks.getUserWorkspaces.mockResolvedValue([baseWorkspace, remainingWorkspace]); + mocks.deleteWorkspaceIfNotLast.mockResolvedValue(baseWorkspace); // Post-deletion read: the deleted row is already gone. mocks.getWorkspaces.mockResolvedValue([remainingWorkspace]); - mocks.deleteWorkspace.mockResolvedValue(baseWorkspace); mocks.getPostDeletionDestination.mockResolvedValue({ workspaceId: remainingWorkspace.id, path: `/workspaces/${remainingWorkspace.id}/`, @@ -93,18 +90,14 @@ describe("deleteWorkspaceWithConfirmation", () => { auditLoggingCtx, }); - expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({ - userId, - organizationId: baseWorkspace.organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + expect(mocks.assertCan).toHaveBeenCalledWith({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: baseWorkspace.organizationId, }); - expect(mocks.getUserWorkspaces).toHaveBeenCalledWith(userId, baseWorkspace.organizationId); - expect(mocks.deleteWorkspace).toHaveBeenCalledWith(baseWorkspace.id); + expect(mocks.deleteWorkspaceIfNotLast).toHaveBeenCalledWith( + baseWorkspace.id, + baseWorkspace.organizationId + ); expect(auditLoggingCtx).toMatchObject({ organizationId: baseWorkspace.organizationId, workspaceId: baseWorkspace.id, @@ -119,18 +112,17 @@ describe("deleteWorkspaceWithConfirmation", () => { test("resolves the destination after the deletion, from the surviving workspaces", async () => { await callDeleteWorkspaceWithConfirmation(); - // The freshly read surviving list, not the pre-deletion snapshot from getUserWorkspaces — a - // workspace deleted concurrently since that snapshot must not be picked as the destination. + // The freshly read surviving list is resolved only after the atomic deletion guard completes. expect(mocks.getPostDeletionDestination).toHaveBeenCalledWith({ organizationId: baseWorkspace.organizationId, currentWorkspace: baseWorkspace, availableWorkspaces: [remainingWorkspace], }); expect(mocks.getWorkspaces.mock.invocationCallOrder[0]).toBeGreaterThan( - mocks.deleteWorkspace.mock.invocationCallOrder[0] + mocks.deleteWorkspaceIfNotLast.mock.invocationCallOrder[0] ); // The gate and the workspace list must be read after the row is gone, not when the page rendered. - expect(mocks.deleteWorkspace.mock.invocationCallOrder[0]).toBeLessThan( + expect(mocks.deleteWorkspaceIfNotLast.mock.invocationCallOrder[0]).toBeLessThan( mocks.getPostDeletionDestination.mock.invocationCallOrder[0] ); }); @@ -206,7 +198,7 @@ describe("deleteWorkspaceWithConfirmation", () => { ).rejects.toThrow(DELETE_WORKSPACE_CONFIRMATION_REQUIRED_ERROR); expect(mocks.getWorkspace).not.toHaveBeenCalled(); - expect(mocks.deleteWorkspace).not.toHaveBeenCalled(); + expect(mocks.deleteWorkspaceIfNotLast).not.toHaveBeenCalled(); }); test("does not delete when the confirmation name does not match", async () => { @@ -215,9 +207,8 @@ describe("deleteWorkspaceWithConfirmation", () => { await expect(deleteAttempt).rejects.toThrow(InvalidInputError); await expect(deleteAttempt).rejects.toThrow(WORKSPACE_DELETE_CONFIRMATION_ERROR); - expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled(); - expect(mocks.getUserWorkspaces).not.toHaveBeenCalled(); - expect(mocks.deleteWorkspace).not.toHaveBeenCalled(); + expect(mocks.assertCan).not.toHaveBeenCalled(); + expect(mocks.deleteWorkspaceIfNotLast).not.toHaveBeenCalled(); }); test("does not delete when the workspace cannot be found", async () => { @@ -225,30 +216,34 @@ describe("deleteWorkspaceWithConfirmation", () => { await expect(callDeleteWorkspaceWithConfirmation()).rejects.toThrow(ResourceNotFoundError); - expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled(); - expect(mocks.deleteWorkspace).not.toHaveBeenCalled(); + expect(mocks.assertCan).not.toHaveBeenCalled(); + expect(mocks.deleteWorkspaceIfNotLast).not.toHaveBeenCalled(); }); test("does not delete when authorization fails", async () => { - mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized")); + mocks.assertCan.mockRejectedValueOnce(new AuthorizationError("Not authorized")); await expect(callDeleteWorkspaceWithConfirmation()).rejects.toThrow(AuthorizationError); - expect(mocks.getUserWorkspaces).not.toHaveBeenCalled(); - expect(mocks.deleteWorkspace).not.toHaveBeenCalled(); + expect(mocks.deleteWorkspaceIfNotLast).not.toHaveBeenCalled(); }); test("does not delete the last available workspace", async () => { - mocks.getUserWorkspaces.mockResolvedValueOnce([baseWorkspace]); + mocks.deleteWorkspaceIfNotLast.mockRejectedValueOnce( + new OperationNotAllowedError("You can't delete the last workspace.") + ); await expect(callDeleteWorkspaceWithConfirmation()).rejects.toThrow(OperationNotAllowedError); - expect(mocks.deleteWorkspace).not.toHaveBeenCalled(); + expect(mocks.deleteWorkspaceIfNotLast).toHaveBeenCalledWith( + baseWorkspace.id, + baseWorkspace.organizationId + ); }); test("rethrows downstream delete failures", async () => { const error = new Error("delete failed"); - mocks.deleteWorkspace.mockRejectedValueOnce(error); + mocks.deleteWorkspaceIfNotLast.mockRejectedValueOnce(error); await expect(callDeleteWorkspaceWithConfirmation()).rejects.toThrow(error); }); diff --git a/apps/web/modules/workspaces/settings/general/lib/delete-workspace.ts b/apps/web/modules/workspaces/settings/general/lib/delete-workspace.ts index 3653be170c8e..4f5eb4196d03 100644 --- a/apps/web/modules/workspaces/settings/general/lib/delete-workspace.ts +++ b/apps/web/modules/workspaces/settings/general/lib/delete-workspace.ts @@ -2,11 +2,11 @@ import { cookies } from "next/headers"; import { z } from "zod"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; -import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { assertCan } from "@/lib/authorization"; import { FORMBRICKS_WORKSPACE_ID_COOKIE } from "@/lib/localStorage"; -import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; -import { getUserWorkspaces, getWorkspace, getWorkspaces } from "@/lib/workspace/service"; -import { deleteWorkspace } from "@/modules/workspaces/settings/lib/workspace"; +import { getWorkspace, getWorkspaces } from "@/lib/workspace/service"; +import { deleteWorkspaceIfNotLast } from "@/modules/workspaces/settings/lib/workspace"; import { WORKSPACE_DELETE_CONFIRMATION_ERROR, hasMatchingWorkspaceDeleteConfirmation, @@ -101,28 +101,16 @@ export const deleteWorkspaceWithConfirmation = async ({ const organizationId = workspace.organizationId; - await checkAuthorizationUpdated({ - userId, - organizationId, - access: [ - { - type: "organization", - roles: ["owner", "manager"], - }, - ], + await assertCan({ type: "user", id: userId }, "organization.manage", { + type: "organization", + id: organizationId, }); - const availableWorkspaces = await getUserWorkspaces(userId, organizationId); - - if (availableWorkspaces.length <= 1) { - throw new OperationNotAllowedError("You can't delete the last workspace."); - } - auditLoggingCtx.organizationId = organizationId; auditLoggingCtx.workspaceId = workspaceId; auditLoggingCtx.oldObject = workspace; - const deletedWorkspace = await deleteWorkspace(workspaceId); + const deletedWorkspace = await deleteWorkspaceIfNotLast(workspaceId, organizationId); // Resolved here rather than when the settings page rendered, so the surviving workspaces and the // onboarding gate are both read at navigation time. Deliberately not `availableWorkspaces`: that diff --git a/apps/web/modules/workspaces/settings/lib/workspace.test.ts b/apps/web/modules/workspaces/settings/lib/workspace.test.ts index f997147267bd..f67ba6a1e408 100644 --- a/apps/web/modules/workspaces/settings/lib/workspace.test.ts +++ b/apps/web/modules/workspaces/settings/lib/workspace.test.ts @@ -3,10 +3,17 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { StorageErrorCode } from "@formbricks/storage"; -import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors"; +import { + DatabaseError, + InvalidInputError, + OperationNotAllowedError, + ValidationError, +} from "@formbricks/types/errors"; import { TWorkspace } from "@formbricks/types/workspace"; +import { reconcileFeedbackDirectoryRelationships } from "@/lib/authzed/feedback-directory"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { deleteFilesByWorkspaceId } from "@/modules/storage/service"; -import { createWorkspace, deleteWorkspace, updateWorkspace } from "./workspace"; +import { createWorkspace, deleteWorkspace, deleteWorkspaceIfNotLast, updateWorkspace } from "./workspace"; vi.mock("server-only", () => ({})); @@ -36,6 +43,7 @@ const baseWorkspace = { vi.mock("@formbricks/database", () => ({ prisma: { $transaction: vi.fn(), + $queryRaw: vi.fn(), workspace: { update: vi.fn(), create: vi.fn(), @@ -54,6 +62,7 @@ vi.mock("@formbricks/database", () => ({ feedbackDirectoryWorkspace: { count: vi.fn(), create: vi.fn(), + findMany: vi.fn(), }, }, })); @@ -64,6 +73,13 @@ vi.mock("@formbricks/database", () => ({ const mockOrgTeams = (...ids: string[]) => ids.map((id) => ({ id })) as unknown as Awaited>; +vi.mock("@/lib/authzed/team-workspace", () => ({ + reconcileTeamWorkspaceRelationships: vi.fn(), +})); +vi.mock("@/lib/authzed/feedback-directory", () => ({ + reconcileFeedbackDirectoryRelationships: vi.fn(), +})); + const expectNoFrdSideEffects = () => { expect(prisma.feedbackDirectory.upsert).not.toHaveBeenCalled(); expect(prisma.feedbackDirectory.findFirst).not.toHaveBeenCalled(); @@ -92,6 +108,7 @@ describe("workspace lib", () => { // createWorkspace runs its ownership check and both writes in one transaction. Hand the callback // the same prisma mock so assertions stay on `prisma.*` and a rollback surfaces as a throw. vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback(prisma)); + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValue([]); }); describe("updateWorkspace", () => { @@ -102,6 +119,7 @@ describe("workspace lib", () => { }); expect(result).toEqual(baseWorkspace); expect(prisma.workspace.update).toHaveBeenCalled(); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ workspaceIds: ["p1"] }); }); test("throws DatabaseError on Prisma error", async () => { @@ -161,6 +179,10 @@ describe("workspace lib", () => { ); expect(prisma.workspace.create).toHaveBeenCalled(); expect(prisma.workspaceTeam.createMany).toHaveBeenCalled(); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ + workspaceIds: ["p2"], + workspaceTeamGrants: [{ teamId: "t1", workspaceId: "p2" }], + }); expectNoFrdSideEffects(); }); @@ -307,11 +329,22 @@ describe("workspace lib", () => { describe("deleteWorkspace", () => { test("deletes workspace, deletes files, and revalidates cache", async () => { + const feedbackDirectoryAssignment = { + feedbackDirectoryId: "feedback-directory-1", + workspaceId: "p1", + }; + vi.mocked(prisma.feedbackDirectoryWorkspace.findMany).mockResolvedValueOnce([ + feedbackDirectoryAssignment, + ] as any); vi.mocked(prisma.workspace.delete).mockResolvedValueOnce(baseWorkspace as any); vi.mocked(deleteFilesByWorkspaceId).mockResolvedValue({ ok: true, data: undefined }); const result = await deleteWorkspace("p1"); expect(result).toEqual(baseWorkspace); + expect(reconcileTeamWorkspaceRelationships).toHaveBeenCalledWith({ workspaceIds: ["p1"] }); + expect(reconcileFeedbackDirectoryRelationships).toHaveBeenCalledWith({ + assignments: [feedbackDirectoryAssignment], + }); expect(deleteFilesByWorkspaceId).toHaveBeenCalledWith("p1", []); }); @@ -339,5 +372,20 @@ describe("workspace lib", () => { vi.mocked(prisma.workspace.delete).mockRejectedValueOnce(new Error("fail")); await expect(deleteWorkspace("p1")).rejects.toThrow("fail"); }); + + test("deletes a workspace while another workspace remains", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([{ id: "p1" }, { id: "p2" }]); + vi.mocked(prisma.workspace.delete).mockResolvedValueOnce(baseWorkspace as any); + vi.mocked(deleteFilesByWorkspaceId).mockResolvedValue({ ok: true, data: undefined }); + + await expect(deleteWorkspaceIfNotLast("p1", "org1")).resolves.toEqual(baseWorkspace); + }); + + test("does not delete the last workspace", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([{ id: "p1" }]); + + await expect(deleteWorkspaceIfNotLast("p1", "org1")).rejects.toThrow(OperationNotAllowedError); + expect(prisma.workspace.delete).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/web/modules/workspaces/settings/lib/workspace.ts b/apps/web/modules/workspaces/settings/lib/workspace.ts index 460b2ddb02f2..0ddd0f7b9a20 100644 --- a/apps/web/modules/workspaces/settings/lib/workspace.ts +++ b/apps/web/modules/workspaces/settings/lib/workspace.ts @@ -3,8 +3,17 @@ import { prisma } from "@formbricks/database"; import { Prisma } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; import { ZId } from "@formbricks/types/common"; -import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors"; +import { + DatabaseError, + InvalidInputError, + OperationNotAllowedError, + ResourceNotFoundError, + ValidationError, +} from "@formbricks/types/errors"; import { TWorkspace, TWorkspaceUpdateInput, ZWorkspaceUpdateInput } from "@formbricks/types/workspace"; +import { reconcileFeedbackDirectoryRelationships } from "@/lib/authzed/feedback-directory"; +import { runPostCommitProjection } from "@/lib/authzed/projection-boundary"; +import { reconcileTeamWorkspaceRelationships } from "@/lib/authzed/team-workspace"; import { DEFAULT_LOCALE } from "@/lib/constants"; import { isPrismaKnownRequestError, isUniqueConstraintError } from "@/lib/utils/prisma-error"; import { validateInputs } from "@/lib/utils/validate"; @@ -97,6 +106,10 @@ export const updateWorkspace = async ( throw error; } + await runPostCommitProjection("workspace_update", () => + reconcileTeamWorkspaceRelationships({ workspaceIds: [workspaceId] }) + ); + return updatedWorkspace as TWorkspace; }; @@ -114,12 +127,13 @@ export const createWorkspace = async ( // Captured out here so the guard above still narrows it: inside the transaction callback below, // TypeScript widens workspaceInput.name back to `string | undefined`. const name = workspaceInput.name; + let workspace: TWorkspace; try { // The ownership check and both writes share one transaction: it keeps the check and the link // atomic (a team cannot leave the organization in between), and stops a failed createMany from // leaving an orphan workspace with no team links. - return await prisma.$transaction(async (tx) => { + workspace = (await prisma.$transaction(async (tx) => { // ENG-1922: teamIds are caller-supplied. Validate that every team belongs to this // organization before linking it — otherwise a caller could attach another org's team // to their workspace (a cross-tenant WorkspaceTeam write). The FK only enforces that the @@ -177,7 +191,7 @@ export const createWorkspace = async ( } return workspace; - }); + })) as TWorkspace; } catch (error) { if (isUniqueConstraintError(error)) { throw new InvalidInputError("A workspace with this name already exists in your organization"); @@ -187,32 +201,100 @@ export const createWorkspace = async ( } throw error; } + + await runPostCommitProjection("workspace_create", () => + reconcileTeamWorkspaceRelationships({ + workspaceIds: [workspace.id], + workspaceTeamGrants: (teamIds ?? []).map((teamId) => ({ teamId, workspaceId: workspace.id })), + }) + ); + + return workspace; +}; + +type TWorkspaceDeletionDbClient = typeof prisma | Prisma.TransactionClient; + +const deleteWorkspaceRecord = async (db: TWorkspaceDeletionDbClient, workspaceId: string) => { + const feedbackDirectoryAssignments = await db.feedbackDirectoryWorkspace.findMany({ + where: { workspaceId }, + select: { feedbackDirectoryId: true, workspaceId: true }, + }); + const workspace = await db.workspace.delete({ + where: { + id: workspaceId, + }, + select: selectWorkspace, + }); + + return { feedbackDirectoryAssignments, workspace }; +}; + +const completeWorkspaceDeletion = async ( + workspaceId: string, + { feedbackDirectoryAssignments, workspace }: Awaited> +) => { + await runPostCommitProjection("workspace_delete", () => + reconcileTeamWorkspaceRelationships({ workspaceIds: [workspaceId] }) + ); + await runPostCommitProjection("workspace_delete_feedback_directory_cleanup", () => + reconcileFeedbackDirectoryRelationships({ assignments: feedbackDirectoryAssignments }) + ); + + const s3Result = await deleteFilesByWorkspaceId(workspaceId, []); + + if (!s3Result.ok && "error" in s3Result) { + // fail silently because we don't want to throw an error if the files are not deleted + logger.error(s3Result.error, "Error deleting S3 files"); + } + + return workspace; +}; + +const throwWorkspaceDeletionError = (error: unknown): never => { + if (isPrismaKnownRequestError(error)) { + throw new DatabaseError(error.message); + } + + throw error; }; export const deleteWorkspace = async (workspaceId: string): Promise => { try { - const workspace = await prisma.workspace.delete({ - where: { - id: workspaceId, - }, - select: selectWorkspace, - }); + return await completeWorkspaceDeletion(workspaceId, await deleteWorkspaceRecord(prisma, workspaceId)); + } catch (error) { + return throwWorkspaceDeletionError(error); + } +}; + +export const deleteWorkspaceIfNotLast = async ( + workspaceId: string, + organizationId: string +): Promise => { + try { + const deletion = await prisma.$transaction(async (tx) => { + // Lock every workspace in a stable order. Concurrent deletion requests for the organization + // then serialize, so the second request sees the first deletion before evaluating the guard. + const workspaces = await tx.$queryRaw>` + SELECT "id" + FROM "Workspace" + WHERE "organizationId" = ${organizationId} + ORDER BY "id" + FOR UPDATE + `; - if (workspace) { - const s3Result = await deleteFilesByWorkspaceId(workspaceId, []); + if (!workspaces.some((workspace) => workspace.id === workspaceId)) { + throw new ResourceNotFoundError("workspace", workspaceId); + } - if (!s3Result.ok && "error" in s3Result) { - // fail silently because we don't want to throw an error if the files are not deleted - logger.error(s3Result.error, "Error deleting S3 files"); + if (workspaces.length <= 1) { + throw new OperationNotAllowedError("You can't delete the last workspace."); } - } - return workspace; - } catch (error) { - if (isPrismaKnownRequestError(error)) { - throw new DatabaseError(error.message); - } + return deleteWorkspaceRecord(tx, workspaceId); + }); - throw error; + return await completeWorkspaceDeletion(workspaceId, deletion); + } catch (error) { + return throwWorkspaceDeletionError(error); } }; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 29fb3cd78d7a..f9f24624e4c5 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -56,7 +56,9 @@ const nextConfig = { // Enable source maps only when uploading to Sentry (CI/production); skip for faster local builds productionBrowserSourceMaps: !!process.env.SENTRY_AUTH_TOKEN, serverExternalPackages: [ + "@authzed/authzed-node", "@aws-sdk", + "@grpc/grpc-js", "@opentelemetry/api", "@opentelemetry/auto-instrumentations-node", "@opentelemetry/exporter-metrics-otlp-http", diff --git a/apps/web/package.json b/apps/web/package.json index d20cb5c79d1b..595d7357c7ec 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "clean": "rimraf .turbo node_modules .next coverage", "dev": "next dev -p 3000 --turbopack", "go": "next dev -p 3000 --turbopack", - "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build && pnpm build:env-validator", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build && pnpm build:env-validator && pnpm build:authzed-cli", + "build:authzed-cli": "vite build --config vite.authzed-cli.config.mts", "build:env-validator": "vite build --config vite.env-validation.config.mts", "build:dev": "pnpm run build", "start": "next start", @@ -24,6 +25,7 @@ "i18n:generate": "dotenv -e ../../.env -- npx lingo.dev@latest run && dotenv -e ../../.env -- npx lingo.dev@latest lockfile --force" }, "dependencies": { + "@authzed/authzed-node": "1.6.1", "@better-auth/core": "1.7.0", "@better-auth/oauth-provider": "1.7.0", "@better-auth/utils": "0.4.2", @@ -48,6 +50,7 @@ "@formbricks/types": "workspace:*", "@formbricks/workflows": "workspace:*", "@formkit/auto-animate": "catalog:", + "@grpc/grpc-js": "1.14.4", "@hookform/resolvers": "5.2.2", "@json2csv/node": "7.0.6", "@lexical/code": "0.41.0", @@ -61,6 +64,7 @@ "@lexical/table": "0.41.0", "@lexical/utils": "0.41.0", "@modelcontextprotocol/server": "2.0.0", + "@opentelemetry/api": "1.9.0", "@opentelemetry/auto-instrumentations-node": "0.75.0", "@opentelemetry/exporter-metrics-otlp-http": "0.217.0", "@opentelemetry/exporter-prometheus": "0.217.0", diff --git a/apps/web/scripts/authzed-backfill-smoke.ts b/apps/web/scripts/authzed-backfill-smoke.ts new file mode 100644 index 000000000000..52ac131cb120 --- /dev/null +++ b/apps/web/scripts/authzed-backfill-smoke.ts @@ -0,0 +1,218 @@ +import "server-only"; +import type { TAuthzedBackfillSource } from "../lib/authzed/backfill"; +import type { TAuthzedOrganizationSource } from "../lib/authzed/backfill-source"; +import { INVALID_CONFIGURATION_RESULT, INVALID_REQUEST_RESULT } from "./authzed-schema-results"; + +/** + * Drives the real backfill orchestrator against a real SpiceDB, without a database. + * + * The compose smoke harness has no Formbricks PostgreSQL — `DATABASE_URL` points at a fake host and + * Prisma never connects — so both the source reads and the reconcilers are stubbed. What runs for real + * is the half that can only be trusted once it has met the engine: paging past the read bound, pinning + * one revision across pages, mapping raw relationships back to the source records they imply, and + * deciding whether pruning is allowed. + * + * The reconcilers are recorded rather than executed because they read PostgreSQL themselves. Their + * deletion behaviour against a real SpiceDB is already covered by the relationship-projection + * assertions elsewhere in this harness; what is new here is *which targets* the orchestrator hands + * over, and under which flags. + * + * Refuses to run outside a test environment, mirroring `authzed-relationships-smoke.ts`. The real + * operator command carries no such guard — that would defeat its purpose — and relies instead on its + * confirmation flags, the endpoint check, and the prune cap. + * + * Commands: + * seed write `count` team parent relationships through the facade + * observe drain every team relationship, reporting count and whether a revision was pinned + * report dry run: detect orphans, hand over nothing + * prune apply + prune: hand the orphans to the reconcilers + * prune-capped apply + prune with a cap of 1, which must hand over nothing + * prune-page-capped apply + prune with a cap above one page but below the total, which must also + * hand over nothing — the case a per-page cap check would have part-pruned + * cleanup remove the seeded relationships + */ + +const COMMANDS = [ + "seed", + "observe", + "report", + "prune", + "prune-capped", + "prune-page-capped", + "cleanup", +] as const; +type TCommand = (typeof COMMANDS)[number]; + +const isCommand = (value: string | undefined): value is TCommand => + value !== undefined && (COMMANDS as readonly string[]).includes(value); + +/** Fixture identifiers, sharing the `application-*` prefix the rest of the harness uses. */ +const ORGANIZATION_ID = "application-backfill-smoke-org"; +const teamId = (index: number): string => `application-backfill-smoke-team-${index}`; + +const writeResult = (result: object): void => { + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +// Annotated rather than cast: adding a field to the source types must break this at compile time, not +// at runtime inside the CI smoke job. +const emptySource: TAuthzedOrganizationSource = { + apiKeyIds: [], + apiKeyWorkspaceGrants: [], + expectedRelationships: [], + feedbackDirectoryAssignments: [], + feedbackDirectoryIds: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + memberships: [], + teamIds: [], + teamMemberships: [], + workspaceIds: [], + workspaceTeamGrants: [], +}; + +const run = async (): Promise => { + const command = process.argv[2]; + if (!isCommand(command)) { + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + if (process.env.NODE_ENV !== "test") { + writeResult({ code: "authzed_backfill_smoke_refused", retryable: false, status: "failed" }); + process.exitCode = 1; + return; + } + + let closeClient: (() => void) | undefined; + + try { + const { closeAuthzedClient, configureAuthzedClientForBulkWork, getAuthzedClient } = + await import("../lib/authzed/client"); + closeClient = closeAuthzedClient; + // The same widening the CLI performs. Without it this exercises the sweep against the request-path + // deadline, so the one test that runs the sweep for real would not be running what operators run. + configureAuthzedClientForBulkWork(); + const client = getAuthzedClient(); + + if (command === "seed") { + const count = Number(process.argv[3] ?? "0"); + if (!Number.isSafeInteger(count) || count < 1 || count > 900) { + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + await client.writeRelationships( + Array.from({ length: count }, (_unused, index) => ({ + operation: "touch" as const, + relationship: { + relation: "organization", + resource: { objectId: teamId(index), objectType: "team" }, + subject: { objectId: ORGANIZATION_ID, objectType: "organization" }, + }, + })) + ); + + writeResult({ seeded: count, status: "seeded" }); + process.exitCode = 0; + return; + } + + if (command === "cleanup") { + await client.deleteRelationships({ + resourceType: "team", + subject: { objectId: ORGANIZATION_ID, objectType: "organization" }, + }); + writeResult({ status: "cleaned" }); + process.exitCode = 0; + return; + } + + const { readAllRelationships } = await import("../lib/authzed/relationship-reads"); + + if (command === "observe") { + const observation = await readAllRelationships(client, { resourceType: "team" }); + writeResult({ + relationshipCount: observation.relationships.length, + snapshotPinned: observation.snapshot !== null, + status: "observed", + }); + process.exitCode = 0; + return; + } + + const { runAuthzedBackfill } = await import("../lib/authzed/backfill"); + + // Every relationship the harness seeded is absent from PostgreSQL by construction, so reporting + // each observed record as missing is both honest and the fullest exercise of the orphan path. + const source: TAuthzedBackfillSource = { + findMismatchedParentEdges: async () => [], + findMissingSourceRefs: async (refs) => refs, + organizationExists: async () => true, + readOrganizationIdPage: async () => [], + readOrganizationSource: async () => emptySource, + readWorkspaceSource: async () => ({ + apiKeyWorkspaceGrants: [], + expectedRelationships: [], + feedbackDirectoryAssignments: [], + invalidApiKeyWorkspaceGrants: [], + invalidFeedbackDirectoryAssignments: [], + invalidWorkspaceTeamGrants: [], + organizationId: null, + workspaceExists: false, + workspaceTeamGrants: [], + }), + }; + + const handedOver: unknown[] = []; + const record = async (targets: unknown) => { + handedOver.push(targets); + return { passes: 1, status: "projected" } as const; + }; + + const applying = command !== "report"; + // 280 sits above one read page (250) and below the seeded total, so a cap enforced per page would + // prune the first page and stop. Only a cap decided against the whole sweep hands over nothing. + const maxPruneFor: Record = { "prune-capped": 1, "prune-page-capped": 280 }; + const result = await runAuthzedBackfill( + { + maxPrune: maxPruneFor[command] ?? 500, + mode: applying ? "apply" : "dry_run", + prune: applying, + scope: { kind: "all" }, + }, + { + apply: { + deleteFeedbackDirectoryAssignmentResources: record, + reconcileApiKeys: record, + reconcileFeedbackDirectories: record, + reconcileMemberships: record, + reconcileTeamWorkspace: record, + }, + client, + source, + } + ); + + writeResult({ + completedAtSnapshot: result.completedAtSnapshot, + handedOverCount: handedOver.length, + orphaned: result.counters.orphaned, + pruned: result.counters.pruned, + skipped: result.counters.skipped, + status: result.status, + truncated: result.truncated, + }); + process.exitCode = result.status === "failed" ? 1 : 0; + } catch { + writeResult(INVALID_CONFIGURATION_RESULT); + process.exitCode = 1; + } finally { + closeClient?.(); + } +}; + +void run(); diff --git a/apps/web/scripts/authzed-backfill.test.ts b/apps/web/scripts/authzed-backfill.test.ts new file mode 100644 index 000000000000..ef6f14d36c37 --- /dev/null +++ b/apps/web/scripts/authzed-backfill.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +/** + * `apps/web/scripts/**` is excluded from coverage, so this is a contract test rather than a behavioural + * one: it asserts the entry point stays a thin argv shim and that every decision lives in the covered + * command module. + */ +describe("authzed backfill script", () => { + const scriptSource = readFileSync(new URL("./authzed-backfill.ts", import.meta.url), "utf8"); + + test("delegates parsing and execution to the covered command module", () => { + expect(scriptSource).toContain("parseAuthzedBackfillCommand"); + expect(scriptSource).toContain("runAuthzedBackfillCli"); + }); + + test("contains no guard logic of its own", () => { + // The prune guards must be in lib/authzed/backfill-cli.ts, where the coverage gate applies. + for (const guard of ["--prune", "--confirm-prune", "--expected-endpoint", "maxPrune"]) { + // Mentioning a flag in the usage doc-block is fine; branching on one is not. + expect(scriptSource).not.toMatch(new RegExp(`(if|includes|startsWith)[^\\n]*${guard}`)); + } + }); + + test("reports an exit code rather than exiting the process", () => { + // process.exit would skip the single-JSON-line output contract the automation depends on. + expect(scriptSource).toContain("process.exitCode"); + expect(scriptSource).not.toContain("process.exit("); + }); +}); diff --git a/apps/web/scripts/authzed-backfill.ts b/apps/web/scripts/authzed-backfill.ts new file mode 100644 index 000000000000..827c60ec8958 --- /dev/null +++ b/apps/web/scripts/authzed-backfill.ts @@ -0,0 +1,62 @@ +import "server-only"; +import { INVALID_CONFIGURATION_RESULT, INVALID_REQUEST_RESULT } from "./authzed-schema-results"; + +/** + * Entry point for `pnpm authzed:backfill`. + * + * Deliberately thin: it hands argv to the covered command layer and reports an exit code. Parsing and + * every guard live in `lib/authzed/backfill-cli.ts`, because `apps/web/scripts/**` is excluded from + * coverage and the destructive-path guards must be tested. + * + * Usage: + * pnpm authzed:backfill + * Dry run over every organization. Reports drift, writes nothing. Exits 2 if drift remains. + * pnpm authzed:backfill --organization-id= + * Dry run over one organization. + * pnpm authzed:backfill --apply + * Converge every organization from PostgreSQL. Reports relationships with no source record but + * leaves them in place. + * pnpm authzed:backfill --apply --prune --confirm-prune --scope=all \ + * --expected-endpoint= + * Also reconcile records observed only in SpiceDB, removing what PostgreSQL no longer holds. + * pnpm authzed:backfill --apply --after-organization-id= + * Resume an interrupted run from the `lastOrganizationId` the previous run reported. + * + * Optional: --max-prune= lowers the per-run prune cap (it can never raise it). + * + * Exit codes: 0 reconciled, 2 drift remains, 1 failed or misused. + */ + +const writeResult = (result: object): void => { + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +const run = async (): Promise => { + const originalConsoleError = console.error; + + try { + // Environment validation logs details before throwing. Suppress that duplicate output so this + // automation-oriented command always emits exactly one sanitized JSON result. + console.error = () => {}; + const { parseAuthzedBackfillCommand } = await import("../lib/authzed/backfill-cli-command"); + const command = parseAuthzedBackfillCommand(process.argv.slice(2)); + if (!command) { + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + const { runAuthzedBackfillCli } = await import("../lib/authzed/backfill-cli"); + console.error = originalConsoleError; + process.exitCode = await runAuthzedBackfillCli(command); + } catch { + console.error = originalConsoleError; + writeResult(INVALID_CONFIGURATION_RESULT); + process.exitCode = 1; + } finally { + console.error = originalConsoleError; + } +}; + +void run(); diff --git a/apps/web/scripts/authzed-ci-outbox-worker-runner.test.ts b/apps/web/scripts/authzed-ci-outbox-worker-runner.test.ts new file mode 100644 index 000000000000..68ef07c7783c --- /dev/null +++ b/apps/web/scripts/authzed-ci-outbox-worker-runner.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test, vi } from "vitest"; +import { runAuthzedCiOutboxWorker } from "./authzed-ci-outbox-worker-runner"; + +describe("AuthZed CI outbox worker runner", () => { + test("recovers from an isolated unexpected failure", async () => { + let stopped = false; + const deliver = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("sensitive transport detail")) + .mockResolvedValueOnce(); + const heartbeat = vi.fn(async () => { + stopped = true; + }); + const onUnexpectedFailure = vi.fn(); + const wait = vi.fn<() => Promise>().mockResolvedValue(); + + await runAuthzedCiOutboxWorker({ + deliver, + heartbeat, + maxConsecutiveFailures: 3, + onUnexpectedFailure, + shouldStop: () => stopped, + wait, + }); + + expect(deliver).toHaveBeenCalledTimes(2); + expect(heartbeat).toHaveBeenCalledOnce(); + expect(onUnexpectedFailure).toHaveBeenCalledWith(1); + expect(wait).toHaveBeenCalledOnce(); + }); + + test("resets the consecutive-failure count after successful delivery", async () => { + let successfulDeliveries = 0; + const deliver = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("first transient failure")) + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error("second transient failure")) + .mockResolvedValueOnce(); + const onUnexpectedFailure = vi.fn(); + + await runAuthzedCiOutboxWorker({ + deliver, + heartbeat: async () => { + successfulDeliveries += 1; + }, + maxConsecutiveFailures: 2, + onUnexpectedFailure, + shouldStop: () => successfulDeliveries === 2, + wait: async () => undefined, + }); + + expect(onUnexpectedFailure.mock.calls).toEqual([[1], [1]]); + }); + + test("stops after the bounded consecutive-failure limit without exposing the cause", async () => { + const onUnexpectedFailure = vi.fn(); + let terminalError: unknown; + + try { + await runAuthzedCiOutboxWorker({ + deliver: async () => { + throw new Error("sensitive transport detail"); + }, + heartbeat: async () => undefined, + maxConsecutiveFailures: 3, + onUnexpectedFailure, + shouldStop: () => false, + wait: async () => undefined, + }); + } catch (error) { + terminalError = error; + } + + expect(terminalError).toBeInstanceOf(Error); + expect((terminalError as Error).message).toBe( + "AuthZed CI outbox delivery exceeded its consecutive-failure limit" + ); + expect(onUnexpectedFailure.mock.calls).toEqual([[1], [2], [3]]); + }); +}); diff --git a/apps/web/scripts/authzed-ci-outbox-worker-runner.ts b/apps/web/scripts/authzed-ci-outbox-worker-runner.ts new file mode 100644 index 000000000000..187cdb215923 --- /dev/null +++ b/apps/web/scripts/authzed-ci-outbox-worker-runner.ts @@ -0,0 +1,47 @@ +import "server-only"; + +type TAuthzedCiOutboxWorkerRunnerOptions = Readonly<{ + deliver: () => Promise; + heartbeat: () => Promise; + maxConsecutiveFailures: number; + onUnexpectedFailure: (consecutiveFailures: number) => void; + shouldStop: () => boolean; + wait: () => Promise; +}>; + +const AUTHZED_CI_OUTBOX_TERMINAL_ERROR = "AuthZed CI outbox delivery exceeded its consecutive-failure limit"; + +/** + * Keep the CI-only outbox processor alive through isolated infrastructure blips, but stop after a + * bounded run of unexpected failures so the workflow reports a broken delivery fixture instead of + * cascading into unrelated authorization denials. + */ +export const runAuthzedCiOutboxWorker = async ({ + deliver, + heartbeat, + maxConsecutiveFailures, + onUnexpectedFailure, + shouldStop, + wait, +}: TAuthzedCiOutboxWorkerRunnerOptions): Promise => { + let consecutiveFailures = 0; + + while (!shouldStop()) { + try { + await deliver(); + await heartbeat(); + consecutiveFailures = 0; + } catch { + consecutiveFailures += 1; + onUnexpectedFailure(consecutiveFailures); + + if (consecutiveFailures >= maxConsecutiveFailures) { + throw new Error(AUTHZED_CI_OUTBOX_TERMINAL_ERROR); + } + } + + if (!shouldStop()) { + await wait(); + } + } +}; diff --git a/apps/web/scripts/authzed-ci-outbox-worker.ts b/apps/web/scripts/authzed-ci-outbox-worker.ts new file mode 100644 index 000000000000..cfe5f4fdb35f --- /dev/null +++ b/apps/web/scripts/authzed-ci-outbox-worker.ts @@ -0,0 +1,48 @@ +import "server-only"; +import { writeFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { prisma } from "@formbricks/database"; +import { closeAuthzedClient } from "@/lib/authzed/client"; +import { processAuthzedProjectionDeliveryJob } from "@/lib/authzed/outbox-processor"; +import { runAuthzedCiOutboxWorker } from "./authzed-ci-outbox-worker-runner"; + +const DELIVERY_INTERVAL_MS = 100; +const MAX_CONSECUTIVE_FAILURES = 5; +const heartbeatPath = process.env.AUTHZED_CI_OUTBOX_HEARTBEAT_PATH; +let stopped = false; + +const stop = (): void => { + stopped = true; +}; + +const main = async (): Promise => { + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + + try { + if (!heartbeatPath) { + throw new Error("AuthZed CI outbox heartbeat path is required"); + } + + await runAuthzedCiOutboxWorker({ + deliver: processAuthzedProjectionDeliveryJob, + heartbeat: async () => writeFile(heartbeatPath, String(Date.now()), { mode: 0o600 }), + maxConsecutiveFailures: MAX_CONSECUTIVE_FAILURES, + onUnexpectedFailure: (consecutiveFailures) => { + process.stderr.write( + `AuthZed CI outbox delivery encountered an unexpected failure (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n` + ); + }, + shouldStop: () => stopped, + wait: async () => delay(DELIVERY_INTERVAL_MS), + }); + } catch { + process.stderr.write("AuthZed CI outbox delivery failed\n"); + process.exitCode = 1; + } finally { + closeAuthzedClient(); + await prisma.$disconnect(); + } +}; + +void main(); diff --git a/apps/web/scripts/authzed-entrypoints.test.ts b/apps/web/scripts/authzed-entrypoints.test.ts new file mode 100644 index 000000000000..64c7788c5cd1 --- /dev/null +++ b/apps/web/scripts/authzed-entrypoints.test.ts @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { INVALID_CONFIGURATION_RESULT, INVALID_REQUEST_RESULT } from "./authzed-schema-results"; + +const webRoot = fileURLToPath(new URL("../", import.meta.url)); +const tsxExecutable = fileURLToPath(new URL("../../../node_modules/.bin/tsx", import.meta.url)); + +const runEntrypoint = ( + script: string, + args: ReadonlyArray, + environment: Readonly> = {} +) => + spawnSync(tsxExecutable, ["--tsconfig", "tsconfig.json", script, ...args], { + cwd: webRoot, + encoding: "utf8", + env: { + ...process.env, + AUTHZED_ENABLED: "true", + AUTHZED_ENDPOINT: "invalid-endpoint", + AUTHZED_SYSTEM_KEY: "formbricks", + AUTHZED_TOKEN: "test-token", + LOG_LEVEL: "fatal", + NODE_OPTIONS: "--conditions=react-server", + ...environment, + }, + }); + +const expectSingleJsonFailure = ( + result: ReturnType, + expected: Readonly> +): void => { + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + + const outputLines = result.stdout.trimEnd().split("\n"); + expect(outputLines).toHaveLength(1); + expect(JSON.parse(outputLines[0])).toEqual(expected); +}; + +describe("AuthZed script entrypoints", () => { + test.each([ + { + args: ["invalid"], + expected: INVALID_REQUEST_RESULT, + name: "schema", + script: "scripts/authzed-schema.ts", + }, + { + args: ["--unknown"], + expected: INVALID_REQUEST_RESULT, + name: "backfill", + script: "scripts/authzed-backfill.ts", + }, + { + args: ["health", "--unknown"], + expected: { + code: "authzed_invalid_request", + latencyMs: 0, + retryable: false, + status: "unhealthy", + }, + name: "packaged health", + script: "scripts/docker/authzed-cli.ts", + }, + { + args: ["upgrade", "--unknown"], + expected: INVALID_REQUEST_RESULT, + name: "packaged upgrade", + script: "scripts/docker/authzed-cli.ts", + }, + ])("$name rejects invalid arguments with one sanitized JSON result", ({ args, expected, script }) => { + expectSingleJsonFailure(runEntrypoint(script, args), expected); + }); + + test.each([ + { + args: ["check"], + expected: INVALID_CONFIGURATION_RESULT, + name: "schema", + script: "scripts/authzed-schema.ts", + }, + { + args: [], + expected: INVALID_CONFIGURATION_RESULT, + name: "backfill", + script: "scripts/authzed-backfill.ts", + }, + { + args: ["health"], + expected: { + code: "authzed_internal", + latencyMs: 0, + retryable: false, + status: "unhealthy", + }, + name: "packaged health", + script: "scripts/docker/authzed-cli.ts", + }, + { + args: ["upgrade", "check"], + expected: INVALID_CONFIGURATION_RESULT, + name: "packaged upgrade", + script: "scripts/docker/authzed-cli.ts", + }, + ])("$name sanitizes runtime-loading failures", ({ args, expected, script }) => { + expectSingleJsonFailure(runEntrypoint(script, args), expected); + }); +}); diff --git a/apps/web/scripts/authzed-health.ts b/apps/web/scripts/authzed-health.ts new file mode 100644 index 000000000000..a7b92b04dc05 --- /dev/null +++ b/apps/web/scripts/authzed-health.ts @@ -0,0 +1,30 @@ +import "server-only"; + +const INVALID_CONFIGURATION_RESULT = { + code: "authzed_internal", + latencyMs: 0, + retryable: false, + status: "unhealthy", +} as const; + +const run = async (): Promise => { + const originalConsoleError = console.error; + + try { + // Environment validation logs details before throwing. Suppress that duplicate output here so this + // automation-oriented command always emits exactly one sanitized JSON result. + console.error = () => {}; + const { runAuthzedHealthCli } = await import("../lib/authzed/cli"); + console.error = originalConsoleError; + + process.exitCode = await runAuthzedHealthCli(); + } catch { + console.error = originalConsoleError; + process.stdout.write(`${JSON.stringify(INVALID_CONFIGURATION_RESULT)}\n`); + process.exitCode = 1; + } finally { + console.error = originalConsoleError; + } +}; + +void run(); diff --git a/apps/web/scripts/authzed-perf.ts b/apps/web/scripts/authzed-perf.ts new file mode 100644 index 000000000000..cadea9c6f422 --- /dev/null +++ b/apps/web/scripts/authzed-perf.ts @@ -0,0 +1,494 @@ +import { createWriteStream, mkdirSync, writeFileSync } from "node:fs"; +import type { WriteStream } from "node:fs"; +import { dirname } from "node:path"; +import { performance } from "node:perf_hooks"; +import { prisma } from "@formbricks/database"; +import { can } from "@/lib/authorization"; +import { withAuthorizationSurface } from "@/lib/authorization/context"; + +/** + * ENG-1739 — authorization performance at BI-like scale. + * + * Two phases, deliberately separate commands. AuthZed's load-testing guidance is explicit that the + * relationships a measurement reads must be seeded beforehand, not written during the run, or you + * measure your own writes and a cold cache instead of the workload. + * + * pnpm authzed:perf seed [--scale=small|default|large] + * pnpm authzed:perf run [--iterations=N] [--concurrency=N] [--log=path] + * pnpm authzed:perf clean + * + * `seed` fills Postgres only. Project the relationships into SpiceDB afterwards with the ENG-1718 + * tooling rather than duplicating relationship writes here: + * + * pnpm authzed:backfill --apply --scope=all + * + * `seed` is idempotent — it removes a previous seed first — and `clean` removes every row the seed + * created and nothing else. Both target only rows tagged with `SEED_TAG`, but note this writes tens + * of thousands of rows into whatever database `.env` points at, so point it at a throwaway one. + * + * `run` drives the real `can()` — real evaluator, real Prisma, real SpiceDB when enforcement is on — + * and writes one JSON object per sample to a log file plus a summary to stdout. + * + * WHAT THIS MEASURES, AND WHAT IT DOES NOT + * + * It measures the cost of an authorization decision per action and resource type. It does NOT measure + * how many decisions a page or endpoint makes — request amplification needs the per-request counter + * that ENG-1739's instrumentation change adds, because nothing counts checks per request today. The + * N+1 question ("does a 6k-survey workspace still issue O(1) checks?") is answered by that counter + * plus an assertion, not by this script. Read a green run here as "each check is affordable", never + * as "the list paths are fine". + * + * Latency numbers from a laptop are indicative only: local SpiceDB has no network in the path, and a + * shared machine is noisy. Percentiles belong in a report, not in a CI gate. + */ + +type TScale = "small" | "default" | "large"; + +type TScaleProfile = Readonly<{ + responsesOnHotSurvey: number; + surveysInHotWorkspace: number; + teams: number; + users: number; + workspaces: number; +}>; + +/** + * Sized per axis to the query each one stresses, not uniformly large. + * + * Surveys and responses grow Postgres and the scope resolvers; they do not grow the SpiceDB graph at + * all, because only organization, team, workspace and api_key are projected today — a survey is + * resolved to its owning workspace before any check. Users, teams and grants are what the graph is + * made of. Responses concentrate on ONE survey because export and analytics read a single survey's + * responses; a million spread thinly across six thousand surveys stresses nothing. + */ +const SCALE_PROFILES = { + small: { responsesOnHotSurvey: 2_000, surveysInHotWorkspace: 300, teams: 10, users: 50, workspaces: 5 }, + default: { + responsesOnHotSurvey: 50_000, + surveysInHotWorkspace: 6_000, + teams: 100, + users: 2_000, + workspaces: 50, + }, + large: { + responsesOnHotSurvey: 500_000, + surveysInHotWorkspace: 6_000, + teams: 200, + users: 5_000, + workspaces: 100, + }, +} as const satisfies Readonly>; + +const SEED_TAG = "eng1739-perf"; +const SEED_ORGANIZATION_NAME = `${SEED_TAG} org`; +const SEED_USER_EMAIL_PREFIX = `${SEED_TAG}-u`; +const DEFAULT_LOG_PATH = "authzed/perf-samples.jsonl"; +const LOG_EXTENSION = ".jsonl"; +const BATCH = 1_000; + +type TSample = Readonly<{ + action: string; + allowed: boolean | null; + durationMs: number; + error: string | null; + role: string; +}>; + +const parsePositiveSafeInteger = (name: string, value: string | undefined, fallback: number): number => { + const parsed = Number(value ?? fallback); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`--${name} must be a positive integer`); + } + return parsed; +}; + +const parseArgs = (argv: ReadonlyArray) => { + const flag = (name: string): string | undefined => + argv + .find((arg) => arg.startsWith(`--${name}=`)) + ?.split("=") + .slice(1) + .join("="); + + const scale = (flag("scale") ?? "default") as TScale; + if (!(scale in SCALE_PROFILES)) { + throw new Error(`Unknown --scale=${scale}. Use one of: ${Object.keys(SCALE_PROFILES).join(", ")}`); + } + + const logPath = flag("log") ?? DEFAULT_LOG_PATH; + if (!logPath.endsWith(LOG_EXTENSION)) { + // The run truncates this path before streaming to it. A mistyped `--log` should not be able to + // empty a source file, so require the extension the harness actually writes. + throw new Error(`--log must end in ${LOG_EXTENSION} (got "${logPath}"); the run truncates it.`); + } + + return { + command: argv.find((arg) => !arg.startsWith("--")) ?? "help", + concurrency: parsePositiveSafeInteger("concurrency", flag("concurrency"), 8), + iterations: parsePositiveSafeInteger("iterations", flag("iterations"), 2_000), + logPath, + scale, + }; +}; + +/** + * Remove everything a seed created, and nothing else. + * + * Deleting the organization cascades its workspaces (and their surveys and responses), teams, + * memberships and API keys. `User` rows are global rather than organization-owned, so they are + * removed separately by the seed's email prefix. + */ +const clean = async (): Promise<{ organizations: number; users: number }> => { + const { count: organizations } = await prisma.organization.deleteMany({ + where: { name: SEED_ORGANIZATION_NAME }, + }); + const { count: users } = await prisma.user.deleteMany({ + where: { email: { startsWith: SEED_USER_EMAIL_PREFIX } }, + }); + + return { organizations, users }; +}; + +const chunk = (items: ReadonlyArray, size: number): T[][] => { + const out: T[][] = []; + for (let index = 0; index < items.length; index += size) out.push(items.slice(index, index + size)); + return out; +}; + +/** The role mix for a seeded membership: a few owners/managers, a couple of billing, mostly members. */ +const roleForSeedIndex = (index: number): "owner" | "manager" | "billing" | "member" => { + if (index === 0) return "owner"; + if (index < 5) return "manager"; + if (index < 8) return "billing"; + return "member"; +}; + +const seed = async (scale: TScale): Promise => { + const profile = SCALE_PROFILES[scale]; + const startedAt = performance.now(); + console.log(`seeding scale=${scale}`, profile); + + // Idempotent by construction. The seed's user emails are unique-constrained, so without this a + // second `seed` dies partway with P2002 and leaves a half-populated organization behind — which + // `run` would then happily resolve and measure. + const removed = await clean(); + if (removed.organizations > 0 || removed.users > 0) { + console.log(" removed previous seed", removed); + } + + const organization = await prisma.organization.create({ data: { name: SEED_ORGANIZATION_NAME } }); + + // Users and memberships. The role mix mirrors a real tenant: a few owners/managers, mostly members + // whose access arrives through teams — which is the interesting path, since owners short-circuit. + const users = []; + for (const batch of chunk([...new Array(profile.users).keys()], BATCH)) { + const created = await prisma.$transaction( + batch.map((index) => + prisma.user.create({ + data: { name: `${SEED_TAG}-u${index}`, email: `${SEED_TAG}-u${index}@perf.test` }, + select: { id: true }, + }) + ) + ); + users.push(...created); + console.log(` users ${users.length}/${profile.users}`); + } + + await prisma.membership.createMany({ + data: users.map((user, index) => ({ + userId: user.id, + organizationId: organization.id, + accepted: true, + role: roleForSeedIndex(index), + })), + }); + + const teams = await prisma.$transaction( + [...new Array(profile.teams).keys()].map((index) => + prisma.team.create({ + data: { name: `${SEED_TAG}-team${index}`, organizationId: organization.id }, + select: { id: true }, + }) + ) + ); + + // Every member joins two teams: enough fan-out that a check has to walk, not so much that the + // seed dominates the run. + await prisma.teamUser.createMany({ + data: users.flatMap((user, index) => [ + { teamId: teams[index % teams.length].id, userId: user.id, role: "contributor" as const }, + { teamId: teams[(index + 1) % teams.length].id, userId: user.id, role: "contributor" as const }, + ]), + skipDuplicates: true, + }); + + const workspaces = await prisma.$transaction( + [...new Array(profile.workspaces).keys()].map((index) => + prisma.workspace.create({ + data: { name: `${SEED_TAG}-ws${index}`, organizationId: organization.id }, + select: { id: true }, + }) + ) + ); + + await prisma.workspaceTeam.createMany({ + data: teams.flatMap((team, index) => [ + { + teamId: team.id, + workspaceId: workspaces[index % workspaces.length].id, + permission: index % 3 === 0 ? ("manage" as const) : ("readWrite" as const), + }, + ]), + skipDuplicates: true, + }); + + // The hot workspace: the one the ticket describes, 5–6k surveys. + const hotWorkspace = workspaces[0]; + for (const batch of chunk([...new Array(profile.surveysInHotWorkspace).keys()], BATCH)) { + await prisma.survey.createMany({ + data: batch.map((index) => ({ + name: `${SEED_TAG}-survey${index}`, + workspaceId: hotWorkspace.id, + status: "inProgress" as const, + type: "link" as const, + })), + }); + console.log( + ` surveys ${Math.min(batch.at(-1)! + 1, profile.surveysInHotWorkspace)}/${profile.surveysInHotWorkspace}` + ); + } + + const hotSurvey = await prisma.survey.findFirst({ + where: { workspaceId: hotWorkspace.id }, + select: { id: true }, + }); + + if (hotSurvey) { + for (const batch of chunk([...new Array(profile.responsesOnHotSurvey).keys()], BATCH)) { + await prisma.response.createMany({ + data: batch.map(() => ({ surveyId: hotSurvey.id, finished: true, data: {}, meta: {} })), + }); + console.log(` responses ${batch.at(-1)! + 1}/${profile.responsesOnHotSurvey}`); + } + } + + console.log( + JSON.stringify({ + durationSeconds: Math.round((performance.now() - startedAt) / 1000), + hotSurveyId: hotSurvey?.id ?? null, + hotWorkspaceId: hotWorkspace.id, + organizationId: organization.id, + phase: "seed", + scale, + teams: teams.length, + users: users.length, + workspaces: workspaces.length, + }) + ); + console.log("\nNext: project the relationships, then measure:"); + console.log(" pnpm authzed:backfill --apply --scope=all"); + console.log(" pnpm authzed:perf run --iterations=2000"); + console.log("\nWhen you are done, remove every row this created:"); + console.log(" pnpm authzed:perf clean"); +}; + +const percentile = (sorted: ReadonlyArray, fraction: number): number => + sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; + +const run = async (iterations: number, concurrency: number, logPath: string): Promise => { + const organization = await prisma.organization.findFirst({ + where: { name: SEED_ORGANIZATION_NAME }, + select: { id: true }, + }); + + if (!organization) { + console.error("No seeded data found. Run: pnpm authzed:perf seed"); + return 1; + } + + const [workspace, survey, memberships] = await Promise.all([ + prisma.workspace.findFirst({ where: { organizationId: organization.id }, select: { id: true } }), + prisma.survey.findFirst({ + where: { workspace: { organizationId: organization.id } }, + select: { id: true }, + }), + // A subset of users, per AuthZed's guidance: checking every user flat produces an unrealistically + // cold cache. This is the "largest number online at once" cohort. + prisma.membership.findMany({ + where: { organizationId: organization.id }, + select: { userId: true, role: true }, + take: 200, + orderBy: { userId: "asc" }, + }), + ]); + + if (!workspace || !survey) { + console.error("Seeded organization has no workspace or survey; re-run seed."); + return 1; + } + + if (memberships.length === 0) { + // Every sample picks a principal out of this list. Empty means the seed was interrupted, and + // letting it through would produce a full run of identical TypeErrors rather than a measurement. + console.error("Seeded organization has no memberships; re-run seed."); + return 1; + } + + // Weighted toward positive checks: negative checks walk every branch of the graph looking for an + // answer that is not there, so a negative-heavy mix measures a workload nobody runs. + const cases = [ + { action: "organization.read", resource: { type: "organization", id: organization.id } }, + { action: "organization.manage", resource: { type: "organization", id: organization.id } }, + { action: "workspace.read", resource: { type: "workspace", id: workspace.id } }, + { action: "workspace.write", resource: { type: "workspace", id: workspace.id } }, + { action: "survey.read", resource: { type: "survey", id: survey.id } }, + { action: "survey.response_export", resource: { type: "survey", id: survey.id } }, + ] as const; + + mkdirSync(dirname(logPath), { recursive: true }); + writeFileSync(logPath, ""); + + const logStream: WriteStream = createWriteStream(logPath, { flags: "a" }); + const samples: TSample[] = []; + + const runOne = + (collect: boolean): ((index: number) => Promise) => + async (index: number): Promise => { + const membership = memberships[index % memberships.length]; + const testCase = cases[index % cases.length]; + const begunAt = performance.now(); + let allowed: boolean | null = null; + let error: string | null = null; + + try { + allowed = await can({ type: "user", id: membership.userId }, testCase.action, testCase.resource); + } catch (error_) { + error = error_ instanceof Error ? error_.name : "unknown"; + } + + const sample: TSample = { + action: testCase.action, + allowed, + durationMs: performance.now() - begunAt, + error, + role: membership.role, + }; + + if (collect) { + samples.push(sample); + logStream.write(`${JSON.stringify(sample)}\n`); + } + }; + + // Warmup: cold gRPC channel, DB connection pool, and SpiceDB cache all bias the first samples. + // Running a throwaway batch before the timed phase means the reported percentiles reflect a warm + // path, not one-time startup costs. + // Concurrency is fixed at 8 regardless of the user's --concurrency flag — this is a throwaway + // phase that only needs to touch every codepath once, not stress the system. + const WARMUP_ITERATIONS = 100; + const WARMUP_CONCURRENCY = 8; + await withAuthorizationSurface("server_action", async () => { + for (const batch of chunk([...new Array(WARMUP_ITERATIONS).keys()], WARMUP_CONCURRENCY)) { + await Promise.all(batch.map(runOne(false))); + } + }); + + // Keep the harness inside the same bounded request surface used by production actions so latency + // and checks-per-request telemetry carry representative attributes. + const startedAt = performance.now(); + await withAuthorizationSurface("server_action", async () => { + for (const batch of chunk([...new Array(iterations).keys()], concurrency)) { + await Promise.all(batch.map(runOne(true))); + } + }); + + logStream.end(); + + if (samples.length === 0) { + // Asserted rather than inferred: with no samples every rate below is 0/0, `JSON.stringify` + // renders NaN as null, and `errorRate > 0` is false — so the run would report measuring nothing + // and still exit 0. A measurement tool must not have a green path that measured nothing. + console.error("No samples were collected; nothing was measured."); + return 1; + } + + const wallSeconds = (performance.now() - startedAt) / 1000; + const byAction = new Map(); + for (const sample of samples) { + // Push into the existing array rather than rebuilding it: spreading copies every duration + // recorded so far on each sample, which is quadratic in samples-per-action. + const durations = byAction.get(sample.action); + if (durations) durations.push(sample.durationMs); + else byAction.set(sample.action, [sample.durationMs]); + } + + const report = { + actions: Object.fromEntries( + [...byAction.entries()].map(([action, durations]) => { + const sorted = [...durations].sort((a, b) => a - b); + return [ + action, + { + count: sorted.length, + p50Ms: Number(percentile(sorted, 0.5).toFixed(2)), + p95Ms: Number(percentile(sorted, 0.95).toFixed(2)), + p99Ms: Number(percentile(sorted, 0.99).toFixed(2)), + }, + ]; + }) + ), + allowRate: Number((samples.filter((s) => s.allowed === true).length / samples.length).toFixed(3)), + concurrency, + errorRate: Number((samples.filter((s) => s.error !== null).length / samples.length).toFixed(4)), + iterations: samples.length, + logPath, + phase: "run", + throughputPerSecond: Number((samples.length / wallSeconds).toFixed(1)), + wallSeconds: Number(wallSeconds.toFixed(1)), + }; + + console.log(JSON.stringify(report, null, 2)); + console.log( + "\nRequest amplification and the list-path N+1 question are NOT answered here — they need the", + "\nper-request check counter. This says each decision is affordable, not that a page makes few." + ); + + return report.errorRate > 0 ? 2 : 0; +}; + +const main = async (): Promise => { + const args = parseArgs(process.argv.slice(2)); + + if (args.command === "seed") { + await seed(args.scale); + return; + } + + if (args.command === "run") { + process.exitCode = await run(args.iterations, args.concurrency, args.logPath); + return; + } + + if (args.command === "clean") { + console.log("removed", await clean()); + return; + } + + console.log(`Usage: + pnpm authzed:perf seed [--scale=small|default|large] + pnpm authzed:perf run [--iterations=2000] [--concurrency=8] [--log=authzed/perf-samples.jsonl] + pnpm authzed:perf clean + +\`seed\` is idempotent — it removes a previous seed before writing a new one. \`clean\` removes +every row the seed created and nothing else. Between seed and run, project the seeded rows +into SpiceDB: + pnpm authzed:backfill --apply --scope=all`); + process.exitCode = 1; +}; + +main() + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/web/scripts/authzed-relationships-smoke.ts b/apps/web/scripts/authzed-relationships-smoke.ts new file mode 100644 index 000000000000..5788881e650d --- /dev/null +++ b/apps/web/scripts/authzed-relationships-smoke.ts @@ -0,0 +1,654 @@ +import "server-only"; +import type { TAuthzedRelationshipUpdate, TAuthzedResourceLookupClient } from "../lib/authzed/client"; +import { getFeedbackDirectoryAssignmentObjectId } from "../lib/authzed/feedback-directory-assignment-id"; + +const ORGANIZATION_ID = "application-relationship-smoke"; +const USER_ID = "application-relationship-smoke"; +const ORGANIZATION_RELATIONS = ["billing", "manager", "member", "owner"] as const; +const GRAPH_ORGANIZATION_ID = "application-graph-smoke"; +const GRAPH_WORKSPACE_ID = "application-graph-smoke"; +const LOOKUP_SECONDARY_WORKSPACE_ID = "application-graph-lookup-secondary"; +const READER_TEAM_ID = "application-graph-reader"; +const MANAGER_TEAM_ID = "application-graph-manager"; +const ALICE_ID = "application-graph-alice"; +const BOB_ID = "application-graph-bob"; +const TEAM_RELATIONS = ["admin", "contributor"] as const; +const WORKSPACE_TEAM_RELATIONS = ["manager_team", "reader_team", "writer_team"] as const; +const API_KEY_ORGANIZATION_ID = "application-api-key-organization"; +const PRIMARY_API_KEY_WORKSPACE_ID = "application-api-key-primary"; +const SECONDARY_API_KEY_WORKSPACE_ID = "application-api-key-secondary"; +const READER_API_KEY_ID = "application-api-key-reader"; +const WRITER_API_KEY_ID = "application-api-key-writer"; +const MANAGER_API_KEY_ID = "application-api-key-manager"; +const COMBINED_ACCESS_API_KEY_ID = "application-api-key-combined-access"; +const API_KEY_ORGANIZATION_RELATIONS = ["api_key_reader", "api_key_writer"] as const; +const API_KEY_WORKSPACE_RELATIONS = ["manager", "reader", "writer"] as const; +const FEEDBACK_ORGANIZATION_ID = "application-feedback-organization"; +const FEEDBACK_DIRECTORY_ID = "application-feedback-directory"; +const FEEDBACK_WORKSPACE_A_ID = "application-feedback-workspace-a"; +const FEEDBACK_WORKSPACE_B_ID = "application-feedback-workspace-b"; +const FEEDBACK_TEAM_ID = "application-feedback-team"; +const FEEDBACK_USER_ID = "application-feedback-user"; +const FEEDBACK_MANAGER_ID = "application-feedback-manager"; +const FEEDBACK_API_KEY_ID = "application-feedback-api-key"; + +type TSmokeCommand = + | "check-api-key-allow" + | "check-api-key-deny" + | "check-user-allow" + | "check-user-deny" + | "check-feedback" + | "lookup-api-key-workspaces" + | "lookup-empty-workspaces" + | "lookup-user-workspaces" + | "delete" + | "delete-api-key" + | "delete-manager-team" + | "delete-workspace" + | "delete-feedback-assignment-a" + | "delete-feedback-directory" + | "downgrade-feedback-api-key" + | "downgrade-api-key-manager" + | "downgrade-manager-grant" + | "remove-alice-memberships" + | "remove-api-key-scope" + | "remove-reader-grant" + | "remove-feedback-user-membership" + | "seed-api-key" + | "seed-feedback-directory" + | "seed-team-workspace" + | "set-billing" + | "set-owner"; + +const writeResult = (result: object): void => { + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +const isSmokeCommand = (value: string | undefined): value is TSmokeCommand => + value === "check-api-key-allow" || + value === "check-api-key-deny" || + value === "check-user-allow" || + value === "check-user-deny" || + value === "check-feedback" || + value === "lookup-api-key-workspaces" || + value === "lookup-empty-workspaces" || + value === "lookup-user-workspaces" || + value === "delete" || + value === "delete-api-key" || + value === "delete-manager-team" || + value === "delete-workspace" || + value === "delete-feedback-assignment-a" || + value === "delete-feedback-directory" || + value === "downgrade-feedback-api-key" || + value === "downgrade-api-key-manager" || + value === "downgrade-manager-grant" || + value === "remove-alice-memberships" || + value === "remove-api-key-scope" || + value === "remove-reader-grant" || + value === "remove-feedback-user-membership" || + value === "seed-api-key" || + value === "seed-feedback-directory" || + value === "seed-team-workspace" || + value === "set-billing" || + value === "set-owner"; + +const createTeamRoleUpdates = ( + teamId: string, + userId: string, + selectedRelation?: (typeof TEAM_RELATIONS)[number] +): ReadonlyArray => + TEAM_RELATIONS.map((relation) => ({ + operation: relation === selectedRelation ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: teamId, objectType: "team" }, + subject: { objectId: userId, objectType: "user" }, + }, + })); + +const createWorkspaceGrantUpdates = ( + teamId: string, + selectedRelation?: (typeof WORKSPACE_TEAM_RELATIONS)[number] +): ReadonlyArray => + WORKSPACE_TEAM_RELATIONS.map((relation) => ({ + operation: relation === selectedRelation ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: GRAPH_WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: teamId, objectType: "team", relation: "member" }, + }, + })); + +const createApiKeyOrganizationAccessUpdates = ( + apiKeyId: string, + selectedRelations: ReadonlyArray<(typeof API_KEY_ORGANIZATION_RELATIONS)[number]> = [] +): ReadonlyArray => + API_KEY_ORGANIZATION_RELATIONS.map((relation) => ({ + operation: selectedRelations.includes(relation) ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: API_KEY_ORGANIZATION_ID, objectType: "organization" }, + subject: { objectId: apiKeyId, objectType: "api_key" }, + }, + })); + +const createApiKeyWorkspaceUpdates = ( + apiKeyId: string, + workspaceId: string, + selectedRelation?: (typeof API_KEY_WORKSPACE_RELATIONS)[number] +): ReadonlyArray => + API_KEY_WORKSPACE_RELATIONS.map((relation) => ({ + operation: relation === selectedRelation ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: apiKeyId, objectType: "api_key" }, + }, + })); + +const writeOrganizationProjection = async ( + client: TAuthzedResourceLookupClient, + command: "delete" | "set-billing" | "set-owner" +): Promise => { + const selectedRelations = { + delete: undefined, + "set-billing": "billing", + "set-owner": "owner", + } as const; + + await client.writeRelationships( + ORGANIZATION_RELATIONS.map((relation) => ({ + operation: selectedRelations[command] === relation ? "touch" : "delete", + relationship: { + relation, + resource: { objectId: ORGANIZATION_ID, objectType: "organization" }, + subject: { objectId: USER_ID, objectType: "user" }, + }, + })) + ); +}; + +const seedTeamWorkspaceProjection = async (client: TAuthzedResourceLookupClient): Promise => { + await client.writeRelationships([ + ...[ALICE_ID, BOB_ID].map((userId) => ({ + operation: "touch" as const, + relationship: { + relation: "member", + resource: { objectId: GRAPH_ORGANIZATION_ID, objectType: "organization" }, + subject: { objectId: userId, objectType: "user" }, + }, + })), + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: READER_TEAM_ID, objectType: "team" }, + subject: { objectId: GRAPH_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: MANAGER_TEAM_ID, objectType: "team" }, + subject: { objectId: GRAPH_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: GRAPH_WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: GRAPH_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: LOOKUP_SECONDARY_WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: GRAPH_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + ...createTeamRoleUpdates(READER_TEAM_ID, ALICE_ID, "contributor"), + ...createTeamRoleUpdates(MANAGER_TEAM_ID, ALICE_ID, "admin"), + ...createTeamRoleUpdates(READER_TEAM_ID, BOB_ID, "contributor"), + ...createWorkspaceGrantUpdates(READER_TEAM_ID, "reader_team"), + ...createWorkspaceGrantUpdates(MANAGER_TEAM_ID, "manager_team"), + { + operation: "touch", + relationship: { + relation: "reader_team", + resource: { objectId: LOOKUP_SECONDARY_WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: READER_TEAM_ID, objectType: "team", relation: "member" }, + }, + }, + ]); +}; + +const deleteManagerTeamProjection = async (client: TAuthzedResourceLookupClient): Promise => { + await client.deleteRelationships({ + resourceId: MANAGER_TEAM_ID, + resourceType: "team", + }); + await client.deleteRelationships({ + resourceType: "workspace", + subject: { objectId: MANAGER_TEAM_ID, objectType: "team", relation: "member" }, + }); +}; + +const seedApiKeyProjection = async (client: TAuthzedResourceLookupClient): Promise => { + await client.writeRelationships([ + ...[READER_API_KEY_ID, WRITER_API_KEY_ID, MANAGER_API_KEY_ID, COMBINED_ACCESS_API_KEY_ID].map( + (apiKeyId) => ({ + operation: "touch" as const, + relationship: { + relation: "organization", + resource: { objectId: apiKeyId, objectType: "api_key" }, + subject: { objectId: API_KEY_ORGANIZATION_ID, objectType: "organization" }, + }, + }) + ), + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: PRIMARY_API_KEY_WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: API_KEY_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: SECONDARY_API_KEY_WORKSPACE_ID, objectType: "workspace" }, + subject: { objectId: API_KEY_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + ...createApiKeyOrganizationAccessUpdates(READER_API_KEY_ID, ["api_key_reader"]), + ...createApiKeyOrganizationAccessUpdates(WRITER_API_KEY_ID, ["api_key_writer"]), + ...createApiKeyOrganizationAccessUpdates(MANAGER_API_KEY_ID), + ...createApiKeyOrganizationAccessUpdates(COMBINED_ACCESS_API_KEY_ID, [ + "api_key_reader", + "api_key_writer", + ]), + ...createApiKeyWorkspaceUpdates(READER_API_KEY_ID, PRIMARY_API_KEY_WORKSPACE_ID, "reader"), + ...createApiKeyWorkspaceUpdates(WRITER_API_KEY_ID, PRIMARY_API_KEY_WORKSPACE_ID, "writer"), + ...createApiKeyWorkspaceUpdates(MANAGER_API_KEY_ID, PRIMARY_API_KEY_WORKSPACE_ID, "manager"), + ...createApiKeyWorkspaceUpdates(MANAGER_API_KEY_ID, SECONDARY_API_KEY_WORKSPACE_ID, "reader"), + ]); +}; + +const deleteWriterApiKeyProjection = async (client: TAuthzedResourceLookupClient): Promise => { + await client.deleteRelationships({ + resourceId: WRITER_API_KEY_ID, + resourceType: "api_key", + }); + await client.deleteRelationships({ + resourceType: "organization", + subject: { objectId: WRITER_API_KEY_ID, objectType: "api_key" }, + }); + await client.deleteRelationships({ + resourceType: "workspace", + subject: { objectId: WRITER_API_KEY_ID, objectType: "api_key" }, + }); +}; + +const feedbackAssignmentUpdates = ( + workspaceId: string, + operation: "delete" | "touch" +): ReadonlyArray => { + const assignmentId = getFeedbackDirectoryAssignmentObjectId(FEEDBACK_DIRECTORY_ID, workspaceId); + return [ + { + operation, + relationship: { + relation: "assignment", + resource: { objectId: FEEDBACK_DIRECTORY_ID, objectType: "feedback_directory" }, + subject: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + }, + }, + { + operation, + relationship: { + relation: "directory", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: FEEDBACK_DIRECTORY_ID, objectType: "feedback_directory" }, + }, + }, + { + operation, + relationship: { + relation: "workspace", + resource: { objectId: assignmentId, objectType: "feedback_directory_assignment" }, + subject: { objectId: workspaceId, objectType: "workspace" }, + }, + }, + ]; +}; + +const seedFeedbackDirectoryProjection = async (client: TAuthzedResourceLookupClient): Promise => { + await client.writeRelationships([ + { + operation: "touch", + relationship: { + relation: "manager", + resource: { objectId: FEEDBACK_ORGANIZATION_ID, objectType: "organization" }, + subject: { objectId: FEEDBACK_MANAGER_ID, objectType: "user" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "member", + resource: { objectId: FEEDBACK_ORGANIZATION_ID, objectType: "organization" }, + subject: { objectId: FEEDBACK_USER_ID, objectType: "user" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: FEEDBACK_TEAM_ID, objectType: "team" }, + subject: { objectId: FEEDBACK_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + ...[FEEDBACK_WORKSPACE_A_ID, FEEDBACK_WORKSPACE_B_ID].map((workspaceId) => ({ + operation: "touch" as const, + relationship: { + relation: "organization", + resource: { objectId: workspaceId, objectType: "workspace" }, + subject: { objectId: FEEDBACK_ORGANIZATION_ID, objectType: "organization" }, + }, + })), + { + operation: "touch", + relationship: { + relation: "contributor", + resource: { objectId: FEEDBACK_TEAM_ID, objectType: "team" }, + subject: { objectId: FEEDBACK_USER_ID, objectType: "user" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "reader_team", + resource: { objectId: FEEDBACK_WORKSPACE_A_ID, objectType: "workspace" }, + subject: { objectId: FEEDBACK_TEAM_ID, objectType: "team", relation: "member" }, + }, + }, + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: FEEDBACK_API_KEY_ID, objectType: "api_key" }, + subject: { objectId: FEEDBACK_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + ...createApiKeyWorkspaceUpdates(FEEDBACK_API_KEY_ID, FEEDBACK_WORKSPACE_B_ID, "writer"), + { + operation: "touch", + relationship: { + relation: "organization", + resource: { objectId: FEEDBACK_DIRECTORY_ID, objectType: "feedback_directory" }, + subject: { objectId: FEEDBACK_ORGANIZATION_ID, objectType: "organization" }, + }, + }, + ...feedbackAssignmentUpdates(FEEDBACK_WORKSPACE_A_ID, "touch"), + ...feedbackAssignmentUpdates(FEEDBACK_WORKSPACE_B_ID, "touch"), + ]); +}; + +const checkFeedbackDirectoryProjection = async (client: TAuthzedResourceLookupClient) => { + const check = ( + permission: string, + resourceType: string, + resourceId: string, + subjectType: string, + subjectId: string + ) => + client.checkPermission({ + permission, + resource: { objectId: resourceId, objectType: resourceType }, + subject: { objectId: subjectId, objectType: subjectType }, + }); + const assignmentA = getFeedbackDirectoryAssignmentObjectId(FEEDBACK_DIRECTORY_ID, FEEDBACK_WORKSPACE_A_ID); + const assignmentB = getFeedbackDirectoryAssignmentObjectId(FEEDBACK_DIRECTORY_ID, FEEDBACK_WORKSPACE_B_ID); + const [ + managerManage, + userRead, + userWrite, + userAssignmentARead, + userAssignmentBRead, + keyWrite, + keyAssignmentAWrite, + keyAssignmentBWrite, + ] = await Promise.all([ + check("manage", "feedback_directory", FEEDBACK_DIRECTORY_ID, "user", FEEDBACK_MANAGER_ID), + check("read", "feedback_directory", FEEDBACK_DIRECTORY_ID, "user", FEEDBACK_USER_ID), + check("write", "feedback_directory", FEEDBACK_DIRECTORY_ID, "user", FEEDBACK_USER_ID), + check("read", "feedback_directory_assignment", assignmentA, "user", FEEDBACK_USER_ID), + check("read", "feedback_directory_assignment", assignmentB, "user", FEEDBACK_USER_ID), + check("write", "feedback_directory", FEEDBACK_DIRECTORY_ID, "api_key", FEEDBACK_API_KEY_ID), + check("write", "feedback_directory_assignment", assignmentA, "api_key", FEEDBACK_API_KEY_ID), + check("write", "feedback_directory_assignment", assignmentB, "api_key", FEEDBACK_API_KEY_ID), + ]); + + return { + keyAssignmentAWrite: keyAssignmentAWrite.allowed, + keyAssignmentBWrite: keyAssignmentBWrite.allowed, + keyWrite: keyWrite.allowed, + managerManage: managerManage.allowed, + status: "checked" as const, + userAssignmentARead: userAssignmentARead.allowed, + userAssignmentBRead: userAssignmentBRead.allowed, + userRead: userRead.allowed, + userWrite: userWrite.allowed, + }; +}; + +type TSmokeResult = + | Readonly<{ status: "projected" }> + | Readonly<{ allowed: boolean; status: "checked" }> + | Readonly<{ resourceCount: number; status: "looked_up" }> + | Awaited>; + +const executeSmokeCommand = async ( + client: TAuthzedResourceLookupClient, + command: TSmokeCommand +): Promise => { + switch (command) { + case "check-user-allow": + case "check-user-deny": + return { + ...(await client.checkPermission({ + permission: "manage", + resource: { objectId: GRAPH_WORKSPACE_ID, objectType: "workspace" }, + subject: { + objectId: command === "check-user-allow" ? ALICE_ID : BOB_ID, + objectType: "user", + }, + })), + status: "checked", + }; + case "check-api-key-allow": + case "check-api-key-deny": + return { + ...(await client.checkPermission({ + permission: "manage_access", + resource: { objectId: API_KEY_ORGANIZATION_ID, objectType: "organization" }, + subject: { + objectId: command === "check-api-key-allow" ? WRITER_API_KEY_ID : READER_API_KEY_ID, + objectType: "api_key", + }, + })), + status: "checked", + }; + case "check-feedback": + return checkFeedbackDirectoryProjection(client); + case "lookup-user-workspaces": + case "lookup-api-key-workspaces": + case "lookup-empty-workspaces": { + const subject = { + "lookup-api-key-workspaces": { objectId: MANAGER_API_KEY_ID, objectType: "api_key" }, + "lookup-empty-workspaces": { objectId: "application-lookup-empty", objectType: "user" }, + "lookup-user-workspaces": { objectId: ALICE_ID, objectType: "user" }, + } as const; + const result = await client.lookupResources({ + permission: "read", + resourceType: "workspace", + subject: subject[command], + }); + return { resourceCount: result.resourceIds.length, status: "looked_up" }; + } + case "delete": + case "set-billing": + case "set-owner": + await writeOrganizationProjection(client, command); + return { status: "projected" }; + case "seed-team-workspace": + await seedTeamWorkspaceProjection(client); + return { status: "projected" }; + case "seed-api-key": + await seedApiKeyProjection(client); + return { status: "projected" }; + case "seed-feedback-directory": + await seedFeedbackDirectoryProjection(client); + return { status: "projected" }; + case "downgrade-feedback-api-key": + await client.writeRelationships( + createApiKeyWorkspaceUpdates(FEEDBACK_API_KEY_ID, FEEDBACK_WORKSPACE_B_ID, "reader") + ); + return { status: "projected" }; + case "remove-feedback-user-membership": + await client.writeRelationships(createTeamRoleUpdates(FEEDBACK_TEAM_ID, FEEDBACK_USER_ID)); + return { status: "projected" }; + case "delete-feedback-assignment-a": + await client.writeRelationships(feedbackAssignmentUpdates(FEEDBACK_WORKSPACE_A_ID, "delete")); + return { status: "projected" }; + case "delete-feedback-directory": + await client.deleteRelationships({ + resourceId: FEEDBACK_DIRECTORY_ID, + resourceType: "feedback_directory", + }); + for (const workspaceId of [FEEDBACK_WORKSPACE_A_ID, FEEDBACK_WORKSPACE_B_ID]) { + await client.deleteRelationships({ + resourceId: getFeedbackDirectoryAssignmentObjectId(FEEDBACK_DIRECTORY_ID, workspaceId), + resourceType: "feedback_directory_assignment", + }); + } + return { status: "projected" }; + case "downgrade-api-key-manager": + await client.writeRelationships( + createApiKeyWorkspaceUpdates(MANAGER_API_KEY_ID, PRIMARY_API_KEY_WORKSPACE_ID, "writer") + ); + return { status: "projected" }; + case "remove-api-key-scope": + await client.writeRelationships( + createApiKeyWorkspaceUpdates(MANAGER_API_KEY_ID, SECONDARY_API_KEY_WORKSPACE_ID) + ); + return { status: "projected" }; + case "delete-api-key": + await deleteWriterApiKeyProjection(client); + return { status: "projected" }; + case "downgrade-manager-grant": + await client.writeRelationships(createWorkspaceGrantUpdates(MANAGER_TEAM_ID, "reader_team")); + return { status: "projected" }; + case "remove-reader-grant": + await client.writeRelationships(createWorkspaceGrantUpdates(READER_TEAM_ID)); + return { status: "projected" }; + case "remove-alice-memberships": + await client.writeRelationships([ + ...createTeamRoleUpdates(READER_TEAM_ID, ALICE_ID), + ...createTeamRoleUpdates(MANAGER_TEAM_ID, ALICE_ID), + ]); + return { status: "projected" }; + case "delete-manager-team": + await deleteManagerTeamProjection(client); + return { status: "projected" }; + case "delete-workspace": + await client.deleteRelationships({ + resourceId: GRAPH_WORKSPACE_ID, + resourceType: "workspace", + }); + await client.deleteRelationships({ + resourceId: LOOKUP_SECONDARY_WORKSPACE_ID, + resourceType: "workspace", + }); + await client.deleteRelationships({ + resourceType: "team", + subject: { + objectId: GRAPH_ORGANIZATION_ID, + objectType: "organization", + }, + }); + await client.deleteRelationships({ + resourceId: GRAPH_ORGANIZATION_ID, + resourceType: "organization", + }); + return { status: "projected" }; + } +}; + +const run = async (): Promise => { + const startedAt = performance.now(); + const latencyMs = (): number => Math.max(0, Math.round(performance.now() - startedAt)); + + if (process.env.NODE_ENV !== "test") { + writeResult({ + code: "authzed_smoke_refused", + latencyMs: latencyMs(), + retryable: false, + status: "failed", + }); + process.exitCode = 1; + return; + } + + const command = process.argv[2]; + if (!isSmokeCommand(command)) { + writeResult({ + code: "authzed_invalid_request", + latencyMs: latencyMs(), + retryable: false, + status: "failed", + }); + process.exitCode = 1; + return; + } + + let closeClient: (() => void) | undefined; + + try { + const { closeAuthzedClient, getAuthzedClient } = await import("../lib/authzed/client"); + closeClient = closeAuthzedClient; + const result = await executeSmokeCommand(getAuthzedClient(), command); + + writeResult({ ...result, latencyMs: latencyMs() }); + process.exitCode = 0; + } catch (error) { + const { AuthzedError } = await import("../lib/authzed/errors"); + + if (error instanceof AuthzedError) { + writeResult({ + attempts: error.attempts, + code: error.code, + latencyMs: latencyMs(), + retryable: error.retryable, + status: "failed", + }); + } else { + writeResult({ + code: "authzed_internal", + latencyMs: latencyMs(), + retryable: false, + status: "failed", + }); + } + process.exitCode = 1; + } finally { + closeClient?.(); + } +}; + +void run(); diff --git a/apps/web/scripts/authzed-schema-results.ts b/apps/web/scripts/authzed-schema-results.ts new file mode 100644 index 000000000000..24f36a1ac60f --- /dev/null +++ b/apps/web/scripts/authzed-schema-results.ts @@ -0,0 +1,11 @@ +export const INVALID_REQUEST_RESULT = { + code: "authzed_invalid_request", + retryable: false, + status: "failed", +} as const; + +export const INVALID_CONFIGURATION_RESULT = { + code: "authzed_internal", + retryable: false, + status: "failed", +} as const; diff --git a/apps/web/scripts/authzed-schema.test.ts b/apps/web/scripts/authzed-schema.test.ts new file mode 100644 index 000000000000..1f1c9b3c4a5f --- /dev/null +++ b/apps/web/scripts/authzed-schema.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; +import { AUTHZED_ERROR_CODES } from "../lib/authzed/errors"; +import { INVALID_CONFIGURATION_RESULT, INVALID_REQUEST_RESULT } from "./authzed-schema-results"; + +describe("authzed schema script results", () => { + test("keeps the argument error code aligned with the AuthZed error contract", () => { + expect(INVALID_REQUEST_RESULT.code).toBe(AUTHZED_ERROR_CODES.INVALID_REQUEST); + }); + + test("keeps the configuration error code aligned with the AuthZed error contract", () => { + expect(INVALID_CONFIGURATION_RESULT.code).toBe(AUTHZED_ERROR_CODES.INTERNAL); + }); +}); diff --git a/apps/web/scripts/authzed-schema.ts b/apps/web/scripts/authzed-schema.ts new file mode 100644 index 000000000000..a88fe1994b4a --- /dev/null +++ b/apps/web/scripts/authzed-schema.ts @@ -0,0 +1,37 @@ +import "server-only"; +import { INVALID_CONFIGURATION_RESULT, INVALID_REQUEST_RESULT } from "./authzed-schema-results"; + +const writeResult = (result: object): void => { + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +const run = async (): Promise => { + const originalConsoleError = console.error; + + try { + // Environment validation logs details before throwing. Suppress that duplicate output here so this + // automation-oriented command always emits exactly one sanitized JSON result. + console.error = () => {}; + const { parseAuthzedSchemaCliCommand } = await import("../lib/authzed/schema-cli-command"); + const command = parseAuthzedSchemaCliCommand(process.argv.slice(2)); + if (!command) { + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + const { runAuthzedSchemaCli } = await import("../lib/authzed/schema-cli"); + console.error = originalConsoleError; + + process.exitCode = await runAuthzedSchemaCli(command); + } catch { + console.error = originalConsoleError; + writeResult(INVALID_CONFIGURATION_RESULT); + process.exitCode = 1; + } finally { + console.error = originalConsoleError; + } +}; + +void run(); diff --git a/apps/web/scripts/docker/authzed-cli.ts b/apps/web/scripts/docker/authzed-cli.ts new file mode 100644 index 000000000000..8d30ba83fe31 --- /dev/null +++ b/apps/web/scripts/docker/authzed-cli.ts @@ -0,0 +1,144 @@ +import "server-only"; +import { configureCanonicalAuthzedSchemaUrl } from "../../lib/authzed/schema-source"; +import { INVALID_CONFIGURATION_RESULT, INVALID_REQUEST_RESULT } from "../authzed-schema-results"; + +configureCanonicalAuthzedSchemaUrl(import.meta.url, "./schema.zed"); + +const HEALTH_INVALID_CONFIGURATION_RESULT = { + code: "authzed_internal", + latencyMs: 0, + retryable: false, + status: "unhealthy", +} as const; + +const HEALTH_INVALID_REQUEST_RESULT = { + code: "authzed_invalid_request", + latencyMs: 0, + retryable: false, + status: "unhealthy", +} as const; + +const writeResult = (result: object): void => { + process.stdout.write(`${JSON.stringify(result)}\n`); +}; + +const closeDatabase = async (): Promise => { + const { prisma } = await import("@formbricks/database"); + + await prisma.$disconnect(); +}; + +const run = async (): Promise => { + const [command, ...args] = process.argv.slice(2); + const originalConsoleError = console.error; + let shouldCloseDatabase = false; + + try { + // Environment validation writes its own diagnostics before throwing. The operator command contract + // is one sanitized JSON document, so suppress that duplicate output while loading runtime modules. + console.error = () => {}; + + switch (command) { + case "health": { + if (args.length !== 0) { + console.error = originalConsoleError; + writeResult(HEALTH_INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + const { runAuthzedHealthCli } = await import("../../lib/authzed/cli"); + console.error = originalConsoleError; + process.exitCode = await runAuthzedHealthCli(); + return; + } + case "schema": { + const { parseAuthzedSchemaCliCommand } = await import("../../lib/authzed/schema-cli-command"); + const schemaCommand = parseAuthzedSchemaCliCommand(args); + + if (!schemaCommand) { + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + const { runAuthzedSchemaCli } = await import("../../lib/authzed/schema-cli"); + console.error = originalConsoleError; + process.exitCode = await runAuthzedSchemaCli(schemaCommand); + return; + } + case "backfill": { + const { parseAuthzedBackfillCommand } = await import("../../lib/authzed/backfill-cli-command"); + const backfillCommand = parseAuthzedBackfillCommand(args); + + if (!backfillCommand) { + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + shouldCloseDatabase = true; + const { runAuthzedBackfillCli } = await import("../../lib/authzed/backfill-cli"); + console.error = originalConsoleError; + process.exitCode = await runAuthzedBackfillCli(backfillCommand); + return; + } + case "outbox": { + const { parseAuthzedOutboxCliCommand } = await import("../../lib/authzed/outbox-cli-command"); + const outboxCommand = parseAuthzedOutboxCliCommand(args); + + if (!outboxCommand) { + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + shouldCloseDatabase = true; + const { runAuthzedOutboxCli } = await import("../../lib/authzed/outbox-cli"); + console.error = originalConsoleError; + process.exitCode = await runAuthzedOutboxCli(outboxCommand); + return; + } + case "upgrade": { + const { parseAuthzedUpgradeCliCommand } = await import("../../lib/authzed/upgrade-cli-command"); + const upgradeCommand = parseAuthzedUpgradeCliCommand(args); + + if (!upgradeCommand) { + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + return; + } + + shouldCloseDatabase = true; + const { runAuthzedUpgradeCli } = await import("../../lib/authzed/upgrade-cli"); + console.error = originalConsoleError; + process.exitCode = await runAuthzedUpgradeCli(upgradeCommand); + return; + } + default: + console.error = originalConsoleError; + writeResult(INVALID_REQUEST_RESULT); + process.exitCode = 1; + } + } catch { + console.error = originalConsoleError; + writeResult(command === "health" ? HEALTH_INVALID_CONFIGURATION_RESULT : INVALID_CONFIGURATION_RESULT); + process.exitCode = 1; + } finally { + console.error = originalConsoleError; + + if (shouldCloseDatabase) { + try { + await closeDatabase(); + } catch { + // Cleanup failures must not replace the command's sanitized result or exit code. + } + } + } +}; + +void run(); diff --git a/apps/web/scripts/docker/formbricks-authzed b/apps/web/scripts/docker/formbricks-authzed new file mode 100644 index 000000000000..11d2be7f2159 --- /dev/null +++ b/apps/web/scripts/docker/formbricks-authzed @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +exec node /home/nextjs/authzed-cli/index.mjs "$@" diff --git a/apps/web/scripts/docker/server-only-empty.ts b/apps/web/scripts/docker/server-only-empty.ts new file mode 100644 index 000000000000..edb72670fd7f --- /dev/null +++ b/apps/web/scripts/docker/server-only-empty.ts @@ -0,0 +1,3 @@ +// The TypeScript entry points retain `server-only` imports for Next.js boundary enforcement. This +// dedicated Node bundle is itself a server-only artifact, so the marker is intentionally empty here. +export {}; diff --git a/apps/web/tsconfig.typecheck.json b/apps/web/tsconfig.typecheck.json index 484d7da94df8..71d34741a7ce 100644 --- a/apps/web/tsconfig.typecheck.json +++ b/apps/web/tsconfig.typecheck.json @@ -1,6 +1,7 @@ { "exclude": ["../../.env", ".next", "node_modules", "playwright"], "extends": "./tsconfig.json", + "files": ["lib/authorization/contract.typecheck.test.ts", "lib/authzed/index.typecheck.test.ts"], "include": [ "next-env.d.ts", "**/*.d.ts", diff --git a/apps/web/turbo.json b/apps/web/turbo.json index 53b027407500..7b1f86efedaa 100644 --- a/apps/web/turbo.json +++ b/apps/web/turbo.json @@ -50,6 +50,12 @@ "AUDIT_LOG_GET_USER_IP", "AUTH_SKIP_INVITE_FOR_SSO", "AUTH_SSO_DEFAULT_TEAM_ID", + "AUTHZED_CONSISTENCY", + "AUTHZED_ENABLED", + "AUTHZED_ENDPOINT", + "AUTHZED_INSECURE", + "AUTHZED_SYSTEM_KEY", + "AUTHZED_TOKEN", "AZUREAD_CLIENT_ID", "AZUREAD_CLIENT_SECRET", "AZUREAD_TENANT_ID", @@ -96,6 +102,7 @@ "LOG_LEVEL", "MAIL_FROM", "MAIL_FROM_NAME", + "MCP_OAUTH_JWKS_URL", "NEXT_PHASE", "NEXT_RUNTIME", "NEXTAUTH_SECRET", diff --git a/apps/web/vite.authzed-cli.config.mts b/apps/web/vite.authzed-cli.config.mts new file mode 100644 index 000000000000..f51c56d0badf --- /dev/null +++ b/apps/web/vite.authzed-cli.config.mts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +const canonicalSchema = readFileSync(new URL("../../authzed/schema.zed", import.meta.url), "utf8"); + +export default defineConfig({ + plugins: [ + tsconfigPaths(), + { + name: "bundle-authzed-schema", + generateBundle() { + this.emitFile({ fileName: "schema.zed", source: canonicalSchema, type: "asset" }); + }, + }, + ], + resolve: { + alias: { + "server-only": fileURLToPath(new URL("./scripts/docker/server-only-empty.ts", import.meta.url)), + }, + }, + build: { + copyPublicDir: false, + emptyOutDir: true, + outDir: "dist/authzed-cli", + ssr: "scripts/docker/authzed-cli.ts", + target: "node24", + rollupOptions: { + output: { + chunkFileNames: "chunks/[name]-[hash].mjs", + entryFileNames: "index.mjs", + }, + }, + }, + ssr: { + noExternal: true, + }, +}); diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 2b0ac9ec7dfc..a998b3d4eef4 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -26,6 +26,7 @@ export default defineConfig({ ".next/**", "**/*.integration.test.ts", "**/*.test.tsx", + "**/*.rsc.test.ts", ], }, }, @@ -37,6 +38,26 @@ export default defineConfig({ include: ["**/*.test.tsx"], }, }, + { + // ENG-2444: the `page` authorization surface lives in a React `cache()` slot, and `cache` only + // does anything in the react-server build — the default build ships it as a permanent no-op, + // and the shared vitestSetup mocks it to identity on top of that. So the `unit` project cannot + // exercise this surface at all. Here React resolves the way Next.js resolves it for RSC, so + // the real implementation runs. `ssr.resolve.conditions` is the knob that works: Vitest loads + // test modules through the SSR environment, so a top-level `resolve.conditions` is ignored. + // + // Deliberately NOT `extends: true`: that merges the root `setupFiles`, which import + // react-dom/client (forbidden under the react-server condition) and mock `cache` away. + plugins: [tsconfigPaths()], + ssr: { resolve: { conditions: ["react-server", "node", "import", "default"] } }, + test: { + name: "rsc", + environment: "node", + include: ["**/*.rsc.test.ts"], + env: loadEnv("", process.cwd(), ""), + setupFiles: ["./vitestSetup.rsc.ts"], + }, + }, ], coverage: { provider: "v8", // Use V8 as the coverage provider @@ -141,6 +162,12 @@ export default defineConfig({ // ENG-1054: keep the new Better Auth code well-tested. Glob aggregate (not perFile) so a single // thin file can't trip the gate; the integration-only BA instance/wiring is excluded above. "modules/auth/lib/**": { statements: 80, branches: 80, functions: 80, lines: 80 }, + // ENG-1718: keep the AuthZed client, projections, and backfill/repair tooling well-tested — + // this code rewrites the authorization graph. Glob aggregate (not perFile) so a single thin + // file can't trip the gate. `**/scripts/**` is excluded above, so every decision the tooling + // makes (argv parsing, scoping, classification, prune guards, exit codes) lives under + // lib/authzed and is covered here; apps/web/scripts/authzed-*.ts stay thin argv shims. + "lib/authzed/**": { statements: 80, branches: 80, functions: 80, lines: 80 }, }, }, }, diff --git a/apps/web/vitestSetup.rsc.ts b/apps/web/vitestSetup.rsc.ts new file mode 100644 index 000000000000..530e8828ce89 --- /dev/null +++ b/apps/web/vitestSetup.rsc.ts @@ -0,0 +1,16 @@ +import { vi } from "vitest"; + +/** + * Setup for the `rsc` Vitest project (ENG-2444), deliberately NOT the shared `vitestSetup.ts`. + * + * That file is unusable here for two reasons, and the second is the important one: + * + * 1. It imports `@testing-library/react`, which pulls `react-dom/client` — React refuses to load it + * under the `react-server` condition. + * 2. **It mocks React's `cache` to the identity function** (`const testCache = (func) => func`). That + * is a sensible default for unit tests, and it is exactly what makes the `page` authorization + * surface untestable there: with `cache` stubbed out there is no request scope to hold the slot, + * so the surface always falls back to the async-scoped boundary and the ENG-2444 behaviour never + * runs. Keep this file free of a `react` mock. + */ +vi.mock("server-only", () => ({})); diff --git a/authzed/INTEGRATION.md b/authzed/INTEGRATION.md new file mode 100644 index 000000000000..f6dcd0b80bb9 --- /dev/null +++ b/authzed/INTEGRATION.md @@ -0,0 +1,47 @@ +# AuthZed epic integration + +This file records the reconciliation used to integrate the AuthZed epic with the product and security +changes on `main` before the direct-cutover work began. + +## Merge contract + +- Epic parent: `3dbebde2d3029a63f42599910ac5b8acc58c712a`. +- Main parent: `12b8aa1a3d4128b7c989a5ebd5fb39b1f5084276`. +- Current `main` is authoritative for product behavior, security fixes, package infrastructure, and + generated configuration. +- `epic/authzed` is authoritative for the Formbricks authorization contract, SpiceDB schema, client, + projection, repair, deployment, and operations implementation. +- The synchronization is a real merge commit so `main` remains an ancestor of the epic. It must not + be squash-merged into `epic/authzed`. + +The already-reviewed synchronization merge from PR 8863 was replayed as the AuthZed reconciliation +ledger on top of the current `main`, followed in order by the eight AuthZed epic changes that landed +after that synchronization. This avoids resolving unrelated historical conflicts a second time while +preserving the reviewed AuthZed behavior. + +## Explicit conflict decisions + +### Rate-limit configuration + +The current `main` integration mutation limit and the AuthZed feedback-source, historical-import, +chart, feedback-directory, and feedback-record limits are all retained. They protect independent +mutation surfaces and are not alternatives. + +### API-key settings page + +The AuthZed `organization.manage_api_keys` page gate is retained. The obsolete `isReadOnly` prop is +not restored because the current `ApiKeyList` contract no longer exposes it; authorization happens +before the list is loaded. + +### Authorization and feedback surfaces + +Current product request shapes, tenant scoping, and rate-limit behavior are retained while the +AuthZed surface context and central authorization calls remain in place. No legacy authorization path +is revived to resolve an integration conflict. + +## Validation gate + +Before this merge can land, the combined tree must pass the frozen install, dependency build, +authorization/AuthZed unit suites, typecheck, schema validation, Docker and Helm contract tests, +AuthZed smoke test, and production web build. The final tree must also pass the authorization resource +inventory so every Prisma model introduced by `main` is classified. diff --git a/authzed/PERFORMANCE.md b/authzed/PERFORMANCE.md new file mode 100644 index 000000000000..67a0dfc6e8c9 --- /dev/null +++ b/authzed/PERFORMANCE.md @@ -0,0 +1,249 @@ +# ENG-1739 — Authorization performance at BI-like scale + +Results from running the ENG-1739 tooling (`pnpm authzed:perf`, the per-request check +counter in `apps/web/lib/authorization/context.ts`) against a locally seeded BI-shaped +tenant. This is a snapshot from one machine on one day, not a standing benchmark — see +[Environment and caveats](#environment-and-caveats) before treating any number here as a +production SLO. + +> **Historical benchmark context:** This report predates the approved direct-cutover contract. The release path +> no longer uses per-surface enforcement cohorts or shadow comparison. The direct-authority artifact uses +> `fully_consistent`; ENG-2453 must revalidate latency, concurrency, and 2x headroom in production-like staging +> before cutover. See the [direct AuthZed cutover and rollback contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad). + +## Summary + +- **The N+1 claim is proven, not argued, on three list/export paths.** A workspace's + survey list, its dashboard list, and a survey's response export each issue a + row-count-independent number of authorization checks — confirmed against real Postgres, + and mutation-checked against all three together (removing the counter call from `can()` + fails all 9 tests across the three files in one run). The suite costs 3.8s of test time + (8.6s including Vitest startup); the slowest single test is 889ms against a 30s budget. +- **A single authorization decision is cheap: legacy evaluator sub-2ms p50, sub-5ms** + **p99; raw SpiceDB engine sub-0.5ms p50, sub-2ms p99.** The full `can()` path + through the coordinator (which includes Postgres scope resolution without `reactCache` + benefit in a plain script) measures 3.5–3.9ms p50 / 23–30ms p99 for SpiceDB — see + the table below and the correction for why this gap is not the SpiceDB engine itself. +- **The first cross-evaluator comparison run was misread, and the correction matters more + than the original number.** The gap between "legacy" and "SpiceDB" in the first run was + not the SpiceDB engine, and it was not `fully_consistent` — it was the coordinator's + pre-check Postgres resolution running with no `reactCache` benefit in a plain script. + Details in [The correction](#the-correction-what-the-first-comparison-got-wrong). +- **What this does not answer:** a real per-page count under Next.js request caching, and + behavior at the `large` scale profile (500k responses) or under concurrent load. + +## Method + +Two tools, run separately per AuthZed's own load-testing guidance — relationships must be +seeded before they're read, never written during the run that measures them: + +```bash +pnpm authzed:perf seed --scale=default # 2,000 users, 100 teams, 50 workspaces, + # 6,000 surveys in one workspace, 50,000 responses +pnpm authzed:backfill --apply --scope=all # project the seeded rows into SpiceDB +pnpm authzed:perf run --iterations=5000 --concurrency=16 +``` + +`authzed-perf.ts run` drives the real `can()` — real coordinator, real evaluator, real +SpiceDB when the historical enforcement harness is configured — inside a `withAuthorizationSurface` wrapper +(so the migration coordinator has a target to match), and reports p50/p95/p99 per action from +5,000 samples weighted toward positive checks and a subset of the seeded users, per +AuthZed's guidance that negative checks are structurally more expensive and an unweighted +sample produces an unrealistic cache profile. + +The N+1 claim is separate and does not use the perf harness at all: it drives the real +survey-list code path (`can(..., "workspace.read", ...)` then `getSurveys(workspaceId)`, +exactly what a workspace's survey list page calls) inside the same request-surface +wrapper, and reads `getIssuedAuthorizationCheckCount()` — a counter incremented once inside +`can()` itself, the single point every caller passes through — before and after. See +`apps/web/lib/authorization/checks-per-request.integration.test.ts`. + +## Results — single-decision cost, legacy vs SpiceDB + +5,000 checks each, same seeded tenant (2,000 users / 100 teams / 50 workspaces / 6,000 +surveys / 50,000 responses), concurrency 16, **0 errors on both**. + +| Action | Legacy p50 | Legacy p95 | Legacy p99 | SpiceDB p50 | SpiceDB p95 | SpiceDB p99 | +| ------------------------ | ---------- | ---------- | ---------- | ----------- | ----------- | ----------- | +| `organization.read` | 0.92 ms | 1.38 | 1.84 | 3.52 ms | 8.12 | 23.68 | +| `organization.manage` | 0.93 ms | 1.41 | 1.87 | 3.52 ms | 8.12 | 23.28 | +| `workspace.read` | 1.37 ms | 2.16 | 3.03 | 3.60 ms | 10.17 | 24.10 | +| `workspace.write` | 1.41 ms | 2.19 | 3.09 | 3.49 ms | 10.00 | 23.84 | +| `survey.read` | 1.99 ms | 2.94 | 4.11 | 3.88 ms | 9.58 | 29.13 | +| `survey.response_export` | 1.97 ms | 2.93 | 4.30 | 3.78 ms | 10.14 | 29.58 | + +Throughput: 6,424 checks/sec (legacy) vs 2,580 checks/sec (SpiceDB, `fully_consistent`, +required by env validation whenever enforcement rules are configured — see below). +`allowRate` was **0.21 on both runs** — the two evaluators reached the same decisions on +the same tenant, which is a correctness signal this comparison produced for free. + +Read this table for shape, not for an absolute SLO: see +[Environment and caveats](#environment-and-caveats). + +## The correction: what the first comparison got wrong + +The first pass at this compared the table above and concluded "enforcement's forced +`fully_consistent` consistency mode is 6–13x slower at p99." That conclusion does not +survive isolating the variable, and it shipped in a draft of this document for about an +hour before a second test caught it — worth recording so the mistake doesn't get made +again. + +**What actually happened:** the comparison above uses two different code paths end to end +— `can()` routed to the legacy evaluator, versus `can()` routed to the SpiceDB evaluator +under enforcement (which forces `fully_consistent`). That conflates three variables that +were never separately measured: which evaluator answers, whether the coordinator's +pre-check Postgres resolution ran with a warm cache, and the consistency mode. + +**Isolating consistency mode alone** — calling the SpiceDB client's `checkPermission` +directly, bypassing the coordinator entirely, at both settings, varying the subject across +200 real seeded users the same way the full run does: + +| | minimize_latency | fully_consistent | +| --- | ---------------- | ---------------- | +| p50 | 0.40 ms | 0.49 ms | +| p95 | 1.21 ms | 1.34 ms | +| p99 | 2.02 ms | 1.94 ms | + +**Consistency mode costs almost nothing.** The raw SpiceDB engine is not the bottleneck — +it is, if anything, faster per-check than the legacy Postgres evaluator (0.4–0.5 ms vs. +0.9–2 ms p50 in the table above). + +**So where did the 3.5 ms p50 / 23–30 ms p99 in the full run come from?** The coordinator. +Before consulting either evaluator, `resolveAuthorizationScope` resolves the actor and +tenant boundary via Postgres (`apps/web/lib/authorization/source-scope.ts`), and the +resolvers behind it (`apps/web/lib/authorization/resolvers.ts`) are wrapped in React's +`cache()` — which deduplicates _within one render_, not across independent calls. The perf +harness is a script, not a React render, so every one of its 5,000 iterations paid full, +uncached Postgres resolution cost. **This script cannot see the benefit `reactCache` gives +a real request that makes several checks against the same resource**, and its numbers are +a pessimistic bound for that reason, not a discovery that SpiceDB or its consistency +requirement is expensive. + +Practical upshot: don't read `fully_consistent` as villain. The real open question is +whether `resolveAuthorizationScope`'s resolvers get real request-scoped caching in +production the way this script cannot exercise — worth a follow-up, not a finding this +report can close on its own. + +## Results — request amplification (the N+1 claim) + +```text +apps/web/lib/authorization/checks-per-request.integration.test.ts +apps/web/lib/authorization/checks-per-request-dashboards.integration.test.ts +``` + +| Path | Small | Large | Δ | +| -------------------------------------------------------- | ------- | ------- | ----- | +| Survey list (50 → 3,000 surveys) | 1 check | 1 check | **0** | +| Survey list as a _member_, access via team (100 surveys) | 1 check | — | **0** | +| Dashboard list (10 → 2,000 dashboards) | 1 check | 1 check | **0** | + +The member row is the one that exercises the interesting code. Owners short-circuit +nearly every authorization branch, so an owner-only suite never touches the scope +resolver or the team-membership walk that real non-admin users go through; that variant +seeds a `member` whose workspace access arrives through a `WorkspaceTeam` grant and +confirms the count is still one. + +One `workspace.read` decision gates the survey list and the dashboard list; neither +`getSurveys` nor `getDashboards` runs authorization of its own. + +None of these grows with the row count — the property "Prove current workspace-scoped +list paths do not perform one AuthZed check per survey or dashboard" from the +ticket scope, stated as passing assertions rather than a grep result. + +This is backed by a request-scoped counter +(`apps/web/lib/authorization/context.ts:recordAuthorizationCheckIssued`), incremented once +inside `can()` itself — the one point every `can()`/`assertCan()` call passes through +regardless of caller — and reported in production as +`formbricks_authzed_authorization_checks_per_request`, a histogram tagged by surface. It counts central +authorization operations: scalar `can()`/`assertCan()` decisions and authoritative list operations each +contribute one, independent of row count. That metric +is the thing to watch on a real dashboard for the general "no page regresses into an N+1" +question; this report exercised the three paths the ticket named explicitly by name. + +**What this does not cover:** API-key scoped listing was investigated, not tested the same +way, because it doesn't fit the pattern. The v2 API-key response-list route +(`apps/web/modules/api/v2/management/responses/route.ts`) resolves the key's workspace +grants directly from the `ApiKeyWorkspace` join table during authentication and filters +the query by that set — it does not call `can()` in the read path at all today. That's a +different, and arguably more interesting, fact than "checks stay flat": there is no check +to count. Worth a decision on whether that read path should route through the central +interface at all, separate from the N+1 question this report answers for the other three. + +**A real bug this pass caught in its own test suite.** The first version of the growth +assertions (`expect(large - small).toBe(0)`) passes vacuously if the counter itself stops +incrementing — both sides read 0, and 0 equals 0. Re-running the mutation check (remove +the counter call from `can()`) against all three files _together_, rather than +spot-checking one file and trusting the aggregate failure count, surfaced that the +survey-list growth test was the one silently passing under that exact mutation on the +first pass. All growth-style assertions now separately assert the baseline count is +positive before asserting the delta is zero; re-running the mutation now fails all 9 +tests across the three files in one run. + +## Environment and caveats + +- **One laptop, one run, no dedicated hardware.** Per general performance-regression + practice, a benchmark on shared/noisy hardware without measuring its own variance first + is a signal, not an SLO — these numbers should inform a budget discussion, not become one + by default. +- **Local SpiceDB, no real network hop.** `formbricks-spicedb-1` runs in the same Docker + network as Postgres on the same machine as the client. Staging/production numbers will + differ, likely upward, once a real network path is in the loop. +- **`large` scale (500k responses, per the ticket's response-heavy analytics mention) was + not run.** `default` (6,000 surveys, 50,000 responses) is what these numbers reflect; + responses only affect the analytics/export paths, not the authorization graph, so this + gap matters less than it sounds — but it is untested. +- **No concurrent-load / throughput ceiling test.** 16 concurrent checks is nowhere near + what BI-scale traffic would look like; this measures per-check latency, not the system's + saturation point. +- **The `reactCache` gap above is real and unresolved.** These numbers likely overstate the + coordinator's cost under real Next.js request handling and understate it for the very + first check in a request that makes several. Neither direction is quantified. +- **`fully_consistent` is required by env validation whenever any enforcement rule is set** + (`apps/web/lib/env.ts`) — not a choice this report's SpiceDB numbers could have avoided. + AuthZed's own docs note that mode "reduces cache hit rate, increasing latency and load on + the datastore" compared to `at_least_as_fresh` with ZedTokens — worth revisiting given + the correction above shows the effect was smaller than first assumed, but the + recommendation itself hasn't been re-evaluated against this app's actual freshness needs. + +## Reproducing this + +```bash +# 1. Bring up SpiceDB locally (schema + a throwaway preshared key/db password) +docker compose -f docker-compose.dev.yml up -d --no-deps authzed-db-bootstrap +docker compose -f docker-compose.dev.yml up --no-deps spicedb-migrate +docker compose -f docker-compose.dev.yml up -d --no-deps spicedb +pnpm authzed:schema apply + +# 2. Seed + project. `seed` is idempotent — it removes a previous seed first — but it +# writes tens of thousands of rows into whatever database `.env` points at, so use a +# throwaway one. +pnpm authzed:perf seed --scale=default +pnpm authzed:backfill --apply --scope=all + +# 3. Measure +pnpm authzed:perf run --iterations=5000 --concurrency=16 + +# 4. The N+1 proofs (survey list, dashboard list, response export). 9 tests, ~4s. +pnpm --dir apps/web test:integration lib/authorization/checks-per-request.integration.test.ts \ + lib/authorization/checks-per-request-dashboards.integration.test.ts \ + lib/authorization/checks-per-request-response-export.integration.test.ts + +# 5. Tear down every row the seed created, and nothing else +pnpm authzed:perf clean +``` + +## Follow-ups + +- Decide whether the v2 API-key-scoped response list should route through `can()` at all — + it currently authorizes by construction (filtering by the key's own workspace grants) + rather than by a checkable decision, which is a different question from N+1. +- Quantify the `reactCache` gap: reproduce a fake request boundary per iteration in the + perf harness (or run the counter test at BI scale for a fuller path) to see whether + request-scoped caching meaningfully changes the coordinator's contribution. +- Re-run the authoritative `fully_consistent` path in production-like staging. Changing consistency is outside + this cutover contract and would require a separately reviewed revocation/read-after-write design. +- Run the `large` scale profile (500k responses) at least once to confirm nothing changes + qualitatively — response volume shouldn't move the authorization graph, but that's an + assumption this report states, not one it tested. +- Add a `formbricks_authzed_authorization_checks_per_request` alert threshold once real production + values establish a baseline (this report has no basis for picking a number). diff --git a/authzed/README.md b/authzed/README.md new file mode 100644 index 000000000000..b7eee85362b9 --- /dev/null +++ b/authzed/README.md @@ -0,0 +1,653 @@ +# Formbricks Authorization Schema (AuthZed / SpiceDB) + +This directory contains the canonical SpiceDB schema for Formbricks and its +assertion-based validation suite. + +- `schema.zed` — the canonical, non-composable authorization schema + (`use typechecking`). +- `schema-validation.yaml` — relationships, assertions, and expected-relations + blocks that pin down the schema's semantics. +- `validate.sh` — offline validation runner (local `zed` binary or the pinned + `authzed/zed` container image; no SpiceDB server needed). +- [Direct AuthZed cutover and rollback contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad) — the approved direct-authority, fail-closed, + immutable-artifact, rollback, and environment-gate contract. It supersedes + the earlier shadow/cohort release proposal and remains in Linear rather than + being duplicated in this repository. +- [`RUNBOOK.md`](./RUNBOOK.md) — diagnosing and recovering from relationship-sync + failures: the metrics, the log field contract, suggested alert rules, and the + recovery path through `pnpm authzed:backfill`. +- [`PERFORMANCE.md`](./PERFORMANCE.md) — measured cost of a single authorization + decision (legacy vs SpiceDB), the proof that the workspace-scoped list paths issue + a row-count-independent number of checks, and how to reproduce both with + `pnpm authzed:perf`. +- [`AuthZed Operations`](../docs/self-hosting/advanced/authzed-operations.mdx) — + the public self-hosting contract for Docker and Kubernetes operators. + +## Running the validation + +```bash +pnpm authzed:validate +``` + +CI runs the same script on every pull request as part of `.github/workflows/pr.yml`. +The validation job is a dependency of the required `PR Check Summary`, so a +failing assertion blocks the change: it means the schema no longer matches the +documented semantics. + +## Checking and applying the schema + +Schema deployment is an explicit operational action. Formbricks never writes a +schema during application startup, database migration, health checks, +readiness, or Helm reconciliation. + +Configure the normal server-only AuthZed variables, then check the connected +SpiceDB instance without changing it: + +```bash +pnpm authzed:schema check +``` + +The command compares the checked-in schema with SpiceDB semantically by using +the AuthZed schema-diff API. It does not compare raw formatted text. A matching +schema exits `0`; drift exits `2`. Both cases print exactly one sanitized JSON +object containing source and remote SHA-256 digests and aggregate difference +counts. Schema contents and changed object names are never printed. + +An empty SpiceDB installation can be initialized explicitly: + +```bash +pnpm authzed:schema apply +``` + +Replacing a non-empty schema requires the exact remote digest returned by the +immediately preceding check: + +```bash +pnpm authzed:schema apply \ + --expected-current-digest sha256: +``` + +This guards against applying over a schema the operator did not inspect. +The digest precondition is not atomic because SpiceDB schema writes do not support compare-and-swap, so ensure +there is no concurrent schema writer between `check` and `apply`. +`apply` exits `0` only after reading the schema back and confirming that its +semantic diff is empty. Applying an already matching schema returns +`status: "unchanged"` without issuing another write. Invalid configuration, +transport failures, digest mismatches, unsafe SpiceDB schema changes, and +read-back failures exit `1` with a stable `authzed_*` code. + +The command loads the repository `.env`. For an external TLS endpoint use a +bare `host:port` with `AUTHZED_INSECURE=false`; internal Docker or Kubernetes +plaintext endpoints use `AUTHZED_INSECURE=true`. Restart long-running +Formbricks processes after changing AuthZed environment values. + +### Backups and rollback + +Before replacing any non-empty schema: + +1. Export the current schema with the pinned `zed` CLI. +2. Export relationships that depend on definitions or relations being removed. +3. Store both files with mode `0600`. +4. Run `check` and retain its remote digest. +5. Apply only the reviewed canonical schema. + +Rollback is safe only while no relationships depend on definitions introduced +by the new schema. Once relationships exist, do not force a downgrade. Use an +expand, backfill, and contract migration so every intermediate schema accepts +the stored relationships. + +## Guiding principle: mirror the current system + +The engine-independent application contract in +`apps/web/lib/authorization` is the source of truth for Formbricks actor, +action, and resource types. This SpiceDB schema is a downstream implementation +of that contract; application types must never be generated from SDK or schema +types. + +The schema is a **technical migration of the current Formbricks authorization +system**. It models exactly what the application enforces today — no future +capabilities, no permission changes. A principal must never gain or lose access +because a check moved from application code into this schema. + +Any schema change must keep `pnpm authzed:validate` green and extend the +assertions to document the new semantics. Intentional semantic changes require +updating the assertions in the same PR, with review. + +## Organization membership projection + +PostgreSQL remains the source of truth for membership lifecycle and roles. +After a `Membership` source mutation commits, Formbricks reconciles the +corresponding SpiceDB `organization` relationship: + +- `Membership.role` maps exhaustively to exactly one of `owner`, `manager`, + `member`, or `billing`. +- A present membership atomically touches its current role and deletes the + other three roles. +- A deleted membership deletes all four possible role relationships. +- Repeating the same create, update, or delete is safe and can heal a missed + projection. +- If the source role changes concurrently, reconciliation reads PostgreSQL + again and converges for up to three passes. + +The current authorization contract treats every `Membership` row as active without checking +`Membership.accepted`. The projection deliberately preserves that +behavior: accepted and pending membership rows project identically. An +`Invite` alone is not projected. + +Every authorization-bearing source table has a PostgreSQL trigger that inserts a +projection event in the same transaction as the source mutation. The existing +post-commit projector remains as a low-latency fast path, but the PostgreSQL +outbox is the durable delivery contract: BullMQ wakes a worker every five +seconds, the worker claims rows with leases and `FOR UPDATE SKIP LOCKED`, and the +idempotent reconcilers deliver each claimed batch as six independent groups. + +Failure is attributed rather than shared. A group that fails takes only its own +events; the rest of the batch is still delivered. A retryable failure releases +every remaining group untried, because spending another three-attempt budget per +group against an unreachable instance buys nothing. + +Dead-lettering requires the failure to _name_ an event, which takes three things +together: the code is non-retryable, the attempt covered exactly one event, and +the code is one an event can actually cause +(`authzed_projection_invalid_source`, `authzed_invalid_request`). The third +condition is not redundant. On a five-second cadence most groups hold a single +event, so size alone would charge whichever revocations happened to be +travelling alone when SpiceDB rejected a credential — dead-lettering bystanders +mid-outage, which is the opposite of the intent. Those same event-attributable +codes are the ones that trigger halving the group until the culprit is alone; +codes describing the instance are neither split nor charged. Ten solitary, +attributable failures dead-letter the event. + +The consequence is the property worth remembering: **no SpiceDB outage, of any +duration or kind, can dead-letter an event that was never the problem.** + +AuthZed being disabled performs no delivery work. An AuthZed outage never +changes a successful PostgreSQL mutation into an application error; committed +outbox rows remain recoverable and are replayed when SpiceDB returns. Existing +records and independent drift are reconciled by the six-hour applying audit and +by `pnpm authzed:backfill` (see [Backfill and repair](#backfill-and-repair)). A +clean graph and drained outbox are mandatory before direct authority. + +Deletes, and updates that are not provably grants, are classified as revocations. +The classifier is deny-by-default: an unmapped target type, an unmapped column, +or any enum move is a revocation. Only three transitions are treated as grants, +each because the projectors' own write shape proves the relationship set can only +grow — reactivating a user, unarchiving a feedback directory that stays in its +organization, and any membership update that leaves `role` unchanged (the +projected snapshot ignores `accepted`, so accepting an invite writes identical +relationships). + +The permission ladder is deliberately not encoded in the trigger. It is rankable, +but a rank table in SQL has no compile-time backstop the way +`relationship-map.ts` does, so a change to `authzed/schema.zed` would silently +make it wrong in the fail-open direction — the one direction this guard must +never be wrong in. Role changes are one-at-a-time admin actions rather than the +bulk operations the classifier exists to keep off the guard. Revisit only if a +bulk re-roling path appears. + +This matters because the guard is global and unscoped. Direct authority refuses +protected operations with `authzed_projection_stale` when an unresolved +revocation reaches 60 seconds or enters dead letter, so an undelivered event +classified as a revocation denies every enforced check in the deployment. A mass +invite acceptance or reactivation sweep must therefore not arm it. + +If a source pair moves, the trigger enqueues both the previous pair as a +revocation and the current pair, preventing a stale old edge from becoming +undiscoverable. + +The organization-membership projection boundary covers: + +- the shared `createMembership` service, including idempotent retries; +- SSO provisioning after its outer transaction commits; +- organization role updates and explicit membership deletion; +- API v2 organization-user nested membership creation and role updates; +- organization deletion and both legacy and Better Auth user-deletion + cascades. + +User deletion removes both organization-role and team-role relationships for +the deleted user. API-key projection is described separately below because API +keys are independent authorization subjects rather than user-owned role edges. + +The application facade accepts only Formbricks-owned relationship types. It +supports idempotent `touch`/`delete` batches of at most 1,000 updates and safely +narrowed bulk deletions. The SDK client, credentials, SDK request/response +types, and raw errors never cross the facade. Relationship identifiers are +write-only inputs and never appear in projection results or logs. + +## Team membership and workspace-grant projection + +PostgreSQL also remains authoritative for team and workspace access. After a +source mutation commits, Formbricks reconciles the affected graph: + +- `Team.organizationId` touches `team#organization@organization`. +- `TeamUser.role` maps exhaustively to exactly one `team#admin@user` or + `team#contributor@user` relationship and deletes the alternate role. +- `Workspace.organizationId` touches + `workspace#organization@organization`. +- `WorkspaceTeam.permission` maps exhaustively to exactly one + `workspace#reader_team`, `workspace#writer_team`, or + `workspace#manager_team` relationship with a `team#member` subject, deleting + the two alternate grants. + +Formbricks never precomputes a user's highest workspace permission. SpiceDB +unions every team grant at evaluation time, preserving the current +`read < readWrite < manage` ladder when a user belongs to multiple teams. + +Reconciliation deduplicates targets, reads a complete PostgreSQL snapshot, and +writes logical relationship groups sequentially in requests of at most 1,000 +updates. A role's two updates or a workspace grant's three updates are never +split across requests. The projector re-reads the source and retries the +complete snapshot for up to three passes when it changes concurrently. + +Team membership writes inside SSO transactions use an explicit deferred mode. +The enclosing service reconciles only after the outer transaction commits. +Multi-step nontransactional flows, including invite assignment and workspace +creation, remember each committed source target and reconcile it in `finally`; +an AuthZed failure never replaces the original source result. + +Deletion cleanup is deliberately two-sided and idempotent: + +- a missing team deletes its resource relationships and every workspace grant + where that `team#member` is the subject; +- a missing workspace deletes all relationships on that workspace resource; +- user deletion removes user-subject relationships from organization and team + resources; +- organization deletion captures its team and workspace IDs before the + PostgreSQL cascade, then removes organization, team, workspace, and + team-as-subject workspace edges. + +Projection covers UI and API team creation/update/deletion, workspace +creation/deletion, API v2 workspace-team CRUD, invite/signup/SSO team +assignment, organization-role promotions, membership removal, API v2 nested +organization-user team changes, and user/organization cascades. Existing records +are not backfilled by these hooks; `pnpm authzed:backfill` covers them. + +## API-key scope projection + +PostgreSQL remains authoritative for API-key ownership and access. After API +key creation commits, Formbricks reconciles: + +- `ApiKey.organizationId` to `api_key#organization@organization`; +- `organizationAccess.accessControl.read` to + `organization#api_key_reader@api_key`; +- `organizationAccess.accessControl.write` to + `organization#api_key_writer@api_key`; +- `ApiKeyWorkspace.permission` (`read`, `write`, or `manage`) to exactly one + `workspace#reader`, `workspace#writer`, or `workspace#manager` relationship + whose subject is the API key. + +The organization-access flags are independent. Missing, malformed, or +non-boolean JSON values are treated as `false`, matching the current evaluator. +Each workspace scope touches its selected relation and deletes the other two, +so repeating a write is idempotent and a lower permission removes any stale +higher grant. + +API-key scopes are selected during creation and are not editable today. Label +and `lastUsedAt` updates do not affect authorization and therefore do not +project. There is no separate revoked state in the current data model: deleting +an API key is revocation. + +Deletion cleanup removes every relationship on the API-key resource and every +organization or workspace relationship where the API key is the subject. +Organization deletion captures API-key IDs before PostgreSQL cascades and then +performs the same idempotent cleanup. Workspace deletion is already covered by +the workspace projector, which deletes every relationship on the missing +workspace resource. + +The projector reads only IDs, organization access, and workspace permissions; +it never reads or logs plaintext keys, hashes, lookup hashes, creator metadata, +or usage timestamps. Reconciliation uses the same post-commit, best-effort, +three-pass convergence and bounded batching contract as organization, team, +and workspace projection. + +Existing API keys are not backfilled by mutation hooks; `pnpm authzed:backfill` +covers them, including a scope revoked outside a hook, which the projector alone +cannot see. API-key principals are routed through the central interface; the +direct-authority release contract is now owned by ENG-2448 and +the [direct AuthZed cutover and rollback contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad). + +## Feedback Dataset projection + +The product term **Feedback Dataset** maps to Prisma `FeedbackDirectory`. PostgreSQL remains the source +of truth for each directory, its owning organization, archive state, and its +`FeedbackDirectoryWorkspace` assignments. + +- A directory projects `feedback_directory#organization@organization`. +- An active same-organization assignment projects a three-edge subgraph linking the directory, an opaque + `feedback_directory_assignment`, and the assigned workspace. +- The assignment object ID is a deterministic `fdwa_`-prefixed SHA-256 digest of the length-framed + directory/workspace pair. Source IDs and the generated ID never appear in projection logs. +- Archived assignments are not active grants. Reconciliation removes all three stored edges. +- A directory and workspace belonging to different organizations is invalid source state. It is reported + for manual investigation and never projected. + +Directory administrators inherit from `organization.manage`. Team members and API keys inherit through +the exact assigned workspace. The assignment resource ensures that an operation scoped to workspace A +cannot use access granted through workspace B. Directory-wide checks can union all active assignments for +gateway operations that do not carry workspace context. + +Projection runs after the PostgreSQL mutation commits and remains best-effort. Creation, assignment +replacement, archive/restore, workspace deletion, and organization deletion reconcile captured previous +and current pairs. The repair command covers existing rows, missing edges, stale archived edges, parent +drift, and exact three-edge permission drift. + +Feedback records, feedback sources, charts, workflows, contacts, attributes, and segments do not receive +standalone Phase 1 SpiceDB resources. Charts and workflows inherit workspace authorization. Chart +`createdBy` is metadata rather than authorization ownership, and record-level tenant/integrity checks +remain in the application and Hub layers. + +### Feedback Dataset authorization routing + +Current feedback access is routed through the central Formbricks authorization interface without changing +its effective rules: + +- dataset administration checks `organization.manage`; +- workspace-scoped records, taxonomy, sources, CSV imports, chart queries, and server-rendered Unify + entry points check the exact `feedbackDirectoryAssignment` resource; +- directory-wide gateway reads and creates check `feedbackDirectory.read` or + `feedbackDirectory.write` across all active assignments; +- existing-record mutations still require organization management for users and an exclusively assigned + dataset plus the existing workspace permission for API keys; +- archive, entitlement, OAuth-scope, Hub tenant, source ownership, and record-integrity checks remain in + the application layer and execute in their existing order. + +Authenticated feedback-gateway requests carry the bounded `feedback_gateway` telemetry surface. Public and +unauthenticated gateway traffic is never authorized as an authenticated actor. The surface only attributes +authoritative metrics; it does not select an evaluator. + +## Resource parent resolution during the current-model migration + +The initial migration deliberately does not project one relationship for every +survey, dashboard, and response. ENG-1738's private evaluator uses +the existing server-only PostgreSQL resolvers to map: + +- a survey or dashboard to its workspace; +- a response to its survey, then to its workspace. + +It then checks the equivalent workspace permission in SpiceDB. This preserves +the current authorization boundary and avoids adding a high-cardinality +`response#survey` projection to every response mutation before direct authority. +Resolver database failures remain operational errors and missing resources +remain denials, preserving the current authorization contract. + +The `survey#workspace`, `dashboard#workspace`, and `response#survey` relations +remain in the schema for later resource-level sharing. They must not be queried +directly until a future projector and matching backfill scope cover those edges; +the backfill classifies them as ignored today and never prunes them. +Phase 2 direct resource grants must add that projection and repair scope before +enforcement. + +## Authorization evaluation and direct cutover + +The private SpiceDB evaluator sits behind the existing server-only `can()` and +`assertCan()` contract. The direct-authority image makes SpiceDB the sole evaluator +with no runtime legacy fallback or cohort selector. The separately pinned bridge +image remains the deployment rollback artifact while the durable outbox keeps its +relationship graph current. + +The immutable bridge/candidate artifacts, fail-closed behavior, sandbox-first +sequence, staging and regional production gates, abort triggers, rollback, and +self-hosted v6 contract are defined in the [direct AuthZed cutover and rollback +contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad). Operational execution is documented in the +[relationship sync runbook](./RUNBOOK.md#7-direct-authority-cutover). + +## Mapping from the current system + +| Application concept | Schema element | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `Membership.role` (`owner`/`manager`/`member`/`billing`) | `organization` relations `owner`/`manager`/`member`/`billing` | +| `TeamUser.role` (`admin`/`contributor`) | `team` relations `admin`/`contributor` | +| `WorkspaceTeam.permission` (`read`/`readWrite`/`manage`) | `workspace` relations `reader_team`/`writer_team`/`manager_team` (subject `team#member`) | +| `ApiKeyWorkspace.permission` (`read`/`write`/`manage`) | `workspace` relations `reader`/`writer`/`manager` (subject `api_key`) | +| `ApiKey.organizationAccess.accessControl` (`read`/`write`) | `organization` relations `api_key_reader`/`api_key_writer` | +| `FeedbackDirectory.organizationId` | `feedback_directory#organization@organization` | +| Active `FeedbackDirectoryWorkspace` | Three-edge `feedback_directory_assignment` graph to the exact workspace | +| `Survey.workspaceId` / `Dashboard.workspaceId` / `Response.surveyId` | `survey`/`dashboard` relation `workspace`; `response` relation `survey` | + +Resource permissions preserve the operation-specific gates that exist today: + +| Current application operation | Schema permission | Required access | +| ---------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------- | +| Read organization teams and workspace-team assignments | `organization.read_access`, `team.read` | organization membership or `accessControl.read`/`write` | +| Rename teams or manage team membership | `organization.manage_access`, `team.manage` | team admin or `accessControl.write` | +| Delete a team | `team.delete` | owner/manager or `accessControl.write` | +| Read or edit a survey | `survey.read`, `survey.write` | workspace `read` or `readWrite` | +| Delete a survey through the web application or V3 API | `survey.delete` | workspace `readWrite` | +| Manage survey languages or delete through legacy management APIs | `survey.manage` | workspace `manage` | +| Read or mutate a dashboard, including deletion | `dashboard.read`, `dashboard.write` | workspace `read` or `readWrite` | +| Read/export a response | `response.read`, `response.export` | workspace `read` | +| Update, tag, or delete a response through the web application | `response.write` | workspace `readWrite` | +| Delete a response through legacy management APIs | `response.manage` | workspace `manage` | + +Behavioral sources of truth in the application (referenced from the schema's +doc comments): + +- `apps/web/lib/authorization` — the engine-independent current-model + authorization contract and role/grant mapping. +- `apps/web/lib/authorization/permission-action.ts` — exhaustive translation of + HTTP/team permission ladders into semantic actions. +- `apps/web/lib/organization/auth.ts` — `verifyUserRoleAccess`: managers manage + members/billing/API keys but cannot update or delete the organization. +- `apps/web/lib/workspace/auth.ts` — navigation and integration-specific compositions; + the billing role is excluded from product data. +- `apps/web/modules/ee/teams/lib/roles.ts` — a member without a team has no + workspace access; the highest team permission wins. +- `apps/web/lib/authorization/spicedb-evaluator.ts` — tenant-safe scope resolution, + API-key ownership checks, and authoritative permission evaluation. + +## Semantics guaranteed by the assertions + +1. **Owner and manager broad access** — org owners and managers have full + access to every workspace and its content; only owners may update or delete + the organization itself. +2. **Billing role blocked from product data** — billing reaches billing + surfaces only; never workspaces, surveys, responses, or dashboards. +3. **Team-based workspace access** — teams are the only path from a plain + member to a workspace; a member without a team has no product access. +4. **Externally scoped read-only access** — a read-level team confines an + external (agency) user to viewing a single workspace. (Per-survey scoping + does not exist today and is deliberately not modeled.) +5. **Dashboard read derives from workspace read** — dashboards carry no ACL of + their own. Response export currently equals response read; the separate + `response_export` permission keeps the vocabulary ready for a future split, + which would be an asserted schema change. +6. **API key as scoped principal** — workspace-scoped keys act only inside + their granted workspace at their granted level; organization-level + `accessControl` rights grant access to organization access-control resources + but no product data. + +## Backfill and repair + +Mutation hooks only project records that change while they are running. They do +not cover records that predate them, and they cannot see a row deleted outside a +hook — a projector derives its targets from PostgreSQL, so a relationship whose +source row is already gone is never named and never removed. `pnpm +authzed:backfill` closes both gaps. + +```bash +# Report drift over every organization. Writes nothing. +pnpm authzed:backfill + +# Converge one organization from PostgreSQL, or a single workspace's grants. +pnpm authzed:backfill --apply --organization-id= +pnpm authzed:backfill --apply --workspace-id= + +# Remove the relationships of a workspace whose row is gone. This is a prune — +# every relationship on that workspace goes, team and API-key grants included — +# so it takes the prune flags rather than --apply alone. +pnpm authzed:backfill --apply --prune --confirm-prune --workspace-id= \ + --expected-endpoint= + +# A stale grant whose *team or API key* is also gone is reported but not removed by +# this scope: deleting it would delete that principal's relationships everywhere, +# which is the organization or full sweep's unit of work, not one workspace's. + +# Converge everything, then remove relationships PostgreSQL no longer holds. +pnpm authzed:backfill --apply --prune --confirm-prune --scope=all \ + --expected-endpoint= + +# Resume an interrupted run from the lastOrganizationId it reported. +pnpm authzed:backfill --apply --after-organization-id= +``` + +Exit codes match `authzed:schema`: `0` reconciled, `2` drift remains, `1` failed +or misused. **`0` means every category is clear, including the ones this tool +deliberately will not repair** — `invalid` and `unmanaged` count toward drift +exactly like `orphaned` and `missing`, because unrepaired authorization state is +still authorization state and this exit code is what gates direct authority. The +result is one line of JSON carrying counters, the offending record identifiers, +a revision captured _after_ the run's own writes (`null` for a dry run), and a +`truncated` flag. The revision remains useful operational evidence, but the +approved direct-authority release uses `fully_consistent` reads and does not use +it as a shadow freshness floor. + +That JSON is the whole diagnostic: like the other AuthZed commands, this one runs +at `LOG_LEVEL=fatal` so stdout stays a single parseable line. Each entry in +`failures` therefore carries `attempts` alongside the sanitized code, because +"failed once" and "exhausted the retry budget" call for different reactions and +the logs that would otherwise distinguish them are suppressed. + +Drift is reported in both directions: + +- `missing` — records PostgreSQL holds that SpiceDB has no relationship for. This + is what an empty or stale SpiceDB looks like, so a report that could not see it + would be worthless. +- `mismatchedPermissions` — an existing source record whose exact role, grant, or + independent access-flag relationship set differs from PostgreSQL. This catches + stale privilege upgrades such as a `manager_team` relationship for a source row + that now grants only `read`. Applying reconciliation writes the current value; + a follow-up dry run confirms the mismatch is gone. +- `orphaned` — relationships whose source record is gone. +- `invalid` — source rows whose principal and resource belong to different + organizations. Never projected and never pruned, in either scope. +- `unmanaged` — relationships outside the vocabulary. Reported, never touched. +- `mismatchedParents` — a resource attached to an organization PostgreSQL says + does not own it. **Reported and never touched.** `organization` is a relation, + so an extra parent edge is additive and hands every owner and manager of the + named organization access to another tenant's resource; but removing it safely + means deleting a relation the resource legitimately needs one of, so it is left + for a human. Any non-zero count here is a privilege-escalation finding, not + routine drift. + + **Only `--scope=all` can find one.** The escalation is an edge on _another_ + tenant's resource that names the organization under investigation, and a + single-organization run reads only the resources PostgreSQL says that + organization owns — so the offending resource is never read. A + `--organization-id` run reporting `mismatchedParents: 0` therefore means "none + among this tenant's own resources", not "this tenant is not being targeted". + +A dry run over the whole deployment checks both directions per organization, +which costs a read per resource. An applying run skips the `missing` check — +its writes converge that direction anyway — and detects orphans with a single +streamed pass per resource type. + +The organization is the unit of work, so a partial run leaves complete graphs for +the organizations it finished rather than a fragment of every tenant's. Runs are +idempotent — relationships are written with `TOUCH` — so re-running is always safe +and is the intended response to a failed unit. + +**"No prune" does not mean "no deletes."** Converging a membership inherently +deletes the roles it does not hold. What `--prune` adds is permission to reconcile +records observed _only_ in SpiceDB. Even then no delete is precomputed: an +unsourced record becomes a reconciler _target_, and the reconciler re-reads +PostgreSQL before deciding, so a row recreated in the meantime is written rather +than deleted. + +Guards on the destructive path: + +- a dry run is the default, so a mistyped invocation is inert; +- `--prune` additionally requires `--apply`, `--confirm-prune`, an explicit scope, + and `--expected-endpoint`; +- `--expected-endpoint` must match `AUTHZED_ENDPOINT`. **`AUTHZED_SYSTEM_KEY` is + not usable for this** — it is a stable namespace and defaults to the same value + everywhere, so it cannot tell staging from production; +- exceeding the per-run prune cap (default 500, lowerable via `--max-prune`, never + raisable) prunes _nothing_ — not a capped subset. Every unit, the streamed sweep + included, counts its orphans to completion before deleting any of them, so the + cap aborts before the first delete rather than part-way through. A large orphan + count is a symptom — wrong endpoint, wrong database, a restore in progress — not + a big cleanup job; +- `survey`, `dashboard`, and `response` relationships are classified ignored, and + anything outside the vocabulary is reported but never touched. + +Two limits worth knowing before relying on a run: + +- `--organization-id` and `--workspace-id` report + `orphanScope: "known_resources"`. SpiceDB relationship filters have no notion of + "belongs to organization X" and Formbricks object IDs carry no organization + prefix, so a resource whose row is already gone is unreachable from its + organization. Only the default whole-deployment run sweeps by resource type and + can claim completeness. (`--scope=all` is a confirmation token for pruning + everything, not what selects the sweep — the sweep is the default.) +- The whole-deployment sweep assumes a SpiceDB dedicated to this deployment. + `AUTHZED_SYSTEM_KEY` is not yet used to namespace object IDs, so a + resource-type sweep cannot tell another installation's relationships from + orphans. + +Note also that the command reads `.env` and ignores `.env.local`, so the instance +it rewrites is not necessarily the one a local dev server talks to. Always pass +`--expected-endpoint` when pruning. + +Released Formbricks images include the equivalent `formbricks-authzed backfill` +command for self-hosted operators. Repository development retains +`pnpm authzed:backfill`. + +## Durable projection outbox + +Release images expose bounded, identifier-free outbox operations: + +```bash +formbricks-authzed outbox status +formbricks-authzed outbox drain +formbricks-authzed outbox drain --max-batches=500 +formbricks-authzed outbox replay +``` + +`status` reports aggregate pending, dead-letter, oldest-age, and revocation-age +counts. `drain` claims revocations first and stops after the requested number of +batches or the first batch that delivers nothing at all — a partially delivered +batch is the normal outcome once failures are attributed per group, so draining +stops on no progress rather than on any failure. `replay` resets all unresolved +dead letters to attempt zero and returns their full permanent-failure budget; it +does not bypass normal reconciliation, retry, or freshness checks. These commands +never print target IDs, relationships, credentials, or raw errors. + +Dead letters also clear themselves. A dead-lettered revocation has no age bound +in the freshness guard on purpose — an old one is more dangerous than a fresh one +— so the six-hour audit replays every unresolved dead letter whenever it comes +back `reconciled`, bounding a global denial at six hours rather than at whenever +an operator notices. A still-poisoned event simply dead-letters again. + +The recurring six-hour audit runs the normal full-deployment applying backfill +without prune. It can repair attributable missing and mismatched-permission +edges, but it never automatically deletes orphaned or unmanaged relationships or +changes a mismatched parent. Those categories still require the guarded operator +workflow above. Successfully delivered outbox rows are retained for seven days; +the scheduled audit removes at most 10,000 expired rows per run. Pending and +dead-letter rows are never removed by retention cleanup. + +## Self-hosted v6 upgrade gate + +Release images expose two aggregate-only orchestration commands: + +```bash +formbricks-authzed upgrade prepare +formbricks-authzed upgrade check +``` + +`prepare` requires `AUTHZED_ENABLED=true` and `AUTHZED_CONSISTENCY=fully_consistent`, checks authenticated +datastore health, applies an empty or guarded canonical schema, drains the outbox, runs attributable repair, and +audits the final graph. `check` repeats the health, schema, outbox, and full dry-run audit without writing. It +exits 0 only for a direct-authority-ready deployment, 2 when readiness is blocked by drift, and 1 for a failed +configuration or operation. Unlike the detailed backfill report, both commands emit aggregate counters only. + +## Deliberately not modeled (stays in application code) + +- Managers may only assign the `member` role when inviting/updating members. +- The billing role is rejected on self-hosted instances. +- `USER_MANAGEMENT_MINIMUM_ROLE` environment override. +- The coarse `hasUserWorkspaceAccess` layout check (billing routing concern). +- Organization-only API keys require per-route opt-in + (`allowOrganizationOnlyApiKey`). +- Audit logs: writing is feature-flagged; there is no in-app read path, so no + `read_audit_log` permission exists yet. diff --git a/authzed/RUNBOOK.md b/authzed/RUNBOOK.md new file mode 100644 index 000000000000..dc4c5740a7dc --- /dev/null +++ b/authzed/RUNBOOK.md @@ -0,0 +1,476 @@ +# AuthZed relationship sync runbook + +PostgreSQL is the source of truth for authorization facts. SpiceDB holds their relationship projection and +becomes the sole decision engine in the direct-authority artifact. + +The durable bridge inserts a PostgreSQL outbox row in the same transaction as every authorization-bearing +source mutation. Existing post-commit projection remains a low-latency fast path, while the outbox is the +recoverable delivery contract. BullMQ only wakes the worker; queue state and leases remain in PostgreSQL. + +> **Direct authority requires durable delivery.** A committed authorization mutation must enqueue its +> relationship reconciliation atomically, and a revocation that cannot be delivered within 60 seconds must make +> protected authorization fail closed. + +Everything below exists to make that visible and recoverable. + +See also: [direct AuthZed cutover and rollback contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad) for the approved release and rollback contract, +[README](./README.md) for the projection development contract, and +[AuthZed Operations](../docs/self-hosting/advanced/authzed-operations.mdx) for the public self-hosted operator +contract. + +## 1. Symptoms + +| What you see | What it usually means | +| -------------------------------------- | ------------------------------------------------------- | +| Outbox warning count is non-zero | A revocation has been pending for 15 seconds. | +| Outbox critical count is non-zero | A revocation has been pending for 45 seconds. | +| `authzed_projection_stale` | Revocation is 60 seconds old or dead-lettered. | +| Scheduled reconciliation reports drift | Attributable graph drift was found or repaired. | +| A dead letter is present | Ten solitary, event-attributable failures; investigate. | + +On the bridge artifact, legacy authorization is unaffected while durable delivery retries or repair converges +the graph. On the direct-authority artifact, operational AuthZed failures fail protected operations closed. + +## 2. Diagnosis + +### Is AuthZed reachable and correctly configured? + +```bash +pnpm authzed:health # 0 healthy, 1 otherwise +pnpm authzed:schema check # 0 matched, 2 drifted, 1 failed +formbricks-authzed outbox status # 0 healthy, 2 warning/critical, 1 failed +formbricks-authzed upgrade check # 0 direct-authority ready, 2 blocked, 1 failed +``` + +Note `status: "disabled"` from the health command exits **1**. A deployment that believes AuthZed is on +while `AUTHZED_ENABLED` is off looks healthy by every other signal, which is why the projection metric +records `disabled` as its own outcome rather than skipping. + +### Metrics + +All metrics carry only bounded attributes — never an organization, user, or relationship identifier. + +| Metric | Attributes | Read it as | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `formbricks_authzed_projection_total` | `operation`, `projection`, `status` | Projection outcomes. `status` is `projected` / `failed` / `disabled`. | +| `formbricks_authzed_projection_duration_seconds` | same | Projection latency. It sits on the request path, so a rise here is user-visible. `disabled` outcomes are deliberately excluded — their duration is a structural zero, not a measurement. | +| `formbricks_authzed_request_failures_total` | `operation`, `code`, `retryable` | Requests that exhausted their retry budget — _any_ facade call, including schema operations and reads, and one failed write can carry a whole batch. So a sample is one terminal request failure, **not** one dropped relationship. For "did projection drift get introduced?", use `formbricks_authzed_projection_total{status="failed"}`. | +| `formbricks_authzed_request_retries_total` | `operation`, `code` | Retries scheduled. Elevated but not failing = degraded, not down. | +| `formbricks_authzed_authorization_decisions_total` | `action`, `actor_type`, `error_code`, `outcome`, `resource_type`, `surface` | Authoritative SpiceDB allow, deny, and operational-error outcomes. `error_code` is `none` unless the outcome is `operational_error`; no actor, resource, or organization identifier is attached. | +| `formbricks_authzed_authorization_decision_duration_seconds` | `action`, `actor_type`, `outcome`, `resource_type`, `surface` | Authoritative scalar or list-decision latency. Use it for the direct-authority p95 and p99 rollout gates. | +| `formbricks_authzed_authorization_checks_per_request` | `surface` | How many central authorization operations one request made. Scalar `can()`/`assertCan()` calls and narrow list observations each count once. Watch the upper percentiles for a page regressing into one operation per row; a rising p99 on a list surface is the N+1 signal. Buckets start at 0.5 so "made no decisions" stays distinct from "made exactly one" — most healthy requests sit in the second bucket. No threshold is suggested yet: it needs a production baseline first. See [`PERFORMANCE.md`](./PERFORMANCE.md). | +| `formbricks_authzed_projection_outbox_delivery_total` | `status` | Durable outbox events delivered or failed. | +| `formbricks_authzed_projection_outbox_delivery_duration_seconds` | `status` | Duration of one claimed delivery batch. | +| `formbricks_authzed_projection_revocation_delivery_duration_seconds` | none | Time from a committed authorization revocation until its successful SpiceDB delivery. The 15-, 45-, and 60-second boundaries align with warning, critical, and fail-closed thresholds. | +| `formbricks_authzed_projection_outbox_status` | `state` | Current pending, dead-letter, 15-second warning, and 45-second critical counts. | +| `formbricks_authzed_projection_outbox_oldest_pending_age_seconds` | none | Current age of the oldest pending event. | +| `formbricks_authzed_reconciliation_audit_total` | `status` | Six-hour applying audit outcomes. | +| `formbricks_authzed_reconciliation_drift_total` | `kind` | Attributable drift and operational failures observed by scheduled audits. | +| `formbricks_authzed_reconciliation_repair_total` | `status` | Attributable relationships repaired or left failed by scheduled reconciliation; `status` is `repaired` or `failed`. | + +Exported through the readers already configured in `instrumentation-node.ts`: Prometheus when +`PROMETHEUS_ENABLED=1` (scraped by the chart's ServiceMonitor), OTLP when +`OTEL_EXPORTER_OTLP_ENDPOINT` is set. + +**The backfill command deliberately exports nothing.** It is a short-lived process with no scrape +window; its observability is the counters in its own JSON result and its exit code. + +### Logs + +Every AuthZed log line carries `component: "authzed"`. Failure lines share a stable field set, so one +query covers all of them: + +| Field | Present on | Meaning | +| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------------- | +| `component` | everything | Always `"authzed"`. | +| `operation` | everything | The facade operation or projection entry point. | +| `status` | projection outcomes | `projected` / `failed`. | +| `errorCode` | every failure | Sanitized `authzed_*` code. Never a raw error. | +| `retryable` | every failure | Whether a retry could have helped. | +| `durationMs` | requests and projections | — | +| `projection` | projection outcomes | Which projector: `organization_membership`, `team_workspace`, `api_key`. | +| `attempts` | projection failures | Total attempts behind the failure. | +| `attemptCount` | request failures/retries | Which attempt this line is about. Distinct from `attempts` above — request layer, not projection layer. | +| `grpcStatus` | request failures/retries | Numeric gRPC status. | +| `errorName` | post-commit boundary only | Error _class_ name when a projector itself threw. | + +``` +# Projections that failed — the drift signal. `status` is on projection outcomes only, which is what +# this wants: a request-layer failure inside a projection surfaces here too. +component:"authzed" AND status:"failed" + +# Any AuthZed failure of one kind, at either layer. +component:"authzed" AND errorCode:"authzed_unavailable" +``` + +**Identifiers never appear in logs.** No organization, user, team, workspace, or API-key ID; no schema +text; no tokens. If you need to know _which_ records drifted, that comes from the backfill's stdout +(see §3), not from logs. + +### Correlating with SpiceDB itself + +A projection-failure spike with healthy SpiceDB metrics points at the app side; both unhealthy points +at the datastore. Worth checking on the SpiceDB side: + +- **`pgxpool_empty_acquire`** — datastore connection starvation, the usual cause of + `authzed_unavailable` / `authzed_overloaded` bursts under load. AuthZed's guidance is to divide the + datastore's max connections by pod count, then split between read and write pools. +- SpiceDB dispatch and cache metrics from the operator's ServiceMonitor. +- `spicedb` pod restarts, and whether `spicedb datastore migrate` ran on the last upgrade. + +## 3. Recovery + +Escalate in this order. Every step is safe to repeat — relationships are written with `TOUCH`. + +**1. See what is wrong. Writes nothing.** + +```bash +pnpm authzed:backfill +``` + +Exit `0` clean, `2` drift remains, `1` failed. Read `counters`, `orphans`, and `failures` from the JSON. + +`0` covers **every** unrepaired category, not only the ones the tool fixes: `invalid` (source rows whose +principal and resource sit in different organizations) and `unmanaged` (relationships outside the +vocabulary) count toward drift alongside `orphaned`, `missing` and `mismatchedParents`. That matters +because this exit code is the gate for direct authority below — a run cannot report +clean while authorization state nothing accounts for is still present. + +**A non-zero `invalid` needs a human.** These are cross-organization source rows in PostgreSQL: the join +tables carry independent foreign keys and no same-organization constraint, so the row is representable +even though nothing in Formbricks creates one. The backfill will never project or prune them. Establish +how the row was written, then correct or delete it in PostgreSQL — after which a re-run reports clean. + +**2. Converge what PostgreSQL says should exist.** + +```bash +pnpm authzed:backfill --apply +``` + +This fixes dropped and wrong relationships. It does **not** remove relationships whose source row is +gone — expect `status: "drifted"` and a non-zero `orphaned` if any exist. + +**3. Remove what PostgreSQL no longer holds.** + +```bash +pnpm authzed:backfill --apply --prune --confirm-prune --scope=all \ + --expected-endpoint= +``` + +Before running this, read §4. + +**Scope it down when you can.** If the drift is one tenant, `--organization-id=` is the smaller +blast radius. It reports `orphanScope: "known_resources"` because a resource whose row is already gone +is unreachable from its organization; only `--scope=all` can claim completeness. + +**One workspace.** `--workspace-id=` is narrower still, and unlike an organization the workspace +does not have to exist — a workspace whose row is gone is the case most worth repairing: + +```bash +# Converge one workspace's team and API-key grants. +pnpm authzed:backfill --apply --workspace-id= + +# Remove the relationships of a workspace whose row is gone. This is a prune — every relationship on +# that workspace goes, team and API-key grants included — so it takes the prune flags, not --apply +# alone. Without them the run reports the orphans and removes nothing. +pnpm authzed:backfill --apply --prune --confirm-prune --workspace-id= \ + --expected-endpoint= +``` + +Narrow, but not hermetic: the API keys holding grants on that workspace are reconciled in full, which +also converges their grants on _other_ workspaces. That direction only ever writes what PostgreSQL says, +so it is a wider repair than you asked for. + +**Deletion is held to the workspace, but at a cost worth knowing.** A grant ref implies its principal, and +a principal with no PostgreSQL row makes the reconciler delete subject-wide — every workspace +relationship for that team, or every organization _and_ workspace relationship for that key. One orphan +here would then delete relationships in other tenants, none of it weighed against this run's cap. So this +scope **withholds** any grant whose team or API key is also gone: it stays counted in `orphaned`, nothing +is deleted for it, and the run finishes `drifted`. That cleanup belongs to `--organization-id` or +`--scope=all`, where the wider deletion is the intended unit of work. If a workspace run keeps reporting +orphans it will not prune, this is why — widen the scope. + +**Resuming.** A run reports `lastOrganizationId`. Feed it back: + +```bash +pnpm authzed:backfill --apply --after-organization-id= +``` + +**Per-unit failures.** One organization failing does not abort the sweep; it lands in `failures` with a +code, and the run exits `1`. Re-running is the fix — successful units simply re-converge. + +**`truncated: true`** means the counters are not exact, from one of two causes that err in opposite +directions. Either an observation was abandoned mid-read, so fewer relationships were seen than exist +and the counts are a floor — or the sweep's deduplication bound was exceeded, so records implied by +more than one page beyond that point are counted twice and the counts may over-report. The second only +happens at a scale orders of magnitude past the prune cap, so nothing is deleted on the strength of it. +Either way, re-run before concluding anything. + +### Reading the drift counters + +- **`missing`** — records PostgreSQL holds that SpiceDB has no relationship for. What an empty or stale + SpiceDB looks like. Step 2 fixes it. +- **`mismatchedPermissions`** — an existing source record has the wrong exact role, grant, or independent + access-flag relationship set. Treat a stale higher permission as a security finding. Step 2 converges + it; require a follow-up dry run with this counter at zero. +- **`orphaned`** — relationships whose source record is gone. Only step 3 removes them. +- **`mismatchedParents`** — **treat as a security finding, not routine drift.** A resource is attached to + an organization PostgreSQL says does not own it. `organization` is a relation, so the edge is _additive_: + every owner and manager of the named organization has access to that resource through + `organization->manage`, and no PostgreSQL row explains it. The backfill reports these and deliberately + never removes them, because deleting a parent edge means deleting a relation the resource legitimately + needs one of. Confirm the true owner in PostgreSQL, then remove the wrong edge by hand. + + **Read the reported `relation` first — it decides which command applies**, because two relationship + shapes state ownership and the organization sits on opposite sides of them: + + ```bash + # relation == "organization": the child's own parent edge. + zed relationship delete : organization organization: + + # any other relation (an organization-level API-key access grant, e.g. api_key_reader/api_key_writer): + # resource and subject are reversed. + zed relationship delete organization: : + ``` + + Running the first command against the second case deletes nothing and leaves the grant — still handing + `manage_access` over another tenant's organization — so check the field rather than assuming. + + Then re-run step 2 to confirm the correct edge is present, and work out how it was written — nothing in + Formbricks creates one. + + **Only `--scope=all` can find one.** The escalation is an edge on _another_ tenant's resource naming + the organization you are investigating, and a `--organization-id` run reads only the resources + PostgreSQL says that organization owns — so it never reads the offending resource. A single-tenant run + reporting `mismatchedParents: 0` means "none among this tenant's own resources", **not** "this tenant + is not being targeted". Investigating a suspected escalation means a full sweep. + +## 4. Before you prune + +`--prune` is the only destructive mode, and the guards are deliberately inconvenient. + +**Confirm which instance you are about to rewrite.** `--expected-endpoint` must match +`AUTHZED_ENDPOINT`. This is the guard against a stale `.env`, and it matters more than it looks: + +> **These commands load `.env` and ignore `.env.local`.** Next.js prefers `.env.local`, so the instance +> the CLI rewrites is not necessarily the one your dev server talks to. + +`AUTHZED_SYSTEM_KEY` is **not** usable for this. It is documented as a stable namespace and defaults to +`formbricks` everywhere, so it cannot tell staging from production. + +**"No prune" does not mean "no deletes."** Converging a membership inherently deletes the roles it does +not hold. What `--prune` adds is permission to reconcile records observed _only_ in SpiceDB. + +**A large orphan count is a symptom, not a workload.** Exceeding the per-run cap (500, lowerable with +`--max-prune`, never raisable) prunes _nothing_ — not a capped subset — and reports it. Every unit, +the streamed whole-deployment sweep included, counts its orphans to completion before deleting any of +them, so the cap aborts before the first delete rather than part-way through. Before raising your +expectations, check: right endpoint? right database? a restore in progress? `--scope=all` on a SpiceDB +shared with another installation? + +**Preconditions for `--scope=all`:** + +- a SpiceDB dedicated to this deployment — `AUTHZED_SYSTEM_KEY` does not yet namespace object IDs, so a + resource-type sweep cannot tell another installation's relationships from orphans; +- the sweep must finish inside `--datastore-gc-window` (24 h default on the Postgres datastore); a run + that outlives it reports `truncated`. + +## 5. When AuthZed is unavailable + +### Projection bridge before the durable outbox + +PostgreSQL stays authoritative and product authorization is unaffected, but every projection attempted during +the outage may be lost. Expect `authzed_unavailable` in logs and a rising failure counter. This bridge is not +eligible for direct authority. + +What matters is afterwards: + +1. Assume every projection attempted during the outage was dropped and will not be retried. +2. Once SpiceDB is healthy, `pnpm authzed:health` returns `healthy`. +3. Run the backfill (§3). Until it reports a clean run, assume the graph is incomplete. +4. Keep every direct-authority deployment blocked until the run is clean. + +To stop projecting entirely on this temporary bridge, set `AUTHZED_ENABLED=0`. Projections become no-ops and no +client is constructed; product authorization is untouched because it still runs on the legacy evaluator. Drift +accumulates for the whole period, so a full backfill is required before re-enabling. + +### Durable bridge and direct authority + +Source mutations commit a PostgreSQL outbox item atomically. SpiceDB outage does not roll back a +successful business mutation; delivery retries from PostgreSQL and BullMQ is only the recurring trigger. When +the service recovers: + +1. Run `formbricks-authzed health` and verify datastore migrations. +2. Inspect `formbricks-authzed outbox status` and correct the operational cause. +3. Run `formbricks-authzed outbox replay` when dead letters are understood, then + `formbricks-authzed outbox drain`. A dead letter can only be reached by an event that failed ten times on + its own, non-retryably, with a code an event can actually cause (`authzed_projection_invalid_source`, + `authzed_invalid_request`). An unreachable SpiceDB, a rejected credential and an unmapped internal error + all fail to qualify, so no outage produces a dead letter however long it lasts — treat any dead letter as + a real disagreement between PostgreSQL and SpiceDB rather than as fallout from the outage. + The six-hour audit also replays dead letters by itself whenever it comes back `reconciled`, so a global + `authzed_projection_stale` denial clears within six hours even if nobody intervenes. +4. Run the complete dry-run audit, apply attributable repair, and require two consecutive clean audits. +5. Keep direct-authority cutover blocked while a revocation is pending, dead-lettered, or older than its SLA. + +For a self-hosted major upgrade, investigate and replay any dead letters separately before starting the gate. +`formbricks-authzed upgrade prepare` drains the outbox, reconciles the graph, and runs a final audit, but it +deliberately blocks rather than replaying dead letters whose cause has not been understood. Always follow it with +the read-only `upgrade check`; do not treat a completed write phase as proof that concurrent source changes left +the graph clean. + +In the direct-authority artifact, a SpiceDB, datastore, resolver, configuration, freshness, or unsupported-result +failure is not an ordinary denial and never falls back. The protected operation receives a sanitized operational +failure and fails closed. Formbricks `/health`, startup, readiness, and liveness remain independent so unrelated +workloads are not restarted. + +## 6. Historical comparison controls (not a release strategy) + +The earlier shadow/cohort controls, freshness floor, comparison queue, and comparison metrics have been +removed from the direct-authority image. Their environment variables are rejected as unsupported deployment +configuration and must not be copied from an older sandbox manifest. Request surfaces remain only as bounded +telemetry attributes; they cannot select an evaluator. + +The direct-authority image contains no configuration switch back to PostgreSQL authorization. Rollback is a +deployment operation: redeploy the pinned bridge image, drain its durable outbox, and require a clean audit. +Historical comparison evidence is preserved in the project records, not as an executable runbook. + +## 7. Direct-authority cutover + +The full approval contract is the [direct AuthZed cutover and rollback contract](https://linear.app/formbricks/document/direct-authzed-cutover-and-rollback-contract-b4c352aecdad). This section is the operator's execution checklist. + +### Freeze the bridge artifact + +After the transactional outbox passes its crash, lease, duplicate, ordering, dead-letter, replay, outage, and +scheduled-repair tests: + +1. Build the bridge and record its source commit, immutable application digest, schema digest, Prisma migration + head, SpiceDB digest, and operator/chart versions. +2. Verify it reads the final outbox migration and canonical schema. +3. Keep legacy authorization authoritative, set `AUTHZED_CONSISTENCY=fully_consistent`, and remove shadow, + enforcement-target, cohort, and minimum-snapshot configuration. +4. Preserve this exact digest as the rollback artifact through production. + +### Establish the graph + +Deploy the bridge first. Drain the outbox, run a full dry-run audit, apply repair, and require two consecutive +clean audits plus one clean scheduled six-hour audit. Exercise mutation delivery while SpiceDB is unavailable, +restore it, and prove replay returns to a clean graph without a dead letter. + +### Cut an environment to direct authority + +1. Freeze authorization mutations for no more than 15 minutes. +2. Drain the outbox and run the final full audit. +3. Abort if both are not complete and clean within 10 minutes. +4. Deploy the exact approved direct-authority digest and verify every running image ID. +5. Verify `fully_consistent` configuration and confirm no legacy evaluator or migration rollout selector is + present. +6. Resume mutations and execute critical allow, deny, revocation, cross-tenant, list, API/MCP/UI, and failure + checks. + +The mandatory order is sandbox, staging, EU, and then KSA. Sandbox must pass rollback/forward recovery and 24 +continuous healthy hours. Staging must pass complete functional, restore, resilience, and capacity suites plus a +seven-day authoritative soak. EU must remain healthy for 24 hours before KSA begins. + +### Roll back + +1. Freeze authorization mutations again. +2. Capture bounded failure evidence. +3. Redeploy the exact pinned bridge digest and verify image IDs. +4. Verify legacy authority and durable outbox delivery. +5. Drain pending work and require a clean full audit. +6. Resume mutations. + +Do not downgrade the SpiceDB schema or outbox migration during a normal application rollback. Restore the +datastore only through the backup/restore runbook, then apply the guarded release schema and rebuild/repair the +graph before another cutover. + +Abort before or after cutover for a non-clean audit, pending/dead-letter/stale revocation, digest mismatch, +incomplete backups or restore evidence, unavailable rollback artifact, cross-tenant decision, unexpected allow or +deny, exceeded operational-error/latency budget, stopped outbox delivery, freshness-guard activation, or open +high/critical security finding. + +## 8. Alerting + +The checked-in metrics cover authoritative decisions, request amplification, SDK retries and terminal failures, +projection delivery, revocation propagation, queue state, scheduled drift, and repair results. Their required +thresholds are: + +- pending revocation warning at 15 seconds; +- pending revocation critical at 45 seconds; +- protected authorization fail-closed guard at 60 seconds; +- any dead-letter revocation is critical and blocks cutover; +- any scheduled residual drift is warning, and stale higher permission or cross-tenant drift is critical; and +- direct-authority operational-error rate above 0.1%, p95 above 250 ms, or p99 above one second blocks the staging + soak. + +The remaining delivery and SDK rules apply to both the bridge and the direct-authority artifact. Thresholds are +starting points — tune non-gate alerts to deployment size. + +```promql +# Critical: authoritative operations are failing, not denying. +sum(rate(formbricks_authzed_authorization_decisions_total{outcome="operational_error"}[5m])) +/ +sum(rate(formbricks_authzed_authorization_decisions_total[5m])) > 0.001 +# for: 5m + +# Warning/Critical: direct-authority latency exceeds the rollout SLO. +histogram_quantile(0.95, sum(rate(formbricks_authzed_authorization_decision_duration_seconds_bucket[5m])) by (le)) > 0.25 +# for: 15m +histogram_quantile(0.99, sum(rate(formbricks_authzed_authorization_decision_duration_seconds_bucket[5m])) by (le)) > 1 +# for: 5m + +# Warning at 15s; critical at 45s. At 60s the request-path freshness guard fails closed. +formbricks_authzed_projection_outbox_status{state="revocation_warning"} > 0 +formbricks_authzed_projection_outbox_status{state="revocation_critical"} > 0 + +# Critical: a dead letter or failed repair blocks cutover. +formbricks_authzed_projection_outbox_status{state="dead_lettered"} > 0 +sum(increase(formbricks_authzed_reconciliation_repair_total{status="failed"}[7h])) > 0 + +# Warning: scheduled audits still see attributable drift after repair. +sum(increase(formbricks_authzed_reconciliation_audit_total{status!="reconciled"}[7h])) > 0 + +# Warning: projections are failing. Drift is accumulating and a backfill will be needed. +sum(rate(formbricks_authzed_projection_total{status="failed"}[5m])) > 0 +# for: 15m + +# Critical: SpiceDB is unreachable rather than slow. +sum(rate(formbricks_authzed_request_failures_total{code="authzed_unavailable"}[5m])) > 0 +# for: 10m + +# Warning: degraded, not down. Often connection-pool pressure — check pgxpool_empty_acquire. +sum(rate(formbricks_authzed_request_retries_total[5m])) + / sum(rate(formbricks_authzed_projection_total[5m])) > 0.1 +# for: 15m + +# Critical: AuthZed is switched off where it is expected to be on. +sum(rate(formbricks_authzed_projection_total{status="disabled"}[15m])) > 0 +# for: 30m + +# Warning: projection latency is on the request path. +histogram_quantile(0.95, sum(rate(formbricks_authzed_projection_duration_seconds_bucket[5m])) by (le)) > 0.5 +# for: 15m +``` + +For projection-delivery alerts, first inspect and drain the durable outbox, then run the full audit and confirm a +clean result. On a pre-outbox bridge, run the backfill immediately because failed writes were not retained. +Decision operational errors instead require diagnosis by their source: application resolvers and configuration, +SpiceDB/datastore health, or transport credentials. Draining the outbox does not correct those failures. + +The application on-call owns decision, outbox, and reconciliation alerts. The infrastructure on-call owns +SpiceDB replicas, datastore, migrations, connection pools, dispatch, and cache health. Page both when an +authorization operational error cannot be cleared by restoring either delivery or SpiceDB health. + +A Helm `PrometheusRule` template shipping these by default is deliberately not part of this change — +that belongs with the AuthZed deployment contract rather than the application. + +## 9. Escalation + +| Situation | Action | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Backfill reports `failures` that persist across runs | Capture the `code` values and the run's JSON. A non-retryable code (`authzed_unauthenticated`, `authzed_permission_denied`, `authzed_invalid_request`) is a configuration problem, not a transient one. | +| Orphan count exceeds the cap and the endpoint is correct | Do not raise the cap. Establish why first — a wrong database or an in-progress restore both look like this. | +| `unmanaged` relationships reported | Something other than Formbricks is writing to this SpiceDB, or the schema moved ahead of its projector. Never pruned; investigate before enforcing. | +| Schema check reports `drifted` | `pnpm authzed:schema apply --expected-current-digest `. Relationship repair against a drifted schema is not meaningful. | +| Durable delivery or a clean graph cannot be restored | Block cutover. If already authoritative, roll back to the pinned bridge digest, drain/replay, and require a clean audit before another attempt. | diff --git a/authzed/schema-validation.yaml b/authzed/schema-validation.yaml new file mode 100644 index 000000000000..a492adca0d2d --- /dev/null +++ b/authzed/schema-validation.yaml @@ -0,0 +1,385 @@ +# Validation suite for the canonical Formbricks authorization schema. +# +# Run with `pnpm authzed:validate` (offline; uses `zed validate`, which shares +# its parser and evaluator with SpiceDB — no running server required). +# +# Every scenario mirrors the CURRENT application behavior (technical migration, +# no permission changes). Each section names the application rule it encodes; +# a failing assertion means a schema change altered the effective permissions +# of the current authorization system and must not ship unreviewed. +# +# Fixture graph: +# organization:acme +# owner user:olivia manager user:mark +# member user:mia (no team — must see no product data) +# billing user:bella (billing surfaces only — AND on team:product, see below) +# member user:tom, user:tina, user:uma (the team users; every TeamUser row in the +# product has a matching Membership, so the +# fixture models that rather than team rows +# floating free of the organization) +# api_key_reader api_key:org_reader_key (accessControl.read) +# api_key_writer api_key:org_writer_key (accessControl.write) +# workspace:main, workspace:other (both owned by acme) +# team:product admin user:tom, contributor user:tina, contributor user:bella +# -> readWrite on workspace:main +# bella is on this team deliberately. Without that row the scenario-2 billing assertions +# only prove the teamless case, and the schema could grant a billing member every workspace +# their teams reach while this suite stayed green — which is exactly what it used to do. +# team:analysts contributor user:uma, contributor user:ghost +# -> read on workspace:main +# ghost holds a team row but NO membership in acme — the state legacy denies at +# `if (!orgMembership) return false;` before it reaches the team ladder. No application path +# creates it today (every TeamUser write is org-validated), so this is defence in depth: the +# schema states the invariant instead of inheriting it from the writers. +# api_key:ci_reader -> read on workspace:main +# api_key:ci_writer -> write on workspace:main +# api_key:ci_manager -> manage on workspace:main +# survey:onboarding (workspace:main), survey:internal (workspace:other) +# dashboard:insights (workspace:main) +# response:r1 (survey:onboarding) + +schemaFile: schema.zed + +relationships: |- + organization:acme#owner@user:olivia + organization:acme#manager@user:mark + organization:acme#member@user:mia + organization:acme#billing@user:bella + organization:acme#member@user:tom + organization:acme#member@user:tina + organization:acme#member@user:uma + organization:acme#api_key_reader@api_key:org_reader_key + organization:acme#api_key_writer@api_key:org_writer_key + api_key:org_reader_key#organization@organization:acme + api_key:org_writer_key#organization@organization:acme + api_key:ci_reader#organization@organization:acme + api_key:ci_writer#organization@organization:acme + api_key:ci_manager#organization@organization:acme + workspace:main#organization@organization:acme + workspace:other#organization@organization:acme + team:product#organization@organization:acme + team:product#admin@user:tom + team:product#contributor@user:tina + team:product#contributor@user:bella + team:analysts#organization@organization:acme + team:analysts#contributor@user:uma + team:analysts#contributor@user:ghost + workspace:main#writer_team@team:product#member + workspace:main#reader_team@team:analysts#member + workspace:main#reader@api_key:ci_reader + workspace:main#writer@api_key:ci_writer + workspace:main#manager@api_key:ci_manager + feedback_directory:customer_feedback#organization@organization:acme + feedback_directory:customer_feedback#assignment@feedback_directory_assignment:fdwa_main + feedback_directory_assignment:fdwa_main#directory@feedback_directory:customer_feedback + feedback_directory_assignment:fdwa_main#workspace@workspace:main + survey:onboarding#workspace@workspace:main + survey:internal#workspace@workspace:other + dashboard:insights#workspace@workspace:main + response:r1#survey@survey:onboarding + +assertions: + assertTrue: + # ------------------------------------------------------------------ + # Scenario 1 — Owner and manager broad access. + # Org owners and managers have full product access to every workspace + # (organization.manage), and both manage + # members, billing, and API keys. Only the owner may update/delete the + # organization itself (verifyUserRoleAccess). + # ------------------------------------------------------------------ + - organization:acme#write@user:olivia + - organization:acme#manage@user:olivia + - organization:acme#manage@user:mark + - organization:acme#manage_access@user:olivia + - organization:acme#manage_access@user:mark + - organization:acme#manage_api_keys@user:mark + - organization:acme#manage_api_keys@user:olivia + - organization:acme#manage_billing@user:mark + # ENG-2409: read_access is the "holds a product-eligible membership role" permission, and it + # now gates five organization settings pages. Its owner/manager arms were never asserted — + # only the member arm below was — so the permission could have narrowed without a red suite. + - organization:acme#read_access@user:olivia + - organization:acme#read_access@user:mark + - workspace:main#manage@user:olivia + - workspace:main#manage@user:mark + - workspace:other#read@user:olivia + - workspace:other#manage@user:mark + - survey:onboarding#manage@user:olivia + - survey:onboarding#delete@user:olivia + - survey:onboarding#response_read@user:mark + - survey:onboarding#response_export@user:mark + - response:r1#write@user:olivia + - response:r1#manage@user:mark + - response:r1#export@user:olivia + - dashboard:insights#read@user:mark + - dashboard:insights#write@user:mark + - api_key:org_reader_key#manage@user:olivia + - api_key:org_reader_key#manage@user:mark + - feedback_directory:customer_feedback#manage@user:olivia + - feedback_directory:customer_feedback#manage@user:mark + - feedback_directory:customer_feedback#write@user:tina + - feedback_directory:customer_feedback#read@user:uma + - feedback_directory:customer_feedback#read@api_key:ci_reader + - feedback_directory_assignment:fdwa_main#write@user:tina + - feedback_directory_assignment:fdwa_main#read@api_key:ci_reader + # Members keep organization visibility (they belong to it) … + - organization:acme#read@user:mia + - organization:acme#read_access@user:mia + - team:product#read@user:mia + + # ------------------------------------------------------------------ + # Scenario 2 — Billing role blocked from product data. + # The billing role reaches billing surfaces only; every product gate + # (`workspace.read`) excludes it. + # ------------------------------------------------------------------ + - organization:acme#read@user:bella + - organization:acme#manage_billing@user:bella + + # ------------------------------------------------------------------ + # Scenario 3 — Team-based workspace access. + # Teams are the only path from a plain member to a workspace. + # WorkspaceTeam readWrite grants create/edit (and read); manage-level + # operations stay out of reach. Team admins manage the team itself. + # ------------------------------------------------------------------ + - workspace:main#write@user:tina + - workspace:main#read@user:tina + - survey:onboarding#read@user:tina + - survey:onboarding#write@user:tina + - survey:onboarding#delete@user:tina + - survey:onboarding#publish@user:tina + - survey:onboarding#response_read@user:tina + - survey:onboarding#response_export@user:tina + - response:r1#write@user:tina + - dashboard:insights#read@user:tina + - dashboard:insights#write@user:tina + - team:product#manage@user:tom + - team:product#member@user:tom + - team:product#read@user:tina + + # ------------------------------------------------------------------ + # Scenario 4 — Externally scoped (agency-style) read-only access. + # An external user is confined to one workspace through a read-level + # team. Today access is scoped per workspace, not per survey; export + # equals read (getResponsesDownloadUrlAction uses the read gate). + # ------------------------------------------------------------------ + - workspace:main#read@user:uma + - survey:onboarding#read@user:uma + - survey:onboarding#response_read@user:uma + - survey:onboarding#response_export@user:uma + - response:r1#read@user:uma + - response:r1#export@user:uma + + # ------------------------------------------------------------------ + # Scenario 5 — Dashboard read derives from workspace read. + # Dashboards carry no ACL of their own; whoever can read the + # workspace can read its dashboards (checkWorkspaceAccess). + # ------------------------------------------------------------------ + - dashboard:insights#read@user:uma + - dashboard:insights#read@user:olivia + - dashboard:insights#read@api_key:ci_reader + + # ------------------------------------------------------------------ + # Scenario 6 — API key as scoped principal. + # A workspace-scoped key acts only inside its granted workspace at its + # granted level (read -> GET). Organization-level accessControl rights + # govern org access-control endpoints and grant no product access. + # ------------------------------------------------------------------ + - workspace:main#read@api_key:ci_reader + - survey:onboarding#read@api_key:ci_reader + - survey:onboarding#response_read@api_key:ci_reader + - survey:onboarding#response_export@api_key:ci_reader + - organization:acme#read@api_key:org_reader_key + - organization:acme#read@api_key:org_writer_key + - organization:acme#read_access@api_key:org_reader_key + - organization:acme#read_access@api_key:org_writer_key + - organization:acme#manage_access@api_key:org_writer_key + - team:product#read@api_key:org_reader_key + - team:product#read@api_key:org_writer_key + - team:product#manage@api_key:org_writer_key + - team:product#delete@api_key:org_writer_key + - workspace:main#write@api_key:ci_writer + - survey:onboarding#write@api_key:ci_writer + - survey:onboarding#delete@api_key:ci_writer + - dashboard:insights#write@api_key:ci_writer + - response:r1#write@api_key:ci_writer + - workspace:main#manage@api_key:ci_manager + - survey:onboarding#manage@api_key:ci_manager + - survey:onboarding#delete@api_key:ci_manager + - dashboard:insights#write@api_key:ci_manager + - response:r1#manage@api_key:ci_manager + + assertFalse: + # Scenario 1 — managers must not update/delete the organization itself; + # a teamless member has organization visibility but zero product access. + - organization:acme#write@user:mark + - organization:acme#write@user:mia + - organization:acme#manage@user:mia + - organization:acme#manage_access@user:mia + - organization:acme#manage_api_keys@user:mia + # ENG-2409: the enterprise settings page maps `isMember -> notFound` onto `manage_billing`, and + # that mapping is only correct because a plain member does not hold it. `manage_billing` had no + # assertFalse anywhere — both mentions were assertTrue — so adding `+ member` to it in the schema + # would have kept this suite green while opening the billing surface to every member. Same shape + # as the read_access gap closed above. + - organization:acme#manage_billing@user:mia + - workspace:main#read@user:mia + - survey:onboarding#read@user:mia + - dashboard:insights#read@user:mia + - api_key:org_reader_key#manage@user:mia + - feedback_directory:customer_feedback#read@user:mia + - feedback_directory:customer_feedback#read@user:bella + - feedback_directory:customer_feedback#manage@user:tina + - feedback_directory_assignment:fdwa_main#manage@user:tina + - feedback_directory_assignment:fdwa_main#write@user:uma + - feedback_directory_assignment:fdwa_main#read@api_key:org_writer_key + + # Scenario 2 — billing is excluded from every product surface and from + # member/API-key management. + # + # bella holds a `team:product` contributor row, so these prove the exclusion survives team + # membership rather than only holding for a billing user who happens to be on no team. The + # first assertion pins the mechanism (`team#member` intersects the organization's non-billing + # membership); the rest are the product surfaces that would open if it regressed. + - team:product#member@user:bella + - organization:acme#manage@user:bella + - organization:acme#write@user:bella + - organization:acme#manage_access@user:bella + - organization:acme#manage_api_keys@user:bella + # ENG-2409: the load-bearing one. `read_access` is the only permission whose expansion is + # "holds a product-eligible membership role", which is what makes it the central expression of + # "not the billing role" — and it is what five organization settings pages now gate on. Until + # this line existed the suite had NO assertFalse for read_access at all, so adding `billing` to + # it in the schema kept every assertion green. Exactly the shape of the ENG-2340 gap: a + # guarantee written in prose and asserted nowhere. + - organization:acme#read_access@user:bella + - workspace:main#read@user:bella + - survey:onboarding#read@user:bella + - survey:onboarding#response_read@user:bella + - response:r1#read@user:bella + - response:r1#write@user:bella + - response:r1#manage@user:bella + - response:r1#export@user:bella + - dashboard:insights#read@user:bella + - dashboard:insights#write@user:bella + + # A team row without a membership in the team's organization grants nothing, mirroring + # legacy's `if (!orgMembership) return false;` ahead of the team ladder. + - team:analysts#member@user:ghost + - workspace:main#read@user:ghost + - survey:onboarding#read@user:ghost + - response:r1#export@user:ghost + + # Scenario 3 — readWrite teams stop below manage; team scope does not + # leak into other workspaces; contributors do not manage the team. + - workspace:main#manage@user:tina + - workspace:main#share@user:tina + - workspace:other#read@user:tina + - survey:internal#read@user:tina + - response:r1#manage@user:tina + - team:product#manage@user:tina + - team:product#delete@user:tom + + # Scenario 4 — read-level access is view-only and stays inside the + # granted workspace. + - workspace:main#write@user:uma + - survey:onboarding#write@user:uma + - survey:onboarding#delete@user:uma + - survey:onboarding#publish@user:uma + - survey:onboarding#manage@user:uma + - response:r1#write@user:uma + - response:r1#manage@user:uma + - dashboard:insights#write@user:uma + - workspace:other#read@user:uma + - survey:internal#read@user:uma + - team:product#member@user:uma + + # Scenario 5 — no workspace read, no dashboard read (covered for bella + # and mia above; org-only API keys likewise see no dashboards). + - dashboard:insights#read@api_key:org_writer_key + + # Scenario 6 — workspace keys must not exceed their level or workspace; + # organization-only keys have no product access; accessControl.read + # does not include accessControl.write. + - workspace:main#write@api_key:ci_reader + - workspace:main#manage@api_key:ci_reader + - workspace:main#share@api_key:ci_reader + - survey:onboarding#delete@api_key:ci_reader + - dashboard:insights#write@api_key:ci_reader + - response:r1#write@api_key:ci_reader + - response:r1#manage@api_key:ci_reader + - workspace:main#manage@api_key:ci_writer + - survey:onboarding#manage@api_key:ci_writer + - response:r1#manage@api_key:ci_writer + - workspace:other#read@api_key:ci_reader + - survey:internal#read@api_key:ci_reader + - workspace:main#read@api_key:org_writer_key + - workspace:main#read@api_key:org_reader_key + - survey:onboarding#read@api_key:org_writer_key + - organization:acme#manage_access@api_key:org_reader_key + - team:product#manage@api_key:org_reader_key + - team:product#delete@api_key:org_reader_key + - organization:acme#manage_api_keys@api_key:org_writer_key + # ENG-2409: a workspace-scoped key holds no organization-level access-control right. Asserted + # because `read_access` gained org-page gates and its api_key arm is granted by the + # `api_key_reader`/`api_key_writer` relations on the organization, which ci_reader lacks. + - organization:acme#read_access@api_key:ci_reader + +# Expected-relations documentation: enumerates, for key permissions, every +# subject that holds them and through which relation. Kept exact so any +# schema change that widens or narrows these permissions shows up as a diff. +validation: + workspace:main#read: + - "[api_key:ci_reader] is " + - "[api_key:ci_writer] is " + - "[api_key:ci_manager] is " + - "[team:analysts#member] is " + - "[team:product#member] is " + - "[user:mark] is " + - "[user:olivia] is " + - "[user:tina] is /" + - "[user:tom] is /" + - "[user:uma] is /" + # ENG-2409: both of these became organization page gates, so enumerate their subjects here as + # well. The assertions above prove the roles that must and must not hold them; this block is what + # turns a *widening* into a visible diff rather than a still-green suite. + organization:acme#read_access: + - "[api_key:org_reader_key] is " + - "[api_key:org_writer_key] is " + - "[user:mark] is " + - "[user:mia] is " + - "[user:olivia] is " + - "[user:tina] is " + - "[user:tom] is " + - "[user:uma] is " + organization:acme#manage_api_keys: + - "[user:mark] is " + - "[user:olivia] is " + # ENG-2409: `read` backs the getOrganizationAuth tenancy gate and `manage_billing` backs the + # enterprise settings page, so both are page-gate permissions now and both are enumerated for the + # same reason as the two above — an assertion proves a role holds it, only this block makes a + # *widening* show up as a diff. + organization:acme#read: + - "[api_key:org_reader_key] is " + - "[api_key:org_writer_key] is " + - "[user:bella] is " + - "[user:mark] is " + - "[user:mia] is " + - "[user:olivia] is " + - "[user:tina] is " + - "[user:tom] is " + - "[user:uma] is " + organization:acme#manage_billing: + - "[user:bella] is " + - "[user:mark] is " + - "[user:olivia] is " + response:r1#export: + - "[api_key:ci_reader] is " + - "[api_key:ci_writer] is " + - "[api_key:ci_manager] is " + - "[team:analysts#member] is " + - "[team:product#member] is " + - "[user:mark] is " + - "[user:olivia] is " + - "[user:tina] is /" + - "[user:tom] is /" + - "[user:uma] is /" diff --git a/authzed/schema.zed b/authzed/schema.zed new file mode 100644 index 000000000000..ccf9156da279 --- /dev/null +++ b/authzed/schema.zed @@ -0,0 +1,328 @@ +use typechecking + +/** + * Canonical Formbricks authorization schema. + * + * This schema is a technical migration of the CURRENT Formbricks authorization + * system to SpiceDB. It models exactly what the application enforces today and + * deliberately contains no future capabilities: a principal must never gain or + * lose access by moving a check from the application code to this schema. + * + * Every permission documents the application rule it mirrors. The semantics are + * asserted by `authzed/schema-validation.yaml`; run `pnpm authzed:validate`. + */ + +/** A human principal, identified by the Prisma `User.id`. */ +definition user {} + +/** + * An API key principal, identified by the Prisma `ApiKey.id`. + * + * API keys are organization-owned machine principals. They receive product + * access exclusively through explicit per-workspace grants (`ApiKeyWorkspace`) + * and organization-level access-control rights (`ApiKey.organizationAccess`). + */ +definition api_key { + /** The organization that owns this API key (`ApiKey.organizationId`). */ + relation organization: organization + + /** Who may view this API key: organization owners and managers. */ + permission read: user = organization->manage_api_keys + + /** Who may update or delete this API key: organization owners and managers. */ + permission manage: user = organization->manage_api_keys +} + +/** + * The billing and membership boundary, identified by the Prisma + * `Organization.id`. Roles mirror `Membership.role` (exactly one per user). + */ +definition organization { + /** Organization owner (`Membership.role = owner`). Full control, including organization update/delete. */ + relation owner: user + + /** + * Organization manager (`Membership.role = manager`). Full product access and + * member/billing/API-key management, but no organization update/delete. + */ + relation manager: user + + /** + * Organization member (`Membership.role = member`). No product access on its + * own; workspace access comes only through team membership. + */ + relation member: user + + /** + * Billing-only role (`Membership.role = billing`, Formbricks Cloud only). + * Sees billing surfaces exclusively; excluded from all product data. + */ + relation billing: user + + /** API key with `organizationAccess.accessControl.read` — may read org-level access-control resources. */ + relation api_key_reader: api_key + + /** API key with `organizationAccess.accessControl.write` — may manage org-level access-control resources. */ + relation api_key_writer: api_key + + /** Who can see the organization exists (any membership role or org-scoped API key). */ + permission read: user | api_key = owner + manager + member + billing + api_key_reader + api_key_writer + + /** + * Who can update or delete the organization record itself. Owner only — + * mirrors `verifyUserRoleAccess` (managers lack org create/update/delete). + */ + permission write: user = owner + + /** + * Full product access across all of the organization's workspaces. This is + * the arrow target that grants owners and managers unrestricted access to + * every workspace, survey, response, and dashboard. + */ + permission manage: user = owner + manager + + /** Who can access billing: owners, managers, and the billing role (`verifyUserRoleAccess.hasBillingAccess`). */ + permission manage_billing: user = owner + manager + billing + + /** + * Who can read organization access-control resources such as teams and + * workspace-team assignments. The management API accepts + * `accessControl.read` and `accessControl.write` keys for these GET routes. + */ + permission read_access: user | api_key = owner + manager + member + api_key_reader + api_key_writer + + /** + * Who can manage members, invites, teams, and workspace-team access: + * owners and managers (`USER_MANAGEMENT_MINIMUM_ROLE`, team actions), plus + * API keys holding `accessControl.write` (org access-control endpoints). + * Finer rules (managers may only assign the `member` role) stay in the app. + */ + permission manage_access: user | api_key = owner + manager + api_key_writer + + /** Who can create, update, or delete API keys: owners and managers (api-keys settings actions). */ + permission manage_api_keys: user = owner + manager + + /** + * Members eligible for product data: every membership role EXCEPT `billing`. + * + * This exists to be intersected into `team#member`, so that team-derived workspace access + * carries the same two preconditions the application enforces before it consults the team + * ladder at all: the principal must hold a membership + * in this organization, and that membership must not be `billing`. + */ + permission product_member: user = owner + manager + member +} + +/** + * A group of users inside one organization, identified by the Prisma `Team.id`. + * Teams are the only mechanism that grants users access to workspaces. + */ +definition team { + /** The organization this team belongs to (`Team.organizationId`). */ + relation organization: organization + + /** Team admin (`TeamUser.role = admin`) — may manage the team itself. */ + relation admin: user + + /** Team contributor (`TeamUser.role = contributor`). */ + relation contributor: user + + /** + * All members of the team; carries workspace access granted to the team. + * + * Intersected with the organization's non-billing membership because a `TeamUser` row is not + * sufficient on its own in the application. The current authorization contract requires two + * gates before it considers `WorkspaceTeam`: + * + * if (!orgMembership) return false; + * if (orgMembership.role === "billing") return false; + * + * Without the intersection this schema would grant a billing-role member every workspace their + * teams can reach — surveys, responses, contact PII — which the application denies, breaking + * this file's own rule that moving a check here must not change who can access what. + */ + permission member: user = (admin + contributor) & organization->product_member + + /** + * Who can view the team: its members, organization owners/managers, and API + * keys with either read or write organization access-control permission. + */ + permission read: user | api_key = member + organization->read_access + + /** Who can manage the team (rename, membership): team admins plus organization access-control writers. */ + permission manage: user | api_key = admin + organization->manage_access + + /** + * Who can delete the team. Unlike other team mutations, deletion is limited + * to organization owners/managers and `accessControl.write` API keys. + */ + permission delete: user | api_key = organization->manage_access +} + +/** + * The product container (surveys, responses, dashboards, contacts …), + * identified by the Prisma `Workspace.id`. + * + * Users are never granted workspace access directly — only via teams + * (`WorkspaceTeam`) or their organization role (owner/manager). API keys are + * the only principals with direct grants (`ApiKeyWorkspace.permission`). + */ +definition workspace { + /** The owning organization (`Workspace.organizationId`). */ + relation organization: organization + + /** API key with `ApiKeyWorkspace.permission = read` — GET-level API access. */ + relation reader: api_key + + /** API key with `ApiKeyWorkspace.permission = write` — POST/PUT/PATCH-level API access. */ + relation writer: api_key + + /** API key with `ApiKeyWorkspace.permission = manage` — full API access including DELETE. */ + relation manager: api_key + + /** Teams granted `WorkspaceTeam.permission = read` (read-only workspace access). */ + relation reader_team: team#member + + /** Teams granted `WorkspaceTeam.permission = readWrite` (create/edit surveys). */ + relation writer_team: team#member + + /** Teams granted `WorkspaceTeam.permission = manage` (full workspace control). */ + relation manager_team: team#member + + /** + * Full workspace control: DELETE-level operations, workspace settings, and + * team-access management. Held by manage-level API keys and teams, and by + * organization owners/managers (`organization.manage`). + */ + permission manage: user | api_key = manager + manager_team + organization->manage + + /** Create and edit content in the workspace (readWrite level and above). */ + permission write: user | api_key = writer + writer_team + manage + + /** View the workspace and its content (read level and above). */ + permission read: user | api_key = reader + reader_team + write + + /** + * Manage who has access to the workspace (workspace team assignments). + * Requires manage today — mirrors the workspace access-management gate. + */ + permission share: user | api_key = manage +} + +/** + * A feedback dataset, identified by Prisma `FeedbackDirectory.id`. + * + * Owners and managers administer every dataset in their organization. Other + * principals receive access only through an active workspace assignment. + */ +definition feedback_directory { + relation organization: organization + relation assignment: feedback_directory_assignment + + permission administrator: user = organization->manage + permission read: user | api_key = administrator + assignment->read + permission write: user | api_key = administrator + assignment->write + permission manage: user | api_key = administrator + assignment->manage +} + +/** + * The exact `FeedbackDirectoryWorkspace` pair. The object ID is a deterministic + * digest of the directory/workspace IDs and is never a public product ID. + */ +definition feedback_directory_assignment { + relation directory: feedback_directory + relation workspace: workspace + + permission read: user | api_key = directory->administrator + workspace->read + permission write: user | api_key = directory->administrator + workspace->write + permission manage: user | api_key = directory->administrator + workspace->manage +} + +/** + * A survey, identified by the Prisma `Survey.id`. Surveys carry no access + * control of their own today (`resultShareKey` was removed): every permission + * derives from the owning workspace. + * + * `response_read` and `response_export` currently both equal workspace read — + * the export gate (`getResponsesDownloadUrlAction`) is identical to the + * response-view gate. They are separate permissions so response access keeps a + * stable vocabulary; splitting them later is a schema change that the + * assertions in `schema-validation.yaml` will surface. + */ +definition survey { + /** The owning workspace (`Survey.workspaceId`). */ + relation workspace: workspace + + /** View the survey. */ + permission read: user | api_key = workspace->read + + /** Create or edit the survey (workspace readWrite level). */ + permission write: user | api_key = workspace->write + + /** + * Manage-only survey operations, including language configuration and + * DELETE through the legacy management APIs. + */ + permission manage: user | api_key = workspace->manage + + /** + * Delete through the Formbricks web application and V3 API. Both currently + * use the workspace readWrite gate rather than the manage gate. + */ + permission delete: user | api_key = workspace->write + + /** Publish the survey / change its status — part of survey editing today. */ + permission publish: user | api_key = workspace->write + + /** View the survey's responses and summaries. */ + permission response_read: user | api_key = workspace->read + + /** Download/export the survey's responses (CSV/XLSX) — same gate as viewing today. */ + permission response_export: user | api_key = workspace->read +} + +/** + * An analysis dashboard, identified by the Prisma `Dashboard.id`. Dashboards + * carry no access control of their own today: access derives from the owning + * workspace (`checkWorkspaceAccess`). + */ +definition dashboard { + /** The owning workspace (`Dashboard.workspaceId`). */ + relation workspace: workspace + + /** View the dashboard and its widgets. */ + permission read: user | api_key = workspace->read + + /** + * Create, update, duplicate, or delete the dashboard and mutate its widgets. + * Every dashboard mutation currently uses the workspace readWrite gate. + */ + permission write: user | api_key = workspace->write +} + +/** + * A survey response, identified by the Prisma `Response.id`. Responses belong + * to a survey and inherit all access from it. + */ +definition response { + /** The survey this response belongs to (`Response.surveyId`). */ + relation survey: survey + + /** View the response. */ + permission read: user | api_key = survey->response_read + + /** + * Update a response and perform web-session mutations such as tagging or + * deleting it. These operations currently use the workspace readWrite gate. + */ + permission write: user | api_key = survey->write + + /** + * Delete a response through the legacy management APIs, whose DELETE method + * mapping currently requires workspace manage permission. + */ + permission manage: user | api_key = survey->manage + + /** Export the response. */ + permission export: user | api_key = survey->response_export +} diff --git a/authzed/validate.sh b/authzed/validate.sh new file mode 100755 index 000000000000..c42c24dd92da --- /dev/null +++ b/authzed/validate.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# Validates the canonical Formbricks authorization schema (schema.zed) against +# the assertion suite in schema-validation.yaml using `zed validate`. +# +# Runs fully offline — no SpiceDB server required. Uses a local `zed` binary +# when available, otherwise the pinned zed container image (the same pin as the +# authzed-cli service in docker-compose.dev.yml). + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly VALIDATION_FILE="schema-validation.yaml" +readonly ZED_IMAGE="${ZED_IMAGE_REF:-authzed/zed:v1.1.1}" + +if command -v zed >/dev/null 2>&1; then + zed validate "${SCRIPT_DIR}/${VALIDATION_FILE}" +elif command -v docker >/dev/null 2>&1; then + docker run --rm \ + --volume "${SCRIPT_DIR}:/authzed:ro" \ + "${ZED_IMAGE}" \ + validate "/authzed/${VALIDATION_FILE}" +else + printf '%s\n' "Neither a zed binary nor docker is available. Install zed (https://github.com/authzed/zed) or start Docker." >&2 + exit 1 +fi diff --git a/charts/formbricks/Chart.lock b/charts/formbricks/Chart.lock index 0bbd73d4b70a..9cd8fe7e3352 100644 --- a/charts/formbricks/Chart.lock +++ b/charts/formbricks/Chart.lock @@ -1,4 +1,7 @@ dependencies: +- name: spicedb-operator + repository: file://../spicedb-operator + version: 0.1.0 - name: postgresql repository: oci://registry-1.docker.io/bitnamicharts version: 16.4.16 @@ -11,5 +14,5 @@ dependencies: - name: vllm-stack repository: https://vllm-project.github.io/production-stack version: 0.1.11 -digest: sha256:d3f3f8395f197fa3f91516e0dbf1ec49f48a7eea6e7e3d5dc2c242588c5c27f7 -generated: "2026-06-13T01:11:28.264456+05:30" +digest: sha256:773f39357be58c0ed738ac9b42d95267ec9aeb6b12b2ac59667619005616eb7d +generated: "2026-07-13T12:42:44.617964+05:30" diff --git a/charts/formbricks/Chart.yaml b/charts/formbricks/Chart.yaml index 1aac64f64539..4b43b7f6adf1 100644 --- a/charts/formbricks/Chart.yaml +++ b/charts/formbricks/Chart.yaml @@ -16,6 +16,11 @@ maintainers: - name: Formbricks email: info@formbricks.com dependencies: + - name: spicedb-operator + alias: spicedbOperator + version: "0.1.0" + repository: "file://../spicedb-operator" + condition: authzed.operator.install - name: postgresql version: "16.4.16" repository: "oci://registry-1.docker.io/bitnamicharts" diff --git a/charts/formbricks/README.md b/charts/formbricks/README.md index 8d1c8723b655..955cfef1201d 100644 --- a/charts/formbricks/README.md +++ b/charts/formbricks/README.md @@ -56,6 +56,187 @@ or provide equivalent edge rate limiting for the documented route coverage. The least two for availability during voluntary disruptions, or change/disable the PDB for an intentional single-replica deployment. +## AuthZed / SpiceDB + +Formbricks v6 enables AuthZed, `fully_consistent` authorization, and the bundled SpiceDB operator by default. + +### Breaking changes from v5 + +| Setting | v5 default | v6 default | Existing shared-operator clusters | +| -------------------------- | ---------- | ---------- | ------------------------------------------------------------------------------- | +| `authzed.operator.install` | `false` | `true` | Set `authzed.operator.install=false` before upgrading to avoid duplicate reconcilers. | + +For a cluster where a compatible operator already watches the Formbricks namespace: + +```yaml +authzed: + operator: + install: false +``` + +The default installs the pinned SpiceDB operator, creates a two-replica `SpiceDBCluster`, and creates a dedicated +`spicedb` database and role in the bundled PostgreSQL server. During normal Helm installs and upgrades, the +chart reuses generated credentials from the existing cluster Secret. Renderers without live Secret access, +including offline `helm template` and Argo CD manifest generation, must provide persistent credentials through +`authzed.auth.existingSecret` and `authzed.datastore.existingSecret`; otherwise generated values are not stable +between renders. The operator runs datastore migrations before rolling out SpiceDB. + +The bundled PostgreSQL dependency uses the following resource baseline, which was validated while PostgreSQL +also served SpiceDB: + +```yaml +postgresql: + primary: + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi +``` + +Helm cannot condition values passed to the PostgreSQL dependency on a sibling value, so the safe database +baseline remains in effect. Override `authzed.cluster.resources` and `postgresql.primary.resources` to match the +expected authorization traffic and the other workloads using the bundled database. + +Install only one operator per Kubernetes cluster. When a platform-managed operator already watches the Formbricks +namespace, keep `authzed.operator.install=false`; the Formbricks release still owns its `SpiceDBCluster`. +Kubernetes does not upgrade CRDs during a normal Helm upgrade. When changing the bundled operator version, apply +the matching `charts/spicedb-operator/crds/authzed.com_spicedbclusters.yaml` before upgrading the release. + +For managed PostgreSQL, create a dedicated database and login outside Helm and expose these keys in a Kubernetes +Secret: + +```yaml +stringData: + datastore_uri: postgresql://spicedb:@postgres.example:5432/spicedb?sslmode=require + preshared_key: +``` + +Alternatively, the chart can create the dedicated database and login with a short-lived bootstrap Job. Put a +PostgreSQL administrator URL in a separate Secret and enable the external bootstrap: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: formbricks-postgresql-admin +stringData: + DATABASE_URL: postgresql://postgres:@postgres.example:5432/postgres?sslmode=require +``` + +```yaml +authzed: + externalPostgresqlBootstrap: + enabled: true + adminSecretName: formbricks-postgresql-admin +``` + +The administrator URL must explicitly set `sslmode=require`, `sslmode=verify-ca`, or `sslmode=verify-full`. +The bootstrap Job validates the TLS mode without logging the URL and passes the URL through unchanged, so other +connection parameters and certificate settings are preserved. + +### Bootstrapping against an existing PostgreSQL without a `postgres` role + +The bundled bootstrap connects as the `postgres` superuser the subchart normally creates. An existing +installation deployed with `postgresql.auth.enablePostgresUser=false` has no such role, so point the Job at a +role that does hold `CREATEROLE` and `CREATEDB` instead: + +```yaml +authzed: + bundledPostgresqlBootstrap: + adminUsername: fbadmin + adminDatabase: formbricks # the maintenance database to attach to + adminPasswordSecretName: existing-pg-admin + adminPasswordKey: password +``` + +`adminPasswordSecretName` is required whenever `adminUsername` is not the bundled superuser, and +`adminPasswordKey` whenever that Secret is configured explicitly. **Both are enforced when the chart renders**, +so a missing one fails `helm template`/`upgrade` with a named value rather than producing a Job. That is the +point of the guards: without them the first would silently fall back to the bundled admin password and the +second would look up the subchart's key name inside your own Secret — neither of which surfaces until the Pod +is created in the cluster. + +`CREATEROLE` and `CREATEDB` cover the normal case, in which this administrator also creates the `spicedb` role. +`CREATE DATABASE ... OWNER spicedb` additionally requires being able to `SET ROLE` to that owner, so the Job +grants itself the `spicedb` role first; from PostgreSQL 16 that is only possible for a role it holds +`ADMIN OPTION` on, which creating the role confers. A `spicedb` role that already exists and was created by +someone else is therefore the one case the Job cannot adopt on PostgreSQL 16+ — grant it explicitly +(`GRANT spicedb TO fbadmin WITH ADMIN OPTION`) or run the bootstrap once as a superuser. PostgreSQL 15 and +older are unaffected. + +Re-running is safe but not inert: the role and the database are created only when absent, while the `spicedb` +role's password is reconciled to the chart's Secret on **every** run. If you rotate that password outside Helm, +update the Secret too, or the next upgrade will set it back. +`authzed.bundledPostgresqlBootstrap.enabled=false` remains available for operators who provision both by hand. + +Then reference it from the release: + +```yaml +authzed: + enabled: true + mode: selfHosted + auth: + existingSecret: formbricks-authzed + datastore: + existingSecret: formbricks-authzed +``` + +To connect to an AuthZed-managed or otherwise external endpoint, use TLS, provide the endpoint without a URL +scheme, and reference a Secret containing `preshared_key`: + +```yaml +authzed: + enabled: true + mode: external + operator: + install: false + endpoint: grpc.authzed.com:443 + insecure: false + auth: + existingSecret: formbricks-authzed +``` + +`authzed.insecure` defaults to `false` in external mode. Set it to `true` only for a trusted plaintext gRPC +endpoint; plaintext transport sends the preshared token without TLS protection. The chart injects `AUTHZED_ENABLED`, +`AUTHZED_ENDPOINT`, `AUTHZED_TOKEN`, `AUTHZED_SYSTEM_KEY`, `AUTHZED_INSECURE`, and `AUTHZED_CONSISTENCY` into +the Formbricks app. Authorization checks must fail closed once product enforcement is enabled; general +Formbricks readiness remains independent from transient SpiceDB availability. + +Fresh installs run a release-matched post-install initialization Job that applies the canonical schema and verifies +the empty or reconciled graph. An acknowledged existing release runs the same release-matched gate as a pre-upgrade +hook; unacknowledged upgrades are rejected before rendering. Before the first v6 upgrade, run: + +```bash +kubectl exec -n deployment/ -- formbricks-authzed health +kubectl exec -n deployment/ -- formbricks-authzed schema check +kubectl exec -n deployment/ -- formbricks-authzed upgrade prepare +kubectl exec -n deployment/ -- formbricks-authzed upgrade check + +# Empty instances only +kubectl exec -n deployment/ -- formbricks-authzed schema apply + +# Non-empty instances: use the remoteDigest returned by the immediately preceding check +kubectl exec -n deployment/ -- formbricks-authzed schema apply \ + --expected-current-digest sha256: +``` + +The initial apply to an empty instance needs no digest. A non-empty instance must first be checked and then +prepared with `--expected-current-digest sha256:`. Once `upgrade check` exits `0`, set +`authzed.migrationAcknowledged=true` in the v6 upgrade values. The chart refuses an unacknowledged upgrade, +`authzed.enabled=false`, or consistency other than `fully_consistent`. Back up the current schema and affected +relationships before replacement; see the repository `authzed/README.md` for exit codes and rollback rules. +The public [AuthZed operations guide](../../docs/self-hosting/advanced/authzed-operations.mdx) covers backups, +restoration, schema lifecycle, relationship repair, and monitoring. + +Cloud operators that run the same guarded schema, outbox drain, reconciliation, and audit sequence outside Helm +may set `authzed.initialization.enabled=false` together with `authzed.migrationAcknowledged=true`. This suppresses +the initialization hook so a GitOps sync cannot mutate the authorization graph outside the controlled cutover +window. The acknowledgement must be set only after the external preparation succeeds. Fresh self-hosted installs +should keep the default initialization Job enabled. + ## Cube Cube is part of the baseline Formbricks v5 stack and is deployed by this chart by default @@ -561,6 +742,7 @@ tokens, provider response bodies, and collector URLs are never telemetry fields. | externalSecret.refreshInterval | string | `"1h"` | | | externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | | | externalSecret.secretStore.name | string | `"aws-secrets-manager"` | | +| formbricks.mcpOauthJwksUrl | string | `""` | Optional internal JWKS fetch URL for trusted application networks. | | formbricks.publicUrl | string | `""` | | | formbricks.webappUrl | string | `""` | | | hub.autoscaling.enabled | bool | `false` | | @@ -696,6 +878,7 @@ tokens, provider response bodies, and collector URLs are never telemetry fields. | pdb.enabled | bool | `true` | | | pdb.minAvailable | int | `1` | | | postgresql.auth.database | string | `"formbricks"` | | +| postgresql.auth.enablePostgresUser | bool | `true` | Required by the bundled AuthZed database bootstrap. | | postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | | | postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | | | postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | | @@ -716,6 +899,10 @@ tokens, provider response bodies, and collector URLs are never telemetry fields. | postgresql.primary.podSecurityContext.enabled | bool | `true` | | | postgresql.primary.podSecurityContext.fsGroup | int | `1001` | | | postgresql.primary.podSecurityContext.runAsUser | int | `1001` | | +| postgresql.primary.resources.limits.cpu | string | `"1"` | | +| postgresql.primary.resources.limits.memory | string | `"1Gi"` | | +| postgresql.primary.resources.requests.cpu | string | `"250m"` | | +| postgresql.primary.resources.requests.memory | string | `"512Mi"` | | | rbac.enabled | bool | `false` | | | rbac.serviceAccount.additionalLabels | object | `{}` | | | rbac.serviceAccount.annotations | object | `{}` | | diff --git a/charts/formbricks/charts/spicedb-operator-0.1.0.tgz b/charts/formbricks/charts/spicedb-operator-0.1.0.tgz new file mode 100644 index 000000000000..463e60206f92 Binary files /dev/null and b/charts/formbricks/charts/spicedb-operator-0.1.0.tgz differ diff --git a/charts/formbricks/cube/schema/FeedbackRecords.js b/charts/formbricks/cube/schema/FeedbackRecords.js index cd646dff6c81..4aad61999edd 100644 --- a/charts/formbricks/cube/schema/FeedbackRecords.js +++ b/charts/formbricks/cube/schema/FeedbackRecords.js @@ -6,19 +6,19 @@ cube(`FeedbackRecords`, { measures: { count: { type: `count`, - description: `Total number of feedback responses`, + description: `Total number of feedback records`, }, uniqueRespondents: { type: `countDistinct`, sql: `${CUBE}.user_id`, - description: `Number of unique users who provided feedback`, + description: `Unique identified people who gave feedback, deduplicated by person — one respondent answering 3 questions counts once. Anonymous feedback (no identified respondent) isn't counted here, even though it counts as a Feedback Record.`, }, uniqueResponses: { type: `countDistinct`, sql: `${CUBE}.submission_id`, - description: `Number of unique survey submissions (a submission can produce multiple feedback records)`, + description: `Unique survey submissions, deduplicated by submission — one respondent submitting twice counts twice`, }, promoterCount: { @@ -325,13 +325,13 @@ cube(`FeedbackRecords`, { valueText: { sql: `value_text`, type: `string`, - description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`, + description: `Text answer value (open text, or the label of a multiple-choice/categorical answer). Buckets by the exact text, so a translated label, an edited label or a free-text 'other' answer each becomes its own bucket — for choice questions prefer valueId. Pair with a fieldType filter to keep types consistent.`, }, valueId: { sql: `value_id`, type: `string`, - description: `Stable id of a selected choice (single/multi-select). Group by this instead of valueText to consolidate the same option across languages / after a label edit.`, + description: `Recommended for single-select and multi-select answers: the stable option id keeps one option in one bucket across languages, after a label edit, and for free-text 'other' answers. Charts show the option's label, not the id.`, }, valueBoolean: { diff --git a/charts/formbricks/templates/NOTES.txt b/charts/formbricks/templates/NOTES.txt index dddcc988802f..8af07adb38b4 100644 --- a/charts/formbricks/templates/NOTES.txt +++ b/charts/formbricks/templates/NOTES.txt @@ -52,10 +52,7 @@ Database (PostgreSQL) Access: - **Username**: `{{ .Values.postgresql.auth.username }}` {{- else if .Values.postgresql.externalDatabaseUrl }} You're using an external PostgreSQL database. - Connection URL: - ```sh - echo "{{ .Values.postgresql.externalDatabaseUrl }}" - ``` + The connection URL is intentionally omitted from these notes because it may contain credentials. {{- end }} --- @@ -174,6 +171,36 @@ External Secrets: --- +{{- if .Values.authzed.enabled }} +AuthZed / SpiceDB Operations: + + SpiceDB is configured in `{{ .Values.authzed.mode }}` mode. It is not part of the Formbricks + readiness probe, and its gRPC endpoint should remain private. + + Check the release-matched client connection: + ```sh + kubectl exec -n {{ .Release.Namespace }} deployment/{{ include "formbricks.name" . }} -- formbricks-authzed health + ``` + + Check whether the installed authorization schema matches this Formbricks release: + ```sh + kubectl exec -n {{ .Release.Namespace }} deployment/{{ include "formbricks.name" . }} -- formbricks-authzed schema check + ``` + + Before upgrading an existing installation to v6, complete the release-matched preparation and + read-only gate, then set `authzed.migrationAcknowledged=true`: + ```sh + kubectl exec -n {{ .Release.Namespace }} deployment/{{ include "formbricks.name" . }} -- formbricks-authzed upgrade prepare + kubectl exec -n {{ .Release.Namespace }} deployment/{{ include "formbricks.name" . }} -- formbricks-authzed upgrade check + ``` + + Schema application and relationship repair are explicit operator actions and never run as Helm + hooks. Read the safeguards and backup requirements before changing authorization state: + https://formbricks.com/docs/self-hosting/advanced/authzed-operations + +--- +{{- end }} + Persistence: {{- if .Values.postgresql.enabled }} diff --git a/charts/formbricks/templates/_helpers.tpl b/charts/formbricks/templates/_helpers.tpl index ff12871e3de9..3c9d7b567df1 100644 --- a/charts/formbricks/templates/_helpers.tpl +++ b/charts/formbricks/templates/_helpers.tpl @@ -136,6 +136,67 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to ` {{- printf "%s-app-secrets" (include "formbricks.name" .) -}} {{- end }} +{{- define "formbricks.authzedClusterName" -}} +{{- .Values.authzed.cluster.name | default (printf "%s-spicedb" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{- define "formbricks.authzedManagedSecretName" -}} +{{- printf "%s-authzed" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}} +{{- end }} + +{{- define "formbricks.authzedAuthSecretName" -}} +{{- .Values.authzed.auth.existingSecret | default (include "formbricks.authzedManagedSecretName" .) -}} +{{- end }} + +{{- define "formbricks.authzedDatastoreSecretName" -}} +{{- .Values.authzed.datastore.existingSecret | default (include "formbricks.authzedManagedSecretName" .) -}} +{{- end }} + +{{- define "formbricks.authzedEndpoint" -}} +{{- if .Values.authzed.endpoint -}} +{{- .Values.authzed.endpoint -}} +{{- else if eq .Values.authzed.mode "selfHosted" -}} +{{- printf "%s:50051" (include "formbricks.authzedClusterName" .) -}} +{{- else if eq .Values.authzed.mode "external" -}} +{{- fail "authzed.endpoint is required when authzed.mode=external" -}} +{{- else -}} +{{- fail "authzed.mode must be one of: selfHosted, external" -}} +{{- end -}} +{{- end }} + +{{- define "formbricks.authzedInsecure" -}} +{{- if eq .Values.authzed.insecure nil -}} +{{- eq .Values.authzed.mode "selfHosted" -}} +{{- else -}} +{{- .Values.authzed.insecure -}} +{{- end -}} +{{- end }} + +{{- define "formbricks.authzedPresharedKey" -}} +{{- /* Cluster-generated credentials are persisted through the managed Secret. Renderers without + live Secret access must use authzed.auth.existingSecret, as documented in the chart README. */ -}} +{{- $secretName := include "formbricks.authzedManagedSecretName" . -}} +{{- $secret := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- $secretData := dig "data" dict $secret -}} +{{- if index $secretData .Values.authzed.auth.tokenKey -}} +{{- index $secretData .Values.authzed.auth.tokenKey | b64dec -}} +{{- else -}} +{{- randAlphaNum 48 -}} +{{- end -}} +{{- end }} + +{{- define "formbricks.authzedDatabasePassword" -}} +{{- /* See formbricks.authzedPresharedKey for the offline-rendering persistence contract. */ -}} +{{- $secretName := include "formbricks.authzedManagedSecretName" . -}} +{{- $secret := lookup "v1" "Secret" .Release.Namespace $secretName -}} +{{- $secretData := dig "data" dict $secret -}} +{{- if index $secretData "database_password" -}} +{{- index $secretData "database_password" | b64dec -}} +{{- else -}} +{{- randAlphaNum 32 -}} +{{- end -}} +{{- end }} + {{- define "formbricks.redisName" -}} {{- .Values.redis.fullnameOverride | default (printf "%s-redis" (include "formbricks.name" .)) | trunc 63 | trimSuffix "-" -}} {{- end }} diff --git a/charts/formbricks/templates/authzed-cluster.yaml b/charts/formbricks/templates/authzed-cluster.yaml new file mode 100644 index 000000000000..cf3e0c5d6b6e --- /dev/null +++ b/charts/formbricks/templates/authzed-cluster.yaml @@ -0,0 +1,38 @@ +{{- if and .Values.authzed.enabled (eq .Values.authzed.mode "selfHosted") }} +apiVersion: authzed.com/v1alpha1 +kind: SpiceDBCluster +metadata: + name: {{ include "formbricks.authzedClusterName" . }} + labels: + {{- include "formbricks.labels" . | nindent 4 }} +spec: + version: {{ .Values.authzed.cluster.version | quote }} + channel: {{ .Values.authzed.cluster.channel | quote }} + config: + replicas: {{ .Values.authzed.cluster.replicas }} + {{- toYaml .Values.authzed.cluster.config | nindent 4 }} + credentials: + datastoreURI: + secretName: {{ include "formbricks.authzedDatastoreSecretName" . }} + key: {{ .Values.authzed.datastore.uriKey }} + presharedKey: + secretName: {{ include "formbricks.authzedAuthSecretName" . }} + key: {{ .Values.authzed.auth.tokenKey }} + {{- if or .Values.authzed.cluster.resources .Values.authzed.cluster.patches }} + patches: + {{- with .Values.authzed.cluster.resources }} + - kind: Deployment + patch: + spec: + template: + spec: + containers: + - name: spicedb + resources: + {{- toYaml . | nindent 20 }} + {{- end }} + {{- with .Values.authzed.cluster.patches }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/formbricks/templates/authzed-initialize-job.yaml b/charts/formbricks/templates/authzed-initialize-job.yaml new file mode 100644 index 000000000000..1bb24712df25 --- /dev/null +++ b/charts/formbricks/templates/authzed-initialize-job.yaml @@ -0,0 +1,134 @@ +{{- if and .Values.authzed.enabled .Values.authzed.initialization.enabled (or .Release.IsInstall (and .Release.IsUpgrade .Values.authzed.migrationAcknowledged)) }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "formbricks.name" . }}-authzed-initialize + labels: + {{- include "formbricks.labels" . | nindent 4 }} + annotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded + argocd.argoproj.io/sync-wave: "1" + helm.sh/hook: {{ ternary "post-install" "pre-upgrade" .Release.IsInstall }} + helm.sh/hook-weight: "10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + activeDeadlineSeconds: 900 + backoffLimit: 1 + ttlSecondsAfterFinished: 300 + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "formbricks.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: authzed-initialize + spec: + restartPolicy: Never + {{- if .Values.deployment.nodeSelector }} + nodeSelector: + {{- toYaml .Values.deployment.nodeSelector | nindent 8 }} + {{- end }} + {{- if .Values.deployment.tolerations }} + tolerations: + {{- toYaml .Values.deployment.tolerations | nindent 8 }} + {{- end }} + {{- if .Values.deployment.imagePullSecrets }} + imagePullSecrets: + {{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }} + {{- end }} + {{- if .Values.rbac.serviceAccount.enabled }} + serviceAccountName: {{ .Values.rbac.serviceAccount.name | default (include "formbricks.name" .) }} + {{- end }} + {{- if .Values.deployment.securityContext }} + securityContext: + {{- toYaml .Values.deployment.securityContext | nindent 8 }} + {{- end }} + containers: + - name: initialize + image: {{ include "formbricks.deploymentImage" . }} + imagePullPolicy: {{ .Values.deployment.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + runAsNonRoot: true + command: ["sh", "-ec"] + args: + - | + attempts=0 + until formbricks-authzed upgrade prepare; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 120 ]; then + echo '{"code":"authzed_unavailable","retryable":true,"status":"failed"}' + exit 1 + fi + sleep 5 + done + formbricks-authzed upgrade check + {{- if or .Values.deployment.envFrom (or (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) .Values.secret.enabled) }} + envFrom: + {{- if or .Values.secret.enabled (and .Values.externalSecret.enabled (index .Values.externalSecret.files "app-secrets")) }} + - secretRef: + name: {{ template "formbricks.name" . }}-app-secrets + {{- end }} + {{- range $value := .Values.deployment.envFrom }} + {{- if eq .type "configmap" }} + - configMapRef: + {{- if .name }} + name: {{ include "formbricks.tplvalues.render" (dict "value" $value.name "context" $) }} + {{- else if .nameSuffix }} + name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" (dict "value" $value.nameSuffix "context" $) }} + {{- else }} + name: {{ template "formbricks.name" $ }} + {{- end }} + {{- else if eq .type "secret" }} + - secretRef: + {{- if .name }} + name: {{ include "formbricks.tplvalues.render" (dict "value" $value.name "context" $) }} + {{- else if .nameSuffix }} + name: {{ template "formbricks.name" $ }}-{{ include "formbricks.tplvalues.render" (dict "value" $value.nameSuffix "context" $) }} + {{- else }} + name: {{ template "formbricks.name" $ }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + env: + {{- if hasKey .Values.deployment.env "DATABASE_URL" }} + {{- include "formbricks.envVar" (dict "name" "DATABASE_URL" "value" (index .Values.deployment.env "DATABASE_URL") "context" $) | nindent 12 }} + {{- end }} + {{- if hasKey .Values.deployment.env "MIGRATE_DATABASE_URL" }} + {{- include "formbricks.envVar" (dict "name" "MIGRATE_DATABASE_URL" "value" (index .Values.deployment.env "MIGRATE_DATABASE_URL") "context" $) | nindent 12 }} + {{- end }} + - name: LOG_LEVEL + value: fatal + - name: CUBEJS_API_URL + value: http://localhost + - name: CUBEJS_API_SECRET + value: authzed-initialize-unused + - name: HUB_API_URL + value: http://localhost + - name: HUB_API_KEY + value: authzed-initialize-unused + - name: REDIS_URL + value: redis://localhost + - name: ENCRYPTION_KEY + value: authzed-initialize-unused + - name: AUTHZED_ENABLED + value: "true" + - name: AUTHZED_ENDPOINT + value: {{ include "formbricks.authzedEndpoint" . | quote }} + - name: AUTHZED_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "formbricks.authzedAuthSecretName" . }} + key: {{ .Values.authzed.auth.tokenKey }} + - name: AUTHZED_SYSTEM_KEY + value: {{ .Values.authzed.systemKey | quote }} + - name: AUTHZED_INSECURE + value: {{ include "formbricks.authzedInsecure" . | quote }} + - name: AUTHZED_CONSISTENCY + value: fully_consistent +{{- end }} diff --git a/charts/formbricks/templates/authzed-postgresql-bootstrap.yaml b/charts/formbricks/templates/authzed-postgresql-bootstrap.yaml new file mode 100644 index 000000000000..cbd931cd6482 --- /dev/null +++ b/charts/formbricks/templates/authzed-postgresql-bootstrap.yaml @@ -0,0 +1,202 @@ +{{- $bundledBootstrap := and .Values.authzed.bundledPostgresqlBootstrap.enabled .Values.postgresql.enabled (not .Values.authzed.datastore.existingSecret) -}} +{{- $externalBootstrap := .Values.authzed.externalPostgresqlBootstrap.enabled -}} +{{- $postgresSecretName := .Values.postgresql.auth.existingSecret | default (include "formbricks.appSecretName" .) -}} +{{- if and .Values.authzed.enabled (eq .Values.authzed.mode "selfHosted") (or $bundledBootstrap $externalBootstrap) }} +{{- if and $externalBootstrap (not .Values.authzed.datastore.existingSecret) -}} +{{- fail "authzed.datastore.existingSecret is required for external PostgreSQL bootstrap" -}} +{{- end -}} +{{- if and $externalBootstrap (not .Values.authzed.externalPostgresqlBootstrap.adminSecretName) -}} +{{- fail "authzed.externalPostgresqlBootstrap.adminSecretName is required when external bootstrap is enabled" -}} +{{- end -}} +{{- $bundledAdminUsername := .Values.authzed.bundledPostgresqlBootstrap.adminUsername | default "postgres" -}} +{{- $bundledAdminDatabase := .Values.authzed.bundledPostgresqlBootstrap.adminDatabase | default "postgres" -}} +{{- $bundledAdminSecret := .Values.authzed.bundledPostgresqlBootstrap.adminPasswordSecretName | default $postgresSecretName -}} +{{- $bundledAdminKey := .Values.authzed.bundledPostgresqlBootstrap.adminPasswordKey | default .Values.postgresql.auth.secretKeys.adminPasswordKey -}} +{{- $isBundledSuperuserName := eq $bundledAdminUsername "postgres" -}} +{{- $reliesOnBundledAdminSecret := and $isBundledSuperuserName (not .Values.authzed.bundledPostgresqlBootstrap.adminPasswordSecretName) -}} +{{- /* + enablePostgresUser only matters when this Job is relying on the subchart to *create* the superuser + and hand over its password — that is, the default name AND the default Secret. Keying off the name + alone would refuse an existing server whose privileged role happens to be called `postgres` and + whose credentials the operator supplied explicitly, which is the same over-strict refusal that + forced installations to disable bootstrap entirely (ENG-2390). +*/ -}} +{{- if and $bundledBootstrap $reliesOnBundledAdminSecret (not .Values.postgresql.auth.enablePostgresUser) -}} +{{- fail "Set authzed.bundledPostgresqlBootstrap.adminUsername (with adminPasswordSecretName and adminPasswordKey) to an existing role with CREATEROLE and CREATEDB, or enable postgresql.auth.enablePostgresUser, or disable authzed.bundledPostgresqlBootstrap.enabled" -}} +{{- end -}} +{{- if and $bundledBootstrap (not $bundledAdminKey) -}} +{{- fail "authzed.bundledPostgresqlBootstrap.adminPasswordKey (or postgresql.auth.secretKeys.adminPasswordKey) is required when bundled AuthZed PostgreSQL bootstrap is enabled" -}} +{{- end -}} +{{- if and $bundledBootstrap (not $isBundledSuperuserName) (not .Values.authzed.bundledPostgresqlBootstrap.adminPasswordSecretName) -}} +{{- fail "authzed.bundledPostgresqlBootstrap.adminPasswordSecretName is required when adminUsername is not the bundled postgres superuser" -}} +{{- end -}} +{{- /* + The key needs its own explicit requirement, not just the non-empty check above. `$bundledAdminKey` + falls back to `postgresql.auth.secretKeys.adminPasswordKey`, which the subchart defaults to + `POSTGRES_ADMIN_PASSWORD` — never empty — so that check cannot fire for a custom role. Without this, + setting adminUsername and adminPasswordSecretName but forgetting the key renders happily and looks + up the *subchart's* key name inside the *operator's own* Secret, which fails in-cluster as + CreateContainerConfigError rather than at render. Fail-fast is the whole point of these guards. + + Keyed off the *credential source*, not the username: an existing administrator that happens to be + called `postgres` still supplies its own Secret, and that Secret is no more likely to carry the + subchart's key name than any other. Keying off the name left exactly that configuration rendering + a dangling secretKeyRef. +*/ -}} +{{- if and $bundledBootstrap (not $reliesOnBundledAdminSecret) (not .Values.authzed.bundledPostgresqlBootstrap.adminPasswordKey) -}} +{{- fail "authzed.bundledPostgresqlBootstrap.adminPasswordKey is required when the administrator password comes from an explicitly configured Secret" -}} +{{- end -}} +{{- $bootstrap := ternary .Values.authzed.bundledPostgresqlBootstrap .Values.authzed.externalPostgresqlBootstrap $bundledBootstrap -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "formbricks.authzedClusterName" . }}-database-bootstrap + labels: + {{- include "formbricks.labels" . | nindent 4 }} + annotations: + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded + argocd.argoproj.io/sync-wave: "-1" + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ $bootstrap.backoffLimit }} + activeDeadlineSeconds: 300 + ttlSecondsAfterFinished: 300 + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "formbricks.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/component: authzed-database-bootstrap + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: bootstrap + image: {{ $bootstrap.image | quote }} + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + runAsNonRoot: true + runAsUser: 70 + env: + {{- if $bundledBootstrap }} + - name: PGHOST + value: formbricks-postgresql + - name: PGUSER + value: {{ $bundledAdminUsername | quote }} + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ $bundledAdminSecret }} + key: {{ $bundledAdminKey }} + - name: ADMIN_DATABASE_NAME + value: {{ $bundledAdminDatabase | quote }} + - name: SPICEDB_DATABASE_NAME + value: spicedb + - name: SPICEDB_DATABASE_USERNAME + value: spicedb + - name: SPICEDB_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "formbricks.authzedManagedSecretName" . }} + key: database_password + {{- else }} + - name: ADMIN_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.authzed.externalPostgresqlBootstrap.adminSecretName }} + key: {{ .Values.authzed.externalPostgresqlBootstrap.adminUrlKey }} + - name: SPICEDB_DATABASE_NAME + valueFrom: + secretKeyRef: + name: {{ include "formbricks.authzedDatastoreSecretName" . }} + key: {{ .Values.authzed.externalPostgresqlBootstrap.databaseNameKey }} + - name: SPICEDB_DATABASE_USERNAME + valueFrom: + secretKeyRef: + name: {{ include "formbricks.authzedDatastoreSecretName" . }} + key: {{ .Values.authzed.externalPostgresqlBootstrap.databaseUsernameKey }} + - name: SPICEDB_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "formbricks.authzedDatastoreSecretName" . }} + key: {{ .Values.authzed.externalPostgresqlBootstrap.databasePasswordKey }} + {{- end }} + command: ["/bin/sh", "-ec"] + args: + - | + if [ -n "${ADMIN_DATABASE_URL:-}" ]; then + case "$ADMIN_DATABASE_URL" in + *\?*) admin_database_query="${ADMIN_DATABASE_URL#*\?}" ;; + *) + echo "ADMIN_DATABASE_URL must explicitly enable TLS with sslmode=require, verify-ca, or verify-full" >&2 + exit 1 + ;; + esac + + sslmode="" + remaining_query="$admin_database_query" + while [ -n "$remaining_query" ]; do + parameter="${remaining_query%%&*}" + if [ "$remaining_query" = "$parameter" ]; then + remaining_query="" + else + remaining_query="${remaining_query#*&}" + fi + case "$parameter" in + sslmode=*) sslmode="${parameter#sslmode=}" ;; + esac + done + + case "$sslmode" in + require|verify-ca|verify-full) ;; + *) + echo "ADMIN_DATABASE_URL must explicitly enable TLS with sslmode=require, verify-ca, or verify-full" >&2 + exit 1 + ;; + esac + admin_database_url="$ADMIN_DATABASE_URL" + else + # Bundled mode connects through PGHOST/PGUSER/PGPASSWORD; this is the maintenance + # database to attach to, which is not necessarily named `postgres` on an existing + # server (ENG-2390). No shell-level default: this branch only runs for bundled + # bootstrap, where the template always sets ADMIN_DATABASE_NAME, so a second + # fallback here would just be a competing source of truth for the same default. + admin_database_url="$ADMIN_DATABASE_NAME" + fi + readiness_attempt=1 + until pg_isready --dbname "$admin_database_url"; do + if [ "$readiness_attempt" -ge 60 ]; then + echo "PostgreSQL did not become ready after 60 attempts" >&2 + exit 1 + fi + readiness_attempt=$((readiness_attempt + 1)) + sleep 2 + done + psql --dbname "$admin_database_url" --set ON_ERROR_STOP=1 \ + --set database_name="$SPICEDB_DATABASE_NAME" \ + --set database_username="$SPICEDB_DATABASE_USERNAME" \ + --set database_password="$SPICEDB_DATABASE_PASSWORD" <<'SQL' + SELECT format('CREATE ROLE %I LOGIN', :'database_username') + WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'database_username') \gexec + -- CREATE DATABASE ... OWNER requires the administrator to be able to SET ROLE to the + -- owner, and merely holding CREATEROLE does not give that. PostgreSQL 16+ grants a + -- role's creator ADMIN but SET FALSE; PostgreSQL 15 and older grant nothing at all. So + -- a non-superuser administrator has to take the membership explicitly or the next + -- statement fails with "must be able to SET ROLE" (16+) / "must be member of role" + -- (15). Superusers already qualify and are skipped, so the bundled path is unchanged. + SELECT format('GRANT %I TO CURRENT_USER', :'database_username') + WHERE NOT (SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER) \gexec + SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'database_username', :'database_password') \gexec + SELECT format('CREATE DATABASE %I OWNER %I', :'database_name', :'database_username') + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'database_name') \gexec + SQL +{{- end }} diff --git a/charts/formbricks/templates/authzed-secret.yaml b/charts/formbricks/templates/authzed-secret.yaml new file mode 100644 index 000000000000..da42c9364a62 --- /dev/null +++ b/charts/formbricks/templates/authzed-secret.yaml @@ -0,0 +1,22 @@ +{{- if and .Values.authzed.enabled (eq .Values.authzed.mode "selfHosted") (or (not .Values.authzed.auth.existingSecret) (not .Values.authzed.datastore.existingSecret)) }} +{{- if and (not .Values.authzed.datastore.existingSecret) (not .Values.postgresql.enabled) }} +{{- fail "authzed.datastore.existingSecret is required for selfHosted AuthZed with external PostgreSQL" }} +{{- end }} +{{- $presharedKey := include "formbricks.authzedPresharedKey" . -}} +{{- $databasePassword := include "formbricks.authzedDatabasePassword" . -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "formbricks.authzedManagedSecretName" . }} + labels: + {{- include "formbricks.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if not .Values.authzed.auth.existingSecret }} + {{ .Values.authzed.auth.tokenKey }}: {{ $presharedKey | quote }} + {{- end }} + {{- if not .Values.authzed.datastore.existingSecret }} + {{ .Values.authzed.datastore.uriKey }}: {{ printf "postgresql://spicedb:%s@formbricks-postgresql:5432/spicedb?sslmode=disable" $databasePassword | quote }} + database_password: {{ $databasePassword | quote }} + {{- end }} +{{- end }} diff --git a/charts/formbricks/templates/authzed-validation.yaml b/charts/formbricks/templates/authzed-validation.yaml new file mode 100644 index 000000000000..bb17a1f100da --- /dev/null +++ b/charts/formbricks/templates/authzed-validation.yaml @@ -0,0 +1,26 @@ +{{- if not .Values.authzed.enabled -}} +{{- fail "Formbricks v6 requires AuthZed. Follow the v6 migration guide instead of upgrading with authzed.enabled=false" -}} +{{- end -}} +{{- if and .Release.IsUpgrade (not .Values.authzed.migrationAcknowledged) -}} +{{- fail "AuthZed v6 upgrade preparation is not acknowledged. Complete formbricks-authzed upgrade prepare and upgrade check, then set authzed.migrationAcknowledged=true" -}} +{{- end -}} +{{- if and (not .Values.authzed.initialization.enabled) (not .Values.authzed.migrationAcknowledged) -}} +{{- fail "Disabling the AuthZed initialization Job requires authzed.migrationAcknowledged=true after completing the guarded preparation externally" -}} +{{- end -}} +{{- if ne .Values.authzed.consistency "fully_consistent" -}} +{{- fail "Formbricks v6 requires authzed.consistency=fully_consistent" -}} +{{- end -}} +{{- if .Values.authzed.enabled -}} +{{- if not (has .Values.authzed.mode (list "selfHosted" "external")) -}} +{{- fail "authzed.mode must be one of: selfHosted, external" -}} +{{- end -}} +{{- if and (eq .Values.authzed.mode "external") (not .Values.authzed.auth.existingSecret) -}} +{{- fail "authzed.auth.existingSecret is required when authzed.mode=external" -}} +{{- end -}} +{{- if and (eq .Values.authzed.mode "external") .Values.authzed.operator.install -}} +{{- fail "Set authzed.operator.install=false when authzed.mode=external" -}} +{{- end -}} +{{- if and (ne .Values.authzed.insecure nil) (not (kindIs "bool" .Values.authzed.insecure)) -}} +{{- fail "authzed.insecure must be true, false, or null" -}} +{{- end -}} +{{- end -}} diff --git a/charts/formbricks/templates/deployment.yaml b/charts/formbricks/templates/deployment.yaml index 6e531f55efb7..c85f0ebcb453 100644 --- a/charts/formbricks/templates/deployment.yaml +++ b/charts/formbricks/templates/deployment.yaml @@ -8,7 +8,7 @@ metadata: {{- if .Values.deployment.additionalLabels }} {{- toYaml .Values.deployment.additionalLabels | nindent 4 }} {{- end }} - {{- if or .Values.deployment.annotations .Values.deployment.reloadOnChange }} + {{- if or .Values.deployment.annotations .Values.deployment.reloadOnChange .Values.authzed.enabled }} annotations: {{- if .Values.deployment.annotations }} {{- toYaml .Values.deployment.annotations | nindent 4 }} @@ -16,6 +16,11 @@ metadata: {{- if .Values.deployment.reloadOnChange }} reloader.stakater.com/auto: "true" {{- end }} + {{- if and .Values.authzed.enabled (not (hasKey (default dict .Values.deployment.annotations) "argocd.argoproj.io/sync-wave")) }} + # The release-matched AuthZed preparation runs at wave 1. This orders GitOps reconciliation without + # coupling the application process, startup, or probes to SpiceDB availability. + argocd.argoproj.io/sync-wave: "2" + {{- end }} {{- end }} spec: {{- if and (not .Values.autoscaling.enabled) (not (kindIs "invalid" .Values.deployment.replicas)) }} @@ -135,6 +140,39 @@ spec: - name: SKIP_STARTUP_MIGRATION value: "true" {{- end }} + {{- if and .Values.formbricks.mcpOauthJwksUrl (not (hasKey .Values.deployment.env "MCP_OAUTH_JWKS_URL")) }} + - name: MCP_OAUTH_JWKS_URL + value: {{ .Values.formbricks.mcpOauthJwksUrl | quote }} + {{- end }} + {{- if .Values.authzed.enabled }} + {{- if not (hasKey .Values.deployment.env "AUTHZED_ENABLED") }} + - name: AUTHZED_ENABLED + value: "true" + {{- end }} + {{- if not (hasKey .Values.deployment.env "AUTHZED_ENDPOINT") }} + - name: AUTHZED_ENDPOINT + value: {{ include "formbricks.authzedEndpoint" . | quote }} + {{- end }} + {{- if not (hasKey .Values.deployment.env "AUTHZED_TOKEN") }} + - name: AUTHZED_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "formbricks.authzedAuthSecretName" . }} + key: {{ .Values.authzed.auth.tokenKey }} + {{- end }} + {{- if not (hasKey .Values.deployment.env "AUTHZED_SYSTEM_KEY") }} + - name: AUTHZED_SYSTEM_KEY + value: {{ required "authzed.systemKey is required when AuthZed is enabled" .Values.authzed.systemKey | quote }} + {{- end }} + {{- if not (hasKey .Values.deployment.env "AUTHZED_INSECURE") }} + - name: AUTHZED_INSECURE + value: {{ include "formbricks.authzedInsecure" . | quote }} + {{- end }} + {{- if not (hasKey .Values.deployment.env "AUTHZED_CONSISTENCY") }} + - name: AUTHZED_CONSISTENCY + value: {{ .Values.authzed.consistency | quote }} + {{- end }} + {{- end }} {{- if not (hasKey .Values.deployment.env "HUB_API_URL") }} - name: HUB_API_URL value: "http://{{ include "formbricks.hubname" . }}:8080" diff --git a/charts/formbricks/tests/authzed-bootstrap-runtime.sh b/charts/formbricks/tests/authzed-bootstrap-runtime.sh new file mode 100755 index 000000000000..e5a7ee706776 --- /dev/null +++ b/charts/formbricks/tests/authzed-bootstrap-runtime.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Runs the AuthZed bootstrap Job's *rendered* script against real PostgreSQL servers. +# +# The render tests in authzed-operations.sh prove the manifest says what we mean. They cannot prove +# the SQL works, and the SQL is where this feature actually broke: a role with CREATEROLE and CREATEDB +# — the privileges values.yaml tells operators to grant — could not run `CREATE DATABASE ... OWNER`, +# because PostgreSQL 16+ grants a role's creator ADMIN but SET FALSE, and 15 and older grant nothing. +# Both fail, with different messages, and no amount of template testing sees it. +# +# So this extracts the script from the rendered Job and runs it verbatim. Nothing here restates the +# SQL: a fix that lands in the template but not in reality fails here, and a test that drifts from +# the template is impossible by construction. +# +# Skipped when Docker is unavailable so the render suite still runs anywhere. +set -euo pipefail + +chart_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! docker info >/dev/null 2>&1; then + printf '%s\n' "Docker unavailable — skipping the AuthZed bootstrap runtime tests." + exit 0 +fi + +# PostgreSQL 16 changed how a creator is granted its new role, and the bootstrap has to work either +# way, so both sides of that change are covered. +postgres_versions=("15" "17") +admin_password="admin-pw" +spicedb_password="spicedb-pw" +containers=() +container="" + +cleanup() { + for container in "${containers[@]:-}"; do + [ -n "${container}" ] && docker rm --force "${container}" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT + +# Pull the `args:` block scalar out of the rendered Job. Reading the shipped script rather than a +# copy is the whole point: this test cannot pass against SQL the chart does not actually ship. +extract_bootstrap_script() { + awk ' + /^[[:space:]]*args:[[:space:]]*$/ { in_args = 1; next } + in_args && /^[[:space:]]*-[[:space:]]*\|[[:space:]]*$/ { in_block = 1; next } + in_block { + if (block_indent == 0 && $0 !~ /^[[:space:]]*$/) { + match($0, /^[[:space:]]*/) + block_indent = RLENGTH + } + if ($0 !~ /^[[:space:]]*$/) { + match($0, /^[[:space:]]*/) + if (RLENGTH < block_indent) { exit } + } + print substr($0, block_indent + 1) + } + ' +} + +bootstrap_script="$(helm template runtime "${chart_dir}" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + --show-only templates/authzed-postgresql-bootstrap.yaml | extract_bootstrap_script)" + +if ! grep --quiet 'CREATE DATABASE' <<<"${bootstrap_script}"; then + printf '%s\n' "Could not extract the bootstrap script from the rendered Job." >&2 + exit 1 +fi + +# Sets the global `container` rather than echoing it: a `$(start_postgres …)` call would run this in a +# subshell, so the name would never reach the cleanup list in the parent and a failing assertion +# would leak the server. +start_postgres() { + local version="$1" + container="authzed-bootstrap-runtime-${version}" + docker rm --force "${container}" >/dev/null 2>&1 || true + docker run --detach --name "${container}" \ + --env POSTGRES_PASSWORD=superuser-pw "postgres:${version}-alpine" >/dev/null + containers+=("${container}") + # Probe over TCP, not the Unix socket. The postgres entrypoint runs a temporary socket-only server + # while it initialises the data directory, so a socket probe reports ready, the entrypoint then + # stops that server, and the next command fails with "No such file or directory" — which is exactly + # how this first went red in CI while passing locally. The real server is the one listening on TCP. + local attempt=1 + until docker exec "${container}" pg_isready --host 127.0.0.1 --username postgres >/dev/null 2>&1; do + if [ "${attempt}" -ge 90 ]; then + printf '%s\n' "PostgreSQL ${version} did not become ready." >&2 + exit 1 + fi + attempt=$((attempt + 1)) + sleep 1 + done +} + +# Runs the rendered script exactly as the Job does: same shell, same env contract. +run_bootstrap() { + local container="$1" admin_user="$2" admin_pass="$3" + docker exec --interactive \ + --env PGHOST=127.0.0.1 \ + --env PGUSER="${admin_user}" \ + --env PGPASSWORD="${admin_pass}" \ + --env ADMIN_DATABASE_NAME=postgres \ + --env SPICEDB_DATABASE_NAME=spicedb \ + --env SPICEDB_DATABASE_USERNAME=spicedb \ + --env SPICEDB_DATABASE_PASSWORD="${spicedb_password}" \ + "${container}" /bin/sh -ec "${bootstrap_script}" 2>&1 +} + +as_superuser() { + docker exec --env PGPASSWORD=superuser-pw "$1" \ + psql --host 127.0.0.1 --username postgres --dbname postgres --tuples-only --no-align --command "$2" +} + +for version in "${postgres_versions[@]}"; do + start_postgres "${version}" + printf 'PostgreSQL %s\n' "$(as_superuser "${container}" 'SHOW server_version')" + + # The privileges values.yaml documents, and nothing more. + as_superuser "${container}" \ + "CREATE ROLE fbadmin LOGIN CREATEROLE CREATEDB PASSWORD '${admin_password}'" >/dev/null + + # 1. A fresh server with only a CREATEROLE/CREATEDB administrator: the path this feature exists for. + if ! output="$(run_bootstrap "${container}" fbadmin "${admin_password}")"; then + printf '%s\n%s\n' "Bootstrap failed for a CREATEROLE/CREATEDB administrator on ${version}:" "${output}" >&2 + exit 1 + fi + [ "$(as_superuser "${container}" "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = 'spicedb'")" = "spicedb" ] || { + printf '%s\n' "The spicedb database must exist and be owned by the spicedb role on ${version}." >&2 + exit 1 + } + # Proves ALTER ROLE actually applied the password, not merely that the statement ran. + docker exec --env PGPASSWORD="${spicedb_password}" "${container}" \ + psql --host 127.0.0.1 --username spicedb --dbname spicedb --command 'SELECT 1' >/dev/null || { + printf '%s\n' "The spicedb role must be able to log in with the configured password on ${version}." >&2 + exit 1 + } + + # 2. Rerunning is safe: the Job is a post-install *and* post-upgrade hook, so it reruns every upgrade. + if ! output="$(run_bootstrap "${container}" fbadmin "${admin_password}")"; then + printf '%s\n%s\n' "Rerunning the bootstrap must succeed on ${version}:" "${output}" >&2 + exit 1 + fi + + # 3. The bundled superuser path must be unchanged — and must not collect a role membership it has + # no use for, which is why the GRANT is skipped for superusers rather than run unconditionally. + as_superuser "${container}" "DROP DATABASE spicedb" >/dev/null + as_superuser "${container}" "DROP ROLE spicedb" >/dev/null + if ! output="$(run_bootstrap "${container}" postgres superuser-pw)"; then + printf '%s\n%s\n' "Bootstrap failed for the bundled superuser on ${version}:" "${output}" >&2 + exit 1 + fi + [ "$(as_superuser "${container}" "SELECT count(*) FROM pg_auth_members m JOIN pg_roles r ON r.oid = m.roleid JOIN pg_roles u ON u.oid = m.member WHERE r.rolname = 'spicedb' AND u.rolname = 'postgres'")" = "0" ] || { + printf '%s\n' "A superuser administrator must not be granted the spicedb role on ${version}." >&2 + exit 1 + } + + # 4. A spicedb role created by someone else. PostgreSQL 16 narrowed CREATEROLE: before it, the + # privilege carried authority over every non-superuser role, so the bootstrap simply works; + # from 16 on it only covers roles the administrator has ADMIN OPTION for, so this is the + # documented limitation and must fail loudly rather than appear to succeed. Asserting one + # outcome for both would either miss the failure or demand a fix the older server does not need. + as_superuser "${container}" "DROP DATABASE spicedb" >/dev/null + server_version_num="$(as_superuser "${container}" 'SHOW server_version_num')" + if output="$(run_bootstrap "${container}" fbadmin "${admin_password}")"; then + if [ "${server_version_num}" -ge 160000 ]; then + printf '%s\n' "Bootstrap must fail without ADMIN OPTION on a pre-existing spicedb role (${version})." >&2 + exit 1 + fi + else + if [ "${server_version_num}" -lt 160000 ]; then + printf '%s\n%s\n' "CREATEROLE alone must still adopt a pre-existing spicedb role on ${version}:" "${output}" >&2 + exit 1 + fi + grep --quiet --extended-regexp 'must have admin option|permission denied|must be able to SET ROLE|must be member of role' <<<"${output}" || { + printf '%s\n%s\n' "Expected a privilege error for a pre-existing foreign-owned spicedb role on ${version}, got:" "${output}" >&2 + exit 1 + } + fi + + docker rm --force "${container}" >/dev/null 2>&1 || true +done + +printf '%s\n' "AuthZed bootstrap runtime contracts are valid." diff --git a/charts/formbricks/tests/authzed-operations.sh b/charts/formbricks/tests/authzed-operations.sh new file mode 100644 index 000000000000..4dd8d041600f --- /dev/null +++ b/charts/formbricks/tests/authzed-operations.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly CHART_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +readonly COMMON_ARGS=(--set formbricks.webappUrl=https://qa.example.com) + +temp_dir="$(mktemp -d)" +trap 'rm -rf "${temp_dir}"' EXIT + +# `helm template` deliberately omits NOTES.txt, while Helm 3.15 still contacts Kubernetes during a dry-run +# install. Evaluate the real notes through `tpl` in a minimal ConfigMap chart so this contract remains +# clusterless and release-accurate across supported Helm versions. +notes_chart="${temp_dir}/notes-chart" +mkdir -p "${notes_chart}/templates" +cp "${CHART_DIR}/values.yaml" "${notes_chart}/values.yaml" +cp "${CHART_DIR}/templates/_helpers.tpl" "${notes_chart}/templates/_helpers.tpl" +cp "${CHART_DIR}/templates/NOTES.txt" "${notes_chart}/notes.txt" +printf '%s\n' \ + 'apiVersion: v2' \ + 'name: formbricks-notes-contract' \ + 'version: 0.0.0' \ + 'appVersion: 0.0.0' >"${notes_chart}/Chart.yaml" +printf '%s\n' \ + 'apiVersion: v1' \ + 'kind: ConfigMap' \ + 'metadata:' \ + ' name: notes-contract' \ + 'data:' \ + ' notes: |' \ + '{{ tpl (.Files.Get "notes.txt") . | nindent 4 }}' >"${notes_chart}/templates/notes.yaml" + +render_notes() { + local release_name="$1" + shift + + helm template "${release_name}" "${notes_chart}" --namespace qa "${COMMON_ARGS[@]}" "$@" \ + --show-only templates/notes.yaml \ + | sed -n '/^ notes: |/,$p' \ + | sed '1d; s/^ //' +} + +authzed_operations_notes() { + sed -n '/AuthZed \/ SpiceDB Operations:/,/^---$/p' <<<"$1" +} + +assert_safe_authzed_notes() { + local release_name="$1" + local notes="$2" + + if grep --extended-regexp --ignore-case 'preshared|datastore_uri|token|ingress' <<<"${notes}" >/dev/null; then + printf '%s\n' "AuthZed operations notes for ${release_name} must not expose secrets or suggest an Ingress." >&2 + exit 1 + fi +} + +disabled_notes="$(render_notes authzed-disabled --set authzed.enabled=false)" +if grep --fixed-strings "AuthZed / SpiceDB Operations:" <<<"${disabled_notes}" >/dev/null; then + printf '%s\n' "AuthZed operations notes must be hidden when AuthZed is disabled." >&2 + exit 1 +fi + +external_notes="$(render_notes authzed-external \ + --set authzed.enabled=true \ + --set authzed.mode=external \ + --set authzed.operator.install=false \ + --set authzed.endpoint=grpc.authzed.com:443 \ + --set authzed.insecure=false \ + --set authzed.auth.existingSecret=formbricks-authzed)" + +authzed_notes="$(authzed_operations_notes "${external_notes}")" +grep --fixed-strings 'SpiceDB is configured in `external` mode.' <<<"${authzed_notes}" >/dev/null +grep --fixed-strings 'formbricks-authzed health' <<<"${authzed_notes}" >/dev/null +grep --fixed-strings 'formbricks-authzed schema check' <<<"${authzed_notes}" >/dev/null +grep --fixed-strings 'formbricks-authzed upgrade prepare' <<<"${authzed_notes}" >/dev/null +grep --fixed-strings 'formbricks-authzed upgrade check' <<<"${authzed_notes}" >/dev/null +grep --fixed-strings 'self-hosting/advanced/authzed-operations' <<<"${authzed_notes}" >/dev/null +assert_safe_authzed_notes authzed-external "${authzed_notes}" + +# AuthZed is the v6 authorization engine. Fresh installs render the initialization Job, while an +# existing release must explicitly acknowledge the completed release-matched preparation. +default_install="$(helm template authzed-default "${CHART_DIR}" "${COMMON_ARGS[@]}")" +grep --fixed-strings 'name: formbricks-authzed-initialize' <<<"${default_install}" >/dev/null +grep --fixed-strings 'helm.sh/hook-weight: "10"' <<<"${default_install}" >/dev/null +grep --fixed-strings 'helm.sh/hook-weight: "-10"' <<<"${default_install}" >/dev/null +grep --fixed-strings 'value: fully_consistent' <<<"${default_install}" >/dev/null + +if external_initialization_error="$(helm template authzed-external-initialization "${CHART_DIR}" \ + "${COMMON_ARGS[@]}" \ + --set authzed.initialization.enabled=false 2>&1)"; then + printf '%s\n' "Disabling automatic AuthZed initialization must require an explicit acknowledgement." >&2 + exit 1 +fi +grep --fixed-strings 'Disabling the AuthZed initialization Job requires authzed.migrationAcknowledged=true' \ + <<<"${external_initialization_error}" >/dev/null + +externally_prepared_install="$(helm template authzed-externally-prepared "${CHART_DIR}" \ + "${COMMON_ARGS[@]}" \ + --set authzed.initialization.enabled=false \ + --set authzed.migrationAcknowledged=true)" +if grep --fixed-strings 'name: formbricks-authzed-initialize' <<<"${externally_prepared_install}" >/dev/null; then + printf '%s\n' "An externally prepared cutover must not render the AuthZed initialization Job." >&2 + exit 1 +fi + +helm template authzed-null-annotations "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --set-json 'deployment.annotations=null' >/dev/null + +if authzed_disabled_error="$(helm template authzed-disabled "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --set authzed.enabled=false 2>&1)"; then + printf '%s\n' "Formbricks v6 must refuse a chart deployment with AuthZed disabled." >&2 + exit 1 +fi +grep --fixed-strings 'Formbricks v6 requires AuthZed' <<<"${authzed_disabled_error}" >/dev/null + +if authzed_upgrade_error="$(helm template authzed-upgrade "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --is-upgrade \ + --set global.postgresql.auth.password=test-password \ + --set global.postgresql.auth.postgresPassword=test-password 2>&1)"; then + printf '%s\n' "An existing Helm release must acknowledge the AuthZed v6 migration." >&2 + exit 1 +fi +grep --fixed-strings 'AuthZed v6 upgrade preparation is not acknowledged' \ + <<<"${authzed_upgrade_error}" >/dev/null + +acknowledged_upgrade="$(helm template authzed-upgrade "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --is-upgrade \ + --set global.postgresql.auth.password=test-password \ + --set global.postgresql.auth.postgresPassword=test-password \ + --set authzed.migrationAcknowledged=true)" +if ! grep --fixed-strings 'helm.sh/hook: pre-upgrade' <<<"${acknowledged_upgrade}" >/dev/null; then + printf '%s\n' "An acknowledged Helm upgrade must run the release-matched AuthZed gate before rollout." >&2 + exit 1 +fi + +# Render each supported ownership and datastore shape. These are intentionally render-only checks: none +# of the operational commands are Helm hooks or automatically created Jobs. +helm template authzed-bundled "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + --set authzed.operator.install=true >/dev/null + +bundled_notes="$(render_notes authzed-bundled \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + --set authzed.operator.install=true)" +assert_safe_authzed_notes authzed-bundled "$(authzed_operations_notes "${bundled_notes}")" + +helm template authzed-existing-operator "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + --set authzed.operator.install=false \ + --set authzed.auth.existingSecret=formbricks-authzed \ + --set authzed.datastore.existingSecret=formbricks-authzed >/dev/null + +existing_operator_notes="$(render_notes authzed-existing-operator \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + --set authzed.operator.install=false \ + --set authzed.auth.existingSecret=formbricks-authzed \ + --set authzed.datastore.existingSecret=formbricks-authzed)" +assert_safe_authzed_notes authzed-existing-operator "$(authzed_operations_notes "${existing_operator_notes}")" + +managed_postgresql_notes="$(render_notes authzed-managed-postgresql \ + --set postgresql.enabled=false \ + --set-string postgresql.externalDatabaseUrl=postgresql://formbricks:notes-secret@postgres.example:5432/formbricks?sslmode=require \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + --set authzed.operator.install=false \ + --set authzed.auth.existingSecret=formbricks-authzed \ + --set authzed.datastore.existingSecret=formbricks-authzed)" + +if grep --fixed-strings 'notes-secret' <<<"${managed_postgresql_notes}" >/dev/null; then + printf '%s\n' "Helm notes must not render PostgreSQL credentials." >&2 + exit 1 +fi +assert_safe_authzed_notes authzed-managed-postgresql \ + "$(authzed_operations_notes "${managed_postgresql_notes}")" + +helm template authzed-external "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --set authzed.enabled=true \ + --set authzed.mode=external \ + --set authzed.operator.install=false \ + --set authzed.endpoint=grpc.authzed.com:443 \ + --set authzed.insecure=false \ + --set authzed.auth.existingSecret=formbricks-authzed >/dev/null + +# ENG-2390: the bundled database bootstrap must not hard-require a role named `postgres`. +# An existing PostgreSQL installed without one previously had no option but to disable bootstrap +# entirely, which left the SpiceDB role and database uncreated. + +render_bootstrap() { + helm template "$1" "${CHART_DIR}" "${COMMON_ARGS[@]}" \ + --set authzed.enabled=true \ + --set authzed.mode=selfHosted \ + "${@:2}" \ + --show-only templates/authzed-postgresql-bootstrap.yaml +} + +# The default is unchanged: the bundled `postgres` superuser on the `postgres` database. +default_bootstrap="$(render_bootstrap authzed-bootstrap-default)" +grep --quiet 'value: "postgres"' <<<"${default_bootstrap}" + +# The regression itself. Without an override this still refuses, but it must name the way out +# rather than simply asserting that enablePostgresUser is required. +if bootstrap_refusal="$(render_bootstrap authzed-bootstrap-no-superuser \ + --set postgresql.auth.enablePostgresUser=false 2>&1)"; then + printf '%s\n' "Bootstrap must refuse a missing postgres superuser when no admin role is configured." >&2 + exit 1 +fi +grep --quiet 'adminUsername' <<<"${bootstrap_refusal}" + +# ...and configuring an existing administrative role is what unblocks it. +existing_admin_bootstrap="$(render_bootstrap authzed-bootstrap-existing-admin \ + --set postgresql.auth.enablePostgresUser=false \ + --set authzed.bundledPostgresqlBootstrap.adminUsername=fbadmin \ + --set authzed.bundledPostgresqlBootstrap.adminDatabase=formbricks \ + --set authzed.bundledPostgresqlBootstrap.adminPasswordSecretName=existing-pg-admin \ + --set authzed.bundledPostgresqlBootstrap.adminPasswordKey=password)" +grep --quiet 'value: "fbadmin"' <<<"${existing_admin_bootstrap}" +grep --quiet 'value: "formbricks"' <<<"${existing_admin_bootstrap}" +grep --quiet 'name: existing-pg-admin' <<<"${existing_admin_bootstrap}" + +# Matti's finding on #8875: the key needs its own guard. `$bundledAdminKey` falls back to the +# subchart's non-empty default, so "is it set at all" can never fail for a custom role, and forgetting +# the key silently looks up the subchart's key name inside the operator's own Secret. +if render_bootstrap authzed-bootstrap-admin-without-key \ + --set authzed.bundledPostgresqlBootstrap.adminUsername=fbadmin \ + --set authzed.bundledPostgresqlBootstrap.adminPasswordSecretName=existing-pg-admin >/dev/null 2>&1; then + printf '%s\n' "Bootstrap must require adminPasswordKey when adminUsername is overridden." >&2 + exit 1 +fi + +# An existing server whose privileged role is called `postgres` is a configured administrator, not the +# bundled superuser — supplying its Secret explicitly must be accepted even with enablePostgresUser=false. +explicit_postgres_bootstrap="$(render_bootstrap authzed-bootstrap-explicit-postgres \ + --set postgresql.auth.enablePostgresUser=false \ + --set authzed.bundledPostgresqlBootstrap.adminPasswordSecretName=existing-pg-admin \ + --set authzed.bundledPostgresqlBootstrap.adminPasswordKey=password)" +grep --quiet 'value: "postgres"' <<<"${explicit_postgres_bootstrap}" +grep --quiet 'name: existing-pg-admin' <<<"${explicit_postgres_bootstrap}" + +# ...but that administrator still supplies its own Secret, which is no likelier to carry the +# subchart's key name than any other. Keying the key guard off the username left this configuration +# rendering a dangling secretKeyRef (Bhagya's finding on #8875, and CodeRabbit's before it), so the +# guard keys off the credential source and this render must be refused. +if render_bootstrap authzed-bootstrap-explicit-postgres-without-key \ + --set authzed.bundledPostgresqlBootstrap.adminPasswordSecretName=existing-pg-admin >/dev/null 2>&1; then + printf '%s\n' "Bootstrap must require adminPasswordKey when the administrator Secret is configured explicitly." >&2 + exit 1 +fi + +# A custom admin role with no Secret would silently fall back to the bundled superuser's password. +if render_bootstrap authzed-bootstrap-admin-without-secret \ + --set authzed.bundledPostgresqlBootstrap.adminUsername=fbadmin >/dev/null 2>&1; then + printf '%s\n' "Bootstrap must require adminPasswordSecretName when adminUsername is overridden." >&2 + exit 1 +fi + +# Credentials reach the Job only by reference, in every mode. +# +# Asserted structurally, per Bhagya's finding on #8875. The previous check grepped for `PGPASSWORD: `, +# a shape the renderer never emits — env entries are `- name: PGPASSWORD` followed by `value:` or +# `valueFrom:`. A literal leak therefore matched nothing and the test passed through the exact +# regression it existed to catch. A whole-manifest regex cannot do better: it cannot tell a `value:` +# under PGPASSWORD from the legitimate one under PGHOST. So walk the env list instead and check how +# each sensitive entry is supplied. +assert_env_supplied_by_reference() { + local manifest="$1" variable="$2" + + awk -v target="${variable}" ' + /^[[:space:]]*-[[:space:]]+name:[[:space:]]/ { + if (current == target) { seen = 1; if (source != "reference") literal = 1 } + current = $3 + source = "" + next + } + current == target && /^[[:space:]]*value:/ { source = "literal" } + current == target && /^[[:space:]]*valueFrom:/ { source = "reference" } + END { + if (current == target) { seen = 1; if (source != "reference") literal = 1 } + if (!seen) { print "absent"; exit 2 } + if (literal) { print "literal"; exit 1 } + print "reference" + } + ' <<<"${manifest}" +} + +for credential_variable in PGPASSWORD SPICEDB_DATABASE_PASSWORD; do + if ! supplied_by="$(assert_env_supplied_by_reference "${existing_admin_bootstrap}" "${credential_variable}")"; then + printf '%s\n' "Bootstrap must supply ${credential_variable} by secret reference, found: ${supplied_by}." >&2 + exit 1 + fi +done + +external_bootstrap="$(render_bootstrap authzed-bootstrap-external \ + --set authzed.bundledPostgresqlBootstrap.enabled=false \ + --set authzed.externalPostgresqlBootstrap.enabled=true \ + --set authzed.externalPostgresqlBootstrap.adminSecretName=external-pg-admin \ + --set authzed.datastore.existingSecret=external-datastore)" +for credential_variable in ADMIN_DATABASE_URL SPICEDB_DATABASE_PASSWORD; do + if ! supplied_by="$(assert_env_supplied_by_reference "${external_bootstrap}" "${credential_variable}")"; then + printf '%s\n' "External bootstrap must supply ${credential_variable} by secret reference, found: ${supplied_by}." >&2 + exit 1 + fi +done + +printf '%s\n' "AuthZed Helm operations contracts are valid." diff --git a/charts/formbricks/values.yaml b/charts/formbricks/values.yaml index 3aa5d38fc38c..bfa5188ed383 100644 --- a/charts/formbricks/values.yaml +++ b/charts/formbricks/values.yaml @@ -21,6 +21,12 @@ formbricks: # Optional: Public URL for surveys (defaults to webappUrl if not set) publicUrl: "" + # Optional: Server-side Better Auth JWKS endpoint for MCP OAuth verification. + # Leave empty to fetch keys from the public Better Auth issuer. Set an internal HTTP URL only when + # the cluster cannot reach its public origin and the application network is trusted. Include the + # configured service port and complete custom subpath, if any. + mcpOauthJwksUrl: "" + ########################################################## # Enterprise Configuration ########################################################## @@ -618,6 +624,129 @@ externalSecret: refreshInterval: "1h" # Frequency of secret sync files: {} +########################################################## +# AuthZed / SpiceDB Authorization Configuration +########################################################## +authzed: + # Formbricks v6 uses SpiceDB as its sole authorization decision engine. + enabled: true + + # Existing releases must complete the documented schema/backfill/audit preparation before their + # first v6 Helm upgrade. Fresh installs do not need to set this value. + migrationAcknowledged: false + + initialization: + # Fresh self-hosted installs run the release-matched schema and relationship preparation Job. + # Set this to false only when an operator performs the same guarded cutover externally, and set + # migrationAcknowledged=true only after that preparation has completed successfully. + enabled: true + + # selfHosted creates a SpiceDBCluster. external only configures the app client. + mode: selfHosted + + # Stable identifier used by the Formbricks authorization model. + systemKey: formbricks + + # grpc:// is intentionally omitted. AuthZed SDKs accept host:port endpoints. + # For selfHosted mode this defaults to formbricks-spicedb:50051. + endpoint: "" + # null selects the safe mode-specific default: plaintext for the internal + # selfHosted service and TLS for external endpoints. Set true explicitly only + # when an external endpoint intentionally accepts plaintext gRPC. + insecure: null + consistency: fully_consistent + + auth: + # Secret must contain tokenKey. When empty in selfHosted mode, the chart + # creates a stable secret and reuses it across Helm upgrades. + existingSecret: "" + tokenKey: preshared_key + + datastore: + # Production deployments should provision a dedicated database and role, + # then expose its URI through this secret. The secret may be the same as auth.existingSecret. + existingSecret: "" + uriKey: datastore_uri + + cluster: + name: "" + version: v1.52.0 + channel: stable + replicas: 2 + config: + datastoreEngine: postgres + logLevel: info + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + # Operator patches are applied after the generated resource patch and can + # override it when a deployment needs more specific container settings. + patches: [] + + # Creates the dedicated `spicedb` database and role only when the bundled + # PostgreSQL dependency is enabled and no datastore secret is supplied. + bundledPostgresqlBootstrap: + enabled: true + image: postgres:17-alpine + backoffLimit: 10 + # The administrative role the bootstrap Job connects as to create the + # dedicated `spicedb` role and database. It needs CREATEROLE and CREATEDB, + # not necessarily superuser. + # + # CREATE DATABASE ... OWNER also requires being able to SET ROLE to the + # owner, which CREATEROLE alone does not give, so the Job grants itself the + # `spicedb` role first. From PostgreSQL 16 that works only for a role it + # holds ADMIN OPTION on — which creating the role confers, so the only + # unsupported case is a `spicedb` role someone else already created. See the + # chart README. + # + # Defaults to the `postgres` superuser the bundled subchart creates. An + # existing PostgreSQL that was installed without one (see + # postgresql.auth.enablePostgresUser) would otherwise fail this Job, so point + # these at whichever role does hold those privileges instead of disabling + # bootstrap wholesale. + adminUsername: postgres + adminDatabase: postgres + # Where that role's password lives. Both default to the bundled PostgreSQL + # admin credentials, so overriding adminUsername alone is not enough — set + # these too whenever the role is not the bundled `postgres` superuser. + # Setting the name without the key is refused as well: the key would + # otherwise fall back to the subchart's own key name and be looked up inside + # your Secret, which fails only once the Pod is created. + adminPasswordSecretName: "" + adminPasswordKey: "" + + # Cloud installations can bootstrap a dedicated database on an existing + # PostgreSQL server. The admin Secret is read only by the short-lived Job. + # Its URL must explicitly set sslmode=require, verify-ca, or verify-full. + externalPostgresqlBootstrap: + enabled: false + image: postgres:17-alpine + backoffLimit: 10 + adminSecretName: "" + adminUrlKey: DATABASE_URL + databaseNameKey: database_name + databaseUsernameKey: database_username + databasePasswordKey: database_password + + # Install one operator per cluster. Disable this when a shared operator + # already watches the Formbricks namespace (the Formbricks Cloud topology). + operator: + # Breaking in v6: the default changed from false to true. Existing clusters with a compatible + # shared operator must keep this false to prevent two operators reconciling the same cluster. + # Set false when a compatible cluster-wide operator already watches this namespace. + install: true + +# Values passed to the bundled spicedb-operator dependency when +# authzed.operator.install=true. +spicedbOperator: + watchNamespaces: [] + serviceMonitor: + enabled: false + ########################################################## # Ingress Configuration ########################################################## @@ -1405,6 +1534,9 @@ postgresql: repository: pgvector/pgvector tag: pg17 auth: + # The bundled AuthZed bootstrap creates a dedicated role and database and + # therefore requires the PostgreSQL administrator account. + enablePostgresUser: true username: formbricks database: formbricks existingSecret: "formbricks-app-secrets" @@ -1417,6 +1549,16 @@ postgresql: persistence: enabled: true size: 10Gi + # The upstream nano preset leaves too little memory headroom when the + # bundled database also serves SpiceDB. Helm cannot condition dependency + # values on authzed.enabled, so retain the live-validated safe baseline. + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 1Gi podSecurityContext: enabled: true fsGroup: 1001 diff --git a/charts/spicedb-operator/Chart.yaml b/charts/spicedb-operator/Chart.yaml new file mode 100644 index 000000000000..39c569f7c0f0 --- /dev/null +++ b/charts/spicedb-operator/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: spicedb-operator +description: Namespace-scoped deployment of the AuthZed SpiceDB Operator +type: application +version: 0.1.0 +appVersion: "v1.25.1" +home: https://github.com/authzed/spicedb-operator +sources: + - https://github.com/authzed/spicedb-operator +annotations: + artifacthub.io/crds: | + - kind: SpiceDBCluster + version: v1alpha1 + name: spicedbclusters.authzed.com + displayName: SpiceDB Cluster + description: A SpiceDB cluster managed by the AuthZed operator. diff --git a/charts/spicedb-operator/LICENSE b/charts/spicedb-operator/LICENSE new file mode 100644 index 000000000000..d64569567334 --- /dev/null +++ b/charts/spicedb-operator/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/charts/spicedb-operator/README.md b/charts/spicedb-operator/README.md new file mode 100644 index 000000000000..87ace42ebc43 --- /dev/null +++ b/charts/spicedb-operator/README.md @@ -0,0 +1,16 @@ +# SpiceDB Operator Helm Chart + +This first-party wrapper installs the AuthZed SpiceDB Operator with namespace-scoped RBAC. It vendors the CRD and +update graph from [`authzed/spicedb-operator` v1.25.1](https://github.com/authzed/spicedb-operator/releases/tag/v1.25.1) +and pins the operator's multi-architecture image digest. + +```yaml +watchNamespaces: + - formbricks +``` + +Install exactly one operator release per cluster and list every namespace that may contain a `SpiceDBCluster`. +The CRD is installed from the chart's `crds/` directory. Helm does not upgrade or delete CRDs automatically; apply +the matching CRD before upgrading this chart to a newer operator version. + +The vendored upstream files remain available under the Apache License 2.0 in [LICENSE](LICENSE). diff --git a/charts/spicedb-operator/crds/authzed.com_spicedbclusters.yaml b/charts/spicedb-operator/crds/authzed.com_spicedbclusters.yaml new file mode 100644 index 000000000000..ea2306ec86df --- /dev/null +++ b/charts/spicedb-operator/crds/authzed.com_spicedbclusters.yaml @@ -0,0 +1,352 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: spicedbclusters.authzed.com +spec: + group: authzed.com + names: + categories: + - authzed + kind: SpiceDBCluster + listKind: SpiceDBClusterList + plural: spicedbclusters + shortNames: + - spicedbs + singular: spicedbcluster + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + - jsonPath: .spec.channel + name: Channel + type: string + - jsonPath: .spec.version + name: Desired + type: string + - jsonPath: .status.version.name + name: Current + type: string + - jsonPath: .status.conditions[?(@.type=='ConfigurationWarning')].status + name: Warnings + type: string + - jsonPath: .status.conditions[?(@.type=='Migrating')].status + name: Migrating + type: string + - jsonPath: .status.conditions[?(@.type=='RollingDeployment')].status + name: Updating + type: string + - jsonPath: .status.conditions[?(@.type=='ConditionValidatingFailed')].status + name: Invalid + type: string + - jsonPath: .status.conditions[?(@.type=='Paused')].status + name: Paused + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: SpiceDBCluster defines all options for a full SpiceDB cluster + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ClusterSpec holds the desired state of the cluster. + properties: + baseImage: + description: |- + BaseImage specifies the base container image to use for SpiceDB. + If not specified, will fall back to the operator's --base-image flag, + then to the imageName defined in the update graph. + type: string + channel: + description: |- + Channel is a defined series of updates that operator should follow. + The operator is configured with a datasource that configures available + channels and update paths. + If `version` is not specified, then the operator will keep SpiceDB + up-to-date with the current head of the channel. + If `version` is specified, then the operator will write available updates + in the status. + type: string + config: + description: Config values to be passed to the cluster + type: object + x-kubernetes-preserve-unknown-fields: true + credentials: + description: |- + Credentials configures per-field secret references for sensitive config. + Mutually exclusive with SecretRef. + properties: + datastoreURI: + description: DatastoreURI configures the source for the datastore + connection string. + properties: + key: + description: |- + Key is the key within the Secret. Defaults to the standard SpiceDB key + name for this credential (datastore_uri or preshared_key) if omitted. + type: string + secretName: + description: SecretName is the name of the Kubernetes Secret + in the same namespace. + type: string + skip: + description: |- + Skip instructs the operator not to validate or inject this credential. + Use when the credential is provided externally (CSI driver, workload + identity, sidecar proxy). When true, SecretName and Key are ignored. + type: boolean + type: object + migrationSecrets: + description: MigrationSecrets configures the source for the migration + secrets. + properties: + key: + description: |- + Key is the key within the Secret. Defaults to the standard SpiceDB key + name for this credential (datastore_uri or preshared_key) if omitted. + type: string + secretName: + description: SecretName is the name of the Kubernetes Secret + in the same namespace. + type: string + skip: + description: |- + Skip instructs the operator not to validate or inject this credential. + Use when the credential is provided externally (CSI driver, workload + identity, sidecar proxy). When true, SecretName and Key are ignored. + type: boolean + type: object + presharedKey: + description: PresharedKey configures the source for the gRPC preshared + key. + properties: + key: + description: |- + Key is the key within the Secret. Defaults to the standard SpiceDB key + name for this credential (datastore_uri or preshared_key) if omitted. + type: string + secretName: + description: SecretName is the name of the Kubernetes Secret + in the same namespace. + type: string + skip: + description: |- + Skip instructs the operator not to validate or inject this credential. + Use when the credential is provided externally (CSI driver, workload + identity, sidecar proxy). When true, SecretName and Key are ignored. + type: boolean + type: object + type: object + patches: + description: |- + Patches is a list of patches to apply to generated resources. + If multiple patches apply to the same object and field, later patches + in the list take precedence over earlier ones. + items: + description: Patch represents a single change to apply to generated + manifests + properties: + kind: + description: Kind targets an object by its kubernetes Kind name. + type: string + patch: + description: |- + Patch is an inlined representation of a structured merge patch (one that + just specifies the structure and fields to be modified) or a an explicit + JSON6902 patch operation. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - patch + type: object + type: array + secretName: + description: |- + SecretName points to a secret (in the same namespace) that holds secret + config for the cluster like passwords, credentials, etc. + If the secret is omitted, one will be generated + type: string + version: + description: |- + Version is the name of the version of SpiceDB that will be run. + The version is usually a simple version string like `v1.13.0`, but the + operator is configured with a data source that tells it what versions + are allowed, and they may have other names. + If omitted, the newest version in the head of the channel will be used. + Note that the `config.image` field will take precedence over + version/channel, if it is specified + type: string + type: object + status: + description: ClusterStatus communicates the observed state of the cluster. + properties: + availableVersions: + description: |- + AvailableVersions is a list of versions that the currently running + version can be updated to. Only applies if using an update channel. + items: + properties: + attributes: + description: |- + Attributes is an optional set of descriptors for the update, which + carry additional information like whether there will be a migration + if this version is selected. + items: + type: string + type: array + channel: + description: Channel is the name of the channel this version + is in + type: string + description: + description: Description a human-readable description of the + update. + type: string + name: + description: Name is the identifier for this version + type: string + required: + - channel + - name + type: object + type: array + conditions: + description: Conditions for the current state of the Stack. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + currentMigrationHash: + description: |- + CurrentMigrationHash is a hash of the currently running migration target and config. + If this is equal to TargetMigrationHash (and there are no conditions) then the datastore + is fully migrated. + type: string + image: + description: Image is the image that is or will be used for this cluster + type: string + migration: + description: Migration is the name of the last migration applied + type: string + observedGeneration: + description: |- + ObservedGeneration represents the .metadata.generation that has been + seen by the controller. + format: int64 + minimum: 0 + type: integer + phase: + description: Phase is the currently running phase (used for phased + migrations) + type: string + secretHash: + description: SecretHash is a digest of the last applied secret + type: string + targetMigrationHash: + description: TargetMigrationHash is a hash of the desired migration + target and config + type: string + version: + description: |- + CurrentVersion is a description of the currently selected version from + the channel, if an update channel is being used. + properties: + attributes: + description: |- + Attributes is an optional set of descriptors for the update, which + carry additional information like whether there will be a migration + if this version is selected. + items: + type: string + type: array + channel: + description: Channel is the name of the channel this version is + in + type: string + description: + description: Description a human-readable description of the update. + type: string + name: + description: Name is the identifier for this version + type: string + required: + - channel + - name + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/spicedb-operator/files/update-graph.yaml b/charts/spicedb-operator/files/update-graph.yaml new file mode 100644 index 000000000000..2e427d5f0405 --- /dev/null +++ b/charts/spicedb-operator/files/update-graph.yaml @@ -0,0 +1,3475 @@ +channels: +- edges: + v1.2.0: + - v1.3.0 + - v1.4.0 + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.3.0: + - v1.4.0 + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.4.0: + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.5.0: + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.6.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.7.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.7.1: + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.8.0: + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.9.0: + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.10.0: + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.11.0: + - v1.12.0 + - v1.13.0 + - v1.14.0-phase1 + v1.12.0: + - v1.13.0 + - v1.14.0-phase1 + v1.13.0: + - v1.14.0-phase1 + v1.14.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.14.0-phase1: + - v1.14.0-phase2 + v1.14.0-phase2: + - v1.14.0 + v1.14.1: + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.15.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.16.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.16.1: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.16.2: + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.17.0: + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.18.0: + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.19.1: + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.21.0: + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.22.2: + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.23.1: + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.24.0: + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.25.0: + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.26.0: + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.29.5: + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.30.0: + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.31.0: + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.32.0: + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.33.1: + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.34.0: + - v1.35.3 + - v1.36.2 + v1.35.3: + - v1.36.2 + v1.36.2: + - v1.37.1 + - v1.38.0 + v1.37.1: + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.38.0: + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.39.1: + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.40.1: + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.42.1: + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.45.4: + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.47.1: + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.48.0: + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.49.2: + - v1.51.1 + - v1.52.0 + v1.51.1: + - v1.52.0 + metadata: + datastore: postgres + default: "true" + name: stable + nodes: + - id: v1.52.0 + migration: add-index-for-transaction-gc + tag: v1.52.0 + - id: v1.51.1 + migration: add-index-for-transaction-gc + tag: v1.51.1 + - id: v1.49.2 + migration: add-index-for-transaction-gc + tag: v1.49.2 + - id: v1.48.0 + migration: add-index-for-transaction-gc + tag: v1.48.0 + - id: v1.47.1 + migration: add-index-for-transaction-gc + tag: v1.47.1 + - id: v1.45.4 + migration: add-index-for-transaction-gc + tag: v1.45.4 + - id: v1.42.1 + migration: add-index-for-transaction-gc + tag: v1.42.1 + - id: v1.40.1 + migration: add-index-for-transaction-gc + tag: v1.40.1 + - id: v1.39.1 + migration: add-watch-api-index-to-relation-tuple-table + tag: v1.39.1 + - id: v1.38.0 + migration: add-metadata-to-transaction-table + tag: v1.38.0 + - id: v1.37.1 + migration: create-relationships-counters-table + tag: v1.37.1 + - id: v1.36.2 + migration: create-relationships-counters-table + tag: v1.36.2 + - id: v1.35.3 + migration: create-relationships-counters-table + tag: v1.35.3 + - id: v1.34.0 + migration: create-relationships-counters-table + tag: v1.34.0 + - id: v1.33.1 + migration: add-rel-by-alive-resource-relation-subject + tag: v1.33.1 + - id: v1.32.0 + migration: add-rel-by-alive-resource-relation-subject + tag: v1.32.0 + - id: v1.31.0 + migration: add-rel-by-alive-resource-relation-subject + tag: v1.31.0 + - id: v1.30.0 + migration: add-rel-by-alive-resource-relation-subject + tag: v1.30.0 + - id: v1.29.5 + migration: add-rel-by-alive-resource-relation-subject + tag: v1.29.5 + - id: v1.26.0 + migration: add-rel-by-alive-resource-relation-subject + tag: v1.26.0 + - id: v1.25.0 + migration: add-gc-covering-index + tag: v1.25.0 + - id: v1.24.0 + migration: add-gc-covering-index + tag: v1.24.0 + - id: v1.23.1 + migration: add-gc-covering-index + tag: v1.23.1 + - id: v1.22.2 + migration: add-gc-covering-index + tag: v1.22.2 + - id: v1.21.0 + migration: add-gc-covering-index + tag: v1.21.0 + - id: v1.19.1 + migration: add-gc-covering-index + tag: v1.19.1 + - id: v1.18.0 + migration: drop-bigserial-ids + tag: v1.18.0 + - id: v1.17.0 + migration: drop-bigserial-ids + tag: v1.17.0 + - id: v1.16.2 + migration: drop-bigserial-ids + tag: v1.16.2 + - id: v1.16.1 + migration: drop-bigserial-ids + tag: v1.16.1 + - id: v1.16.0 + migration: drop-bigserial-ids + tag: v1.16.0 + - id: v1.15.0 + migration: drop-bigserial-ids + tag: v1.15.0 + - id: v1.14.1 + migration: drop-bigserial-ids + tag: v1.14.1 + - id: v1.14.0 + migration: drop-bigserial-ids + tag: v1.14.0 + - id: v1.14.0-phase2 + migration: add-xid-constraints + phase: write-both-read-new + tag: v1.14.0 + - id: v1.14.0-phase1 + migration: add-xid-columns + phase: write-both-read-old + tag: v1.14.0 + - id: v1.13.0 + migration: add-ns-config-id + tag: v1.13.0 + - id: v1.12.0 + migration: add-ns-config-id + tag: v1.12.0 + - id: v1.11.0 + migration: add-ns-config-id + tag: v1.11.0 + - id: v1.10.0 + migration: add-ns-config-id + tag: v1.10.0 + - id: v1.9.0 + migration: add-unique-datastore-id + tag: v1.9.0 + - id: v1.8.0 + migration: add-unique-datastore-id + tag: v1.8.0 + - id: v1.7.1 + migration: add-unique-datastore-id + tag: v1.7.1 + - id: v1.7.0 + migration: add-unique-datastore-id + tag: v1.7.0 + - id: v1.6.0 + migration: add-unique-datastore-id + tag: v1.6.0 + - id: v1.5.0 + migration: add-transaction-timestamp-index + tag: v1.5.0 + - id: v1.4.0 + migration: add-transaction-timestamp-index + tag: v1.4.0 + - id: v1.3.0 + migration: add-transaction-timestamp-index + tag: v1.3.0 + - id: v1.2.0 + migration: add-transaction-timestamp-index + tag: v1.2.0 +- edges: + v1.2.0: + - v1.3.0 + - v1.4.0 + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.3.0: + - v1.4.0 + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.4.0: + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.5.0: + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.6.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.7.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.7.1: + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.8.0: + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.9.0: + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.10.0: + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.11.0: + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.12.0: + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.13.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.14.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.14.1: + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.15.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.16.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.16.1: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.16.2: + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.17.0: + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.18.0: + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.19.1: + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.21.0: + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.22.2: + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.23.1: + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.24.0: + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.25.0: + - v1.26.0 + - v1.29.5 + - v1.30.0-phase1 + v1.26.0: + - v1.29.5 + - v1.30.0-phase1 + v1.29.5: + - v1.30.0-phase1 + v1.30.0: + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.30.0-phase1: + - v1.30.0 + v1.31.0: + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.32.0: + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.33.1: + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.34.0: + - v1.35.3 + - v1.36.2 + v1.35.3: + - v1.36.2 + v1.36.2: + - v1.37.1 + - v1.38.0 + v1.37.1: + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.38.0: + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.39.1: + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.40.1: + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.42.1: + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.45.4: + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.47.1: + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.48.0: + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.49.2: + - v1.51.1 + - v1.52.0 + v1.51.1: + - v1.52.0 + metadata: + datastore: cockroachdb + default: "true" + name: stable + nodes: + - id: v1.52.0 + migration: add-expiration-support + tag: v1.52.0 + - id: v1.51.1 + migration: add-expiration-support + tag: v1.51.1 + - id: v1.49.2 + migration: add-expiration-support + tag: v1.49.2 + - id: v1.48.0 + migration: add-expiration-support + tag: v1.48.0 + - id: v1.47.1 + migration: add-expiration-support + tag: v1.47.1 + - id: v1.45.4 + migration: add-expiration-support + tag: v1.45.4 + - id: v1.42.1 + migration: add-expiration-support + tag: v1.42.1 + - id: v1.40.1 + migration: add-expiration-support + tag: v1.40.1 + - id: v1.39.1 + migration: add-transaction-metadata-table + tag: v1.39.1 + - id: v1.38.0 + migration: add-transaction-metadata-table + tag: v1.38.0 + - id: v1.37.1 + migration: add-integrity-relationtuple-table + tag: v1.37.1 + - id: v1.36.2 + migration: add-integrity-relationtuple-table + tag: v1.36.2 + - id: v1.35.3 + migration: add-relationship-counters-table + tag: v1.35.3 + - id: v1.34.0 + migration: add-relationship-counters-table + tag: v1.34.0 + - id: v1.33.1 + migration: remove-stats-table + tag: v1.33.1 + - id: v1.32.0 + migration: remove-stats-table + tag: v1.32.0 + - id: v1.31.0 + migration: remove-stats-table + tag: v1.31.0 + - id: v1.30.0 + migration: remove-stats-table + tag: v1.30.0 + - id: v1.30.0-phase1 + migration: add-caveats + tag: v1.30.0 + - id: v1.29.5 + migration: add-caveats + tag: v1.29.5 + - id: v1.26.0 + migration: add-caveats + tag: v1.26.0 + - id: v1.25.0 + migration: add-caveats + tag: v1.25.0 + - id: v1.24.0 + migration: add-caveats + tag: v1.24.0 + - id: v1.23.1 + migration: add-caveats + tag: v1.23.1 + - id: v1.22.2 + migration: add-caveats + tag: v1.22.2 + - id: v1.21.0 + migration: add-caveats + tag: v1.21.0 + - id: v1.19.1 + migration: add-caveats + tag: v1.19.1 + - id: v1.18.0 + migration: add-caveats + tag: v1.18.0 + - id: v1.17.0 + migration: add-caveats + tag: v1.17.0 + - id: v1.16.2 + migration: add-caveats + tag: v1.16.2 + - id: v1.16.1 + migration: add-caveats + tag: v1.16.1 + - id: v1.16.0 + migration: add-caveats + tag: v1.16.0 + - id: v1.15.0 + migration: add-caveats + tag: v1.15.0 + - id: v1.14.1 + migration: add-caveats + tag: v1.14.1 + - id: v1.14.0 + migration: add-caveats + tag: v1.14.0 + - id: v1.13.0 + migration: add-metadata-and-counters + tag: v1.13.0 + - id: v1.12.0 + migration: add-metadata-and-counters + tag: v1.12.0 + - id: v1.11.0 + migration: add-metadata-and-counters + tag: v1.11.0 + - id: v1.10.0 + migration: add-metadata-and-counters + tag: v1.10.0 + - id: v1.9.0 + migration: add-metadata-and-counters + tag: v1.9.0 + - id: v1.8.0 + migration: add-metadata-and-counters + tag: v1.8.0 + - id: v1.7.1 + migration: add-metadata-and-counters + tag: v1.7.1 + - id: v1.7.0 + migration: add-metadata-and-counters + tag: v1.7.0 + - id: v1.6.0 + migration: add-metadata-and-counters + tag: v1.6.0 + - id: v1.5.0 + migration: add-transactions-table + tag: v1.5.0 + - id: v1.4.0 + migration: add-transactions-table + tag: v1.4.0 + - id: v1.3.0 + migration: add-transactions-table + tag: v1.3.0 + - id: v1.2.0 + migration: add-transactions-table + tag: v1.2.0 +- edges: + v1.7.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.7.1: + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.8.0: + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.9.0: + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.10.0: + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.11.0: + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.12.0: + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.13.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.14.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.14.1: + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.15.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.16.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.16.1: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.16.2: + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.17.0: + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.18.0: + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.19.1: + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.21.0: + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.22.2: + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.23.1: + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.24.0: + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.25.0: + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.26.0: + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.29.5: + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.30.0: + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.31.0: + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.32.0: + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.33.1: + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.34.0: + - v1.35.3 + - v1.36.2 + v1.35.3: + - v1.36.2 + v1.36.2: + - v1.37.1 + - v1.38.0 + v1.37.1: + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.38.0: + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.39.1: + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.40.1: + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.42.1: + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.45.4: + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.47.1: + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.48.0: + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.49.2: + - v1.51.1 + - v1.52.0 + v1.51.1: + - v1.52.0 + metadata: + datastore: mysql + default: "true" + name: stable + nodes: + - id: v1.52.0 + migration: add_expiration_to_relation_tuple + tag: v1.52.0 + - id: v1.51.1 + migration: add_expiration_to_relation_tuple + tag: v1.51.1 + - id: v1.49.2 + migration: add_expiration_to_relation_tuple + tag: v1.49.2 + - id: v1.48.0 + migration: add_expiration_to_relation_tuple + tag: v1.48.0 + - id: v1.47.1 + migration: add_expiration_to_relation_tuple + tag: v1.47.1 + - id: v1.45.4 + migration: add_expiration_to_relation_tuple + tag: v1.45.4 + - id: v1.42.1 + migration: add_expiration_to_relation_tuple + tag: v1.42.1 + - id: v1.40.1 + migration: add_expiration_to_relation_tuple + tag: v1.40.1 + - id: v1.39.1 + migration: add_metadata_to_transaction_table + tag: v1.39.1 + - id: v1.38.0 + migration: add_metadata_to_transaction_table + tag: v1.38.0 + - id: v1.37.1 + migration: add_relationship_counters_table + tag: v1.37.1 + - id: v1.36.2 + migration: add_relationship_counters_table + tag: v1.36.2 + - id: v1.35.3 + migration: add_relationship_counters_table + tag: v1.35.3 + - id: v1.34.0 + migration: add_relationship_counters_table + tag: v1.34.0 + - id: v1.33.1 + migration: watch_api_relation_tuple_index + tag: v1.33.1 + - id: v1.32.0 + migration: watch_api_relation_tuple_index + tag: v1.32.0 + - id: v1.31.0 + migration: watch_api_relation_tuple_index + tag: v1.31.0 + - id: v1.30.0 + migration: watch_api_relation_tuple_index + tag: v1.30.0 + - id: v1.29.5 + migration: watch_api_relation_tuple_index + tag: v1.29.5 + - id: v1.26.0 + migration: longblob_definitions + tag: v1.26.0 + - id: v1.25.0 + migration: longblob_definitions + tag: v1.25.0 + - id: v1.24.0 + migration: extend_object_id + tag: v1.24.0 + - id: v1.23.1 + migration: extend_object_id + tag: v1.23.1 + - id: v1.22.2 + migration: extend_object_id + tag: v1.22.2 + - id: v1.21.0 + migration: extend_object_id + tag: v1.21.0 + - id: v1.19.1 + migration: add_caveat + tag: v1.19.1 + - id: v1.18.0 + migration: add_caveat + tag: v1.18.0 + - id: v1.17.0 + migration: add_caveat + tag: v1.17.0 + - id: v1.16.2 + migration: add_caveat + tag: v1.16.2 + - id: v1.16.1 + migration: add_caveat + tag: v1.16.1 + - id: v1.16.0 + migration: add_caveat + tag: v1.16.0 + - id: v1.15.0 + migration: add_caveat + tag: v1.15.0 + - id: v1.14.1 + migration: add_caveat + tag: v1.14.1 + - id: v1.14.0 + migration: add_caveat + tag: v1.14.0 + - id: v1.13.0 + migration: add_ns_config_id + tag: v1.13.0 + - id: v1.12.0 + migration: add_ns_config_id + tag: v1.12.0 + - id: v1.11.0 + migration: add_ns_config_id + tag: v1.11.0 + - id: v1.10.0 + migration: add_ns_config_id + tag: v1.10.0 + - id: v1.9.0 + migration: add_unique_datastore_id + tag: v1.9.0 + - id: v1.8.0 + migration: add_unique_datastore_id + tag: v1.8.0 + - id: v1.7.1 + migration: add_unique_datastore_id + tag: v1.7.1 + - id: v1.7.0 + migration: add_unique_datastore_id + tag: v1.7.0 +- edges: + v1.8.0: + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.9.0: + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.10.0: + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.11.0: + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.12.0: + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.13.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.14.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.14.1: + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.15.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.16.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.16.1: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.16.2: + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.17.0: + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.18.0: + - v1.19.1 + - v1.21.0 + - v1.22.2-phase1 + v1.19.1: + - v1.21.0 + - v1.22.2-phase1 + v1.21.0: + - v1.22.2-phase1 + v1.22.2: + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5-phase1 + v1.22.2-phase1: + - v1.22.2-phase2 + v1.22.2-phase2: + - v1.22.2 + v1.23.1: + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5-phase1 + v1.24.0: + - v1.25.0 + - v1.26.0 + - v1.29.5-phase1 + v1.25.0: + - v1.26.0 + - v1.29.5-phase1 + v1.26.0: + - v1.29.5-phase1 + v1.29.5: + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.29.5-phase1: + - v1.29.5 + v1.30.0: + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.31.0: + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.32.0: + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.33.1: + - v1.34.0 + - v1.35.3 + - v1.36.2 + v1.34.0: + - v1.35.3 + - v1.36.2 + v1.35.3: + - v1.36.2 + v1.36.2: + - v1.37.1 + - v1.38.0 + v1.37.1: + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.38.0: + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.39.1: + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.40.1: + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.42.1: + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.45.4: + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.47.1: + - v1.48.0 + - v1.49.2 + - v1.51.1 + v1.48.0: + - v1.49.2 + - v1.51.1 + v1.49.2: + - v1.51.1 + metadata: + datastore: spanner + default: "true" + name: stable + nodes: + - id: v1.51.1 + migration: add-expiration-support + tag: v1.52.0 + - id: v1.49.2 + migration: add-expiration-support + tag: v1.51.1 + - id: v1.48.0 + migration: add-expiration-support + tag: v1.48.0 + - id: v1.47.1 + migration: add-expiration-support + tag: v1.47.1 + - id: v1.45.4 + migration: add-expiration-support + tag: v1.45.4 + - id: v1.42.1 + migration: add-expiration-support + tag: v1.42.1 + - id: v1.40.1 + migration: add-expiration-support + tag: v1.40.1 + - id: v1.39.1 + migration: add-transaction-metadata-table + tag: v1.39.1 + - id: v1.38.0 + migration: add-transaction-metadata-table + tag: v1.38.0 + - id: v1.37.1 + migration: add-relationship-counter-table + tag: v1.37.1 + - id: v1.36.2 + migration: add-relationship-counter-table + tag: v1.36.2 + - id: v1.35.3 + migration: add-relationship-counter-table + tag: v1.35.3 + - id: v1.34.0 + migration: add-relationship-counter-table + tag: v1.34.0 + - id: v1.33.1 + migration: delete-older-changestreams + tag: v1.33.1 + - id: v1.32.0 + migration: delete-older-changestreams + tag: v1.32.0 + - id: v1.31.0 + migration: delete-older-changestreams + tag: v1.31.0 + - id: v1.30.0 + migration: delete-older-changestreams + tag: v1.30.0 + - id: v1.29.5 + migration: delete-older-changestreams + tag: v1.29.5 + - id: v1.29.5-phase1 + migration: register-combined-change-stream + tag: v1.29.5 + - id: v1.26.0 + migration: drop-changelog-table + tag: v1.26.0 + - id: v1.25.0 + migration: drop-changelog-table + tag: v1.25.0 + - id: v1.24.0 + migration: drop-changelog-table + tag: v1.24.0 + - id: v1.23.1 + migration: drop-changelog-table + tag: v1.23.1 + - id: v1.22.2 + migration: drop-changelog-table + tag: v1.22.2 + - id: v1.22.2-phase2 + migration: register-tuple-change-stream + phase: write-changelog-read-stream + tag: v1.22.2 + - id: v1.22.2-phase1 + migration: register-tuple-change-stream + phase: write-changelog-read-changelog + tag: v1.22.2 + - id: v1.21.0 + migration: add-caveats + tag: v1.21.0 + - id: v1.19.1 + migration: add-caveats + tag: v1.19.1 + - id: v1.18.0 + migration: add-caveats + tag: v1.18.0 + - id: v1.17.0 + migration: add-caveats + tag: v1.17.0 + - id: v1.16.2 + migration: add-caveats + tag: v1.16.2 + - id: v1.16.1 + migration: add-caveats + tag: v1.16.1 + - id: v1.16.0 + migration: add-caveats + tag: v1.16.0 + - id: v1.15.0 + migration: add-caveats + tag: v1.15.0 + - id: v1.14.1 + migration: add-caveats + tag: v1.14.1 + - id: v1.14.0 + migration: add-caveats + tag: v1.14.0 + - id: v1.13.0 + migration: add-metadata-and-counters + tag: v1.13.0 + - id: v1.12.0 + migration: add-metadata-and-counters + tag: v1.12.0 + - id: v1.11.0 + migration: add-metadata-and-counters + tag: v1.11.0 + - id: v1.10.0 + migration: add-metadata-and-counters + tag: v1.10.0 + - id: v1.9.0 + migration: add-metadata-and-counters + tag: v1.9.0 + - id: v1.8.0 + migration: add-metadata-and-counters + tag: v1.8.0 +- edges: + v1.2.0: + - v1.3.0 + - v1.4.0 + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.3.0: + - v1.4.0 + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.4.0: + - v1.5.0 + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.5.0: + - v1.6.0 + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.6.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.7.0: + - v1.7.1 + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.7.1: + - v1.8.0 + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.8.0: + - v1.9.0 + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.9.0: + - v1.10.0 + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.10.0: + - v1.11.0 + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.11.0: + - v1.12.0 + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.12.0: + - v1.13.0 + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.13.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.14.0: + - v1.14.1 + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.14.1: + - v1.15.0 + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.15.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.16.0: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.16.1: + - v1.16.2 + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.16.2: + - v1.17.0 + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.17.0: + - v1.18.0 + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.18.0: + - v1.19.1 + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.19.1: + - v1.21.0 + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.21.0: + - v1.22.2 + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.22.2: + - v1.23.1 + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.23.1: + - v1.24.0 + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.24.0: + - v1.25.0 + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.25.0: + - v1.26.0 + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.26.0: + - v1.29.5 + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.29.5: + - v1.30.0 + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.30.0: + - v1.31.0 + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.31.0: + - v1.32.0 + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.32.0: + - v1.33.1 + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.33.1: + - v1.34.0 + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.34.0: + - v1.35.3 + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.35.3: + - v1.36.2 + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.36.2: + - v1.37.1 + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.37.1: + - v1.38.0 + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.38.0: + - v1.39.1 + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.39.1: + - v1.40.1 + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.40.1: + - v1.42.1 + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.42.1: + - v1.45.4 + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.45.4: + - v1.47.1 + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.47.1: + - v1.48.0 + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.48.0: + - v1.49.2 + - v1.51.1 + - v1.52.0 + v1.49.2: + - v1.51.1 + - v1.52.0 + v1.51.1: + - v1.52.0 + metadata: + datastore: memory + default: "true" + name: stable + nodes: + - id: v1.52.0 + tag: v1.52.0 + - id: v1.51.1 + tag: v1.51.1 + - id: v1.49.2 + tag: v1.49.2 + - id: v1.48.0 + tag: v1.48.0 + - id: v1.47.1 + tag: v1.47.1 + - id: v1.45.4 + tag: v1.45.4 + - id: v1.42.1 + tag: v1.42.1 + - id: v1.40.1 + tag: v1.40.1 + - id: v1.39.1 + tag: v1.39.1 + - id: v1.38.0 + tag: v1.38.0 + - id: v1.37.1 + tag: v1.37.1 + - id: v1.36.2 + tag: v1.36.2 + - id: v1.35.3 + tag: v1.35.3 + - id: v1.34.0 + tag: v1.34.0 + - id: v1.33.1 + tag: v1.33.1 + - id: v1.32.0 + tag: v1.32.0 + - id: v1.31.0 + tag: v1.31.0 + - id: v1.30.0 + tag: v1.30.0 + - id: v1.29.5 + tag: v1.29.5 + - id: v1.26.0 + tag: v1.26.0 + - id: v1.25.0 + tag: v1.25.0 + - id: v1.24.0 + tag: v1.24.0 + - id: v1.23.1 + tag: v1.23.1 + - id: v1.22.2 + tag: v1.22.2 + - id: v1.21.0 + tag: v1.21.0 + - id: v1.19.1 + tag: v1.19.1 + - id: v1.18.0 + tag: v1.18.0 + - id: v1.17.0 + tag: v1.17.0 + - id: v1.16.2 + tag: v1.16.2 + - id: v1.16.1 + tag: v1.16.1 + - id: v1.16.0 + tag: v1.16.0 + - id: v1.15.0 + tag: v1.15.0 + - id: v1.14.1 + tag: v1.14.1 + - id: v1.14.0 + tag: v1.14.0 + - id: v1.13.0 + tag: v1.13.0 + - id: v1.12.0 + tag: v1.12.0 + - id: v1.11.0 + tag: v1.11.0 + - id: v1.10.0 + tag: v1.10.0 + - id: v1.9.0 + tag: v1.9.0 + - id: v1.8.0 + tag: v1.8.0 + - id: v1.7.1 + tag: v1.7.1 + - id: v1.7.0 + tag: v1.7.0 + - id: v1.6.0 + tag: v1.6.0 + - id: v1.5.0 + tag: v1.5.0 + - id: v1.4.0 + tag: v1.4.0 + - id: v1.3.0 + tag: v1.3.0 + - id: v1.2.0 + tag: v1.2.0 +imageName: ghcr.io/authzed/spicedb diff --git a/charts/spicedb-operator/templates/_helpers.tpl b/charts/spicedb-operator/templates/_helpers.tpl new file mode 100644 index 000000000000..d7c6be401dca --- /dev/null +++ b/charts/spicedb-operator/templates/_helpers.tpl @@ -0,0 +1,40 @@ +{{- define "spicedb-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "spicedb-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name (include "spicedb-operator.name" .) | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{- define "spicedb-operator.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "spicedb-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "spicedb-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "spicedb-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "spicedb-operator.watchNamespaces" -}} +{{- if .Values.watchNamespaces -}} +{{- join "," .Values.watchNamespaces -}} +{{- else -}} +{{- .Release.Namespace -}} +{{- end -}} +{{- end }} + +{{- define "spicedb-operator.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} +{{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} +{{- end -}} +{{- end }} diff --git a/charts/spicedb-operator/templates/configmap.yaml b/charts/spicedb-operator/templates/configmap.yaml new file mode 100644 index 000000000000..0f1bf1e11c17 --- /dev/null +++ b/charts/spicedb-operator/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "spicedb-operator.fullname" . }}-update-graph + labels: + {{- include "spicedb-operator.labels" . | nindent 4 }} +data: + update-graph.yaml: | +{{ .Files.Get "files/update-graph.yaml" | indent 4 }} diff --git a/charts/spicedb-operator/templates/deployment.yaml b/charts/spicedb-operator/templates/deployment.yaml new file mode 100644 index 000000000000..a879b473b4de --- /dev/null +++ b/charts/spicedb-operator/templates/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "spicedb-operator.fullname" . }} + labels: + {{- include "spicedb-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "spicedb-operator.selectorLabels" . | nindent 6 }} + strategy: + type: Recreate + template: + metadata: + annotations: + checksum/update-graph: {{ .Files.Get "files/update-graph.yaml" | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "spicedb-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "spicedb-operator.fullname" . }} + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: spicedb-operator + image: {{ include "spicedb-operator.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - run + - --crd=false + - --config=/opt/operator/update-graph.yaml + - --watch-namespaces={{ include "spicedb-operator.watchNamespaces" . }} + ports: + - name: metrics + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: metrics + initialDelaySeconds: 10 + timeoutSeconds: 15 + readinessProbe: + httpGet: + path: /healthz + port: metrics + timeoutSeconds: 15 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + runAsNonRoot: true + volumeMounts: + - name: update-graph + mountPath: /opt/operator + readOnly: true + volumes: + - name: update-graph + configMap: + name: {{ include "spicedb-operator.fullname" . }}-update-graph + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/spicedb-operator/templates/rbac.yaml b/charts/spicedb-operator/templates/rbac.yaml new file mode 100644 index 000000000000..033719ab5c37 --- /dev/null +++ b/charts/spicedb-operator/templates/rbac.yaml @@ -0,0 +1,59 @@ +{{- $root := . -}} +{{- $watchNamespaces := .Values.watchNamespaces -}} +{{- if not $watchNamespaces -}} +{{- $watchNamespaces = list .Release.Namespace -}} +{{- end -}} +{{- range $namespace := $watchNamespaces }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "spicedb-operator.fullname" $root }} + namespace: {{ $namespace }} + labels: + {{- include "spicedb-operator.labels" $root | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["endpoints"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["events", "jobs", "secrets", "serviceaccounts", "services"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["delete", "get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] + - apiGroups: ["authzed.com"] + resources: ["spicedbclusters", "spicedbclusters/status"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] + - apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "list", "watch"] + - apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["rolebindings", "roles"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "spicedb-operator.fullname" $root }} + namespace: {{ $namespace }} + labels: + {{- include "spicedb-operator.labels" $root | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "spicedb-operator.fullname" $root }} +subjects: + - kind: ServiceAccount + name: {{ include "spicedb-operator.fullname" $root }} + namespace: {{ $root.Release.Namespace }} +{{- end }} diff --git a/charts/spicedb-operator/templates/service.yaml b/charts/spicedb-operator/templates/service.yaml new file mode 100644 index 000000000000..118af76e5dd0 --- /dev/null +++ b/charts/spicedb-operator/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "spicedb-operator.fullname" . }} + labels: + {{- include "spicedb-operator.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + {{- include "spicedb-operator.selectorLabels" . | nindent 4 }} + ports: + - name: metrics + port: {{ .Values.service.port }} + targetPort: metrics diff --git a/charts/spicedb-operator/templates/serviceaccount.yaml b/charts/spicedb-operator/templates/serviceaccount.yaml new file mode 100644 index 000000000000..9d1dc996eb44 --- /dev/null +++ b/charts/spicedb-operator/templates/serviceaccount.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "spicedb-operator.fullname" . }} + labels: + {{- include "spicedb-operator.labels" . | nindent 4 }} diff --git a/charts/spicedb-operator/templates/servicemonitor.yaml b/charts/spicedb-operator/templates/servicemonitor.yaml new file mode 100644 index 000000000000..2233030b06c0 --- /dev/null +++ b/charts/spicedb-operator/templates/servicemonitor.yaml @@ -0,0 +1,18 @@ +{{- if .Values.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "spicedb-operator.fullname" . }} + labels: + {{- include "spicedb-operator.labels" . | nindent 4 }} + {{- with .Values.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "spicedb-operator.selectorLabels" . | nindent 6 }} + endpoints: + - port: metrics + interval: {{ .Values.serviceMonitor.interval }} +{{- end }} diff --git a/charts/spicedb-operator/values.yaml b/charts/spicedb-operator/values.yaml new file mode 100644 index 000000000000..c0d5f374bcf4 --- /dev/null +++ b/charts/spicedb-operator/values.yaml @@ -0,0 +1,38 @@ +image: + repository: ghcr.io/authzed/spicedb-operator + tag: v1.25.1 + digest: sha256:798037034302a60182e601f5399c476c6dc8bb18c253ba1b3f7431bdeda3bafe + pullPolicy: IfNotPresent + +# A single operator release can watch one or more namespaces. If empty, only +# the Helm release namespace is watched. +watchNamespaces: [] + +replicaCount: 1 + +podAnnotations: {} +podLabels: {} + +resources: + requests: + cpu: 20m + memory: 64Mi + limits: + cpu: 250m + memory: 160Mi + +service: + annotations: {} + port: 8080 + +serviceMonitor: + enabled: false + labels: {} + interval: 30s + +nodeSelector: {} +tolerations: [] +affinity: {} + +nameOverride: "" +fullnameOverride: "" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index af539e7d1b0e..bde1713f569c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -55,7 +55,7 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres ports: - - 5432:5432 + - ${POSTGRES_PORT:-5432}:5432 command: > postgres -c shared_preload_libraries=vector @@ -66,6 +66,81 @@ services: retries: 30 start_period: 10s + authzed-db-bootstrap: + image: pgvector/pgvector:pg18 + restart: "no" + entrypoint: ["/bin/sh", "/usr/local/bin/authzed-postgres-bootstrap.sh"] + environment: + POSTGRES_ADMIN_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable + AUTHZED_DATABASE_NAME: spicedb + AUTHZED_DATABASE_USERNAME: spicedb + AUTHZED_DATABASE_PASSWORD: ${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required} + volumes: + - ./docker/authzed-postgres-bootstrap.sh:/usr/local/bin/authzed-postgres-bootstrap.sh:ro + depends_on: + postgres: + condition: service_healthy + + spicedb-migrate: + image: ${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0} + restart: "no" + command: datastore migrate head + environment: + SPICEDB_DATASTORE_ENGINE: postgres + SPICEDB_DATASTORE_CONN_URI: postgresql://spicedb:${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}@postgres:5432/spicedb?sslmode=disable + depends_on: + authzed-db-bootstrap: + condition: service_completed_successfully + + spicedb: + image: ${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0} + restart: unless-stopped + command: serve + mem_limit: 512m + environment: + SPICEDB_DATASTORE_ENGINE: postgres + SPICEDB_DATASTORE_CONN_URI: postgresql://spicedb:${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}@postgres:5432/spicedb?sslmode=disable + SPICEDB_GRPC_PRESHARED_KEY: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required} + SPICEDB_LOG_FORMAT: json + SPICEDB_LOG_LEVEL: info + SPICEDB_TELEMETRY_ENDPOINT: "" + ports: + - 127.0.0.1:${SPICEDB_GRPC_PORT:-50051}:50051 + depends_on: + spicedb-migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:50051"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + + authzed-cli: + image: ${ZED_IMAGE_REF:-authzed/zed:v1.1.1} + profiles: ["authzed-tools"] + entrypoint: ["zed"] + volumes: + - ./authzed/schema.zed:/schema.zed:ro + + authzed-ui: + image: ${GRPCUI_IMAGE_REF:-fullstorydev/grpcui:v1.5.2} + profiles: ["authzed-ui"] + command: + - -plaintext + - -bind + - 0.0.0.0 + - -port + - "8080" + - -H + - "authorization: Bearer ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required}" + - spicedb:50051 + ports: + - 127.0.0.1:${AUTHZED_GRPCUI_PORT:-50052}:8080 + depends_on: + spicedb: + condition: service_healthy + mailhog: image: arjenz/mailhog ports: @@ -325,7 +400,8 @@ services: cube: image: cubejs/cube:v1.6.6 env_file: - - apps/web/.env + - path: apps/web/.env + required: false command: - sh - -c diff --git a/docker/README.md b/docker/README.md index e786d3568dfa..517b8e274f81 100644 --- a/docker/README.md +++ b/docker/README.md @@ -52,6 +52,89 @@ The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API ( service's `environment` block, then run `docker compose up -d --no-deps --force-recreate cube`. - **Development** (`docker-compose.dev.yml`): Hub uses a dedicated local `hub` database and `HUB_API_KEY` defaults to `dev-api-key`. The dev stack starts `hub` plus `hub-worker`; set `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL`, and any provider credentials in the repo root `.env` to enable Hub embeddings locally. See the [Hub embeddings environment reference](https://hub.formbricks.com/reference/environment-variables/#embeddings) for provider-specific values. Cube starts with the dev stack, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub`, `hub-worker`, and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release. +## AuthZed / SpiceDB + +The production and development Compose stacks include one SpiceDB v1.52.0 service backed by a dedicated +`spicedb` database and login in the bundled PostgreSQL server. `authzed-db-bootstrap` creates or updates the +database credentials, `spicedb-migrate` applies datastore migrations, and only then does `spicedb` start. Both +one-shot services are idempotent. + +For production Docker, generate `AUTHZED_TOKEN` and `AUTHZED_DATABASE_PASSWORD` with +`openssl rand -hex 32` and keep them in the mode-`0600` `.env` file. SpiceDB remains internal at +`spicedb:50051`; it is not published through Traefik. The one-click installer generates both values and +downloads `authzed-postgres-bootstrap.sh` automatically. + +For repository development, `pnpm dev:setup` generates and preserves the same credentials and `pnpm db:up` +starts SpiceDB on `127.0.0.1:50051`. Run the isolated persistence test with: + +```bash +pnpm authzed:smoke +``` + +Run the read-only application client health check with: + +```bash +docker compose --profile authzed-ops run --rm authzed-ops health +``` + +The opt-in operations service uses the same release image and environment as Formbricks, but never starts during +normal `docker compose up`. The health command accepts an empty schema as healthy, prints exactly one JSON +result, and exits `0` only for a healthy connection. Disabled, invalid, authentication, permission, timeout, +overload, unavailable, and unexpected states exit `1` with a stable `authzed_*` code. It never prints the +token, schema, raw SDK error, or stack trace. It is intentionally not exposed through a browser or HTTP route, +and SpiceDB availability does not affect the normal Formbricks `/health` result. Restart Formbricks after +changing AuthZed configuration. + +Check or explicitly apply the canonical Formbricks schema with: + +```bash +docker compose --profile authzed-ops run --rm authzed-ops schema check + +# Empty instances only +docker compose --profile authzed-ops run --rm authzed-ops schema apply + +# Non-empty instances: use the remoteDigest returned by the immediately preceding check +docker compose --profile authzed-ops run --rm authzed-ops schema apply \ + --expected-current-digest sha256: + +# Relationship audit (dry run) +docker compose --profile authzed-ops run --rm authzed-ops backfill + +# Release-matched v6 readiness gate +docker compose --profile authzed-ops run --rm authzed-ops upgrade prepare +docker compose --profile authzed-ops run --rm authzed-ops upgrade check +``` + +The first apply to an empty SpiceDB needs no additional argument. Replacing a non-empty schema requires +`--expected-current-digest sha256:`. The command verifies the write by reading and comparing +the schema again. Fresh installs run the idempotent `authzed-initialize` service independently; Formbricks +startup and `/health` do not depend on it. Existing upgrades require the explicit preparation and read-only gate. See +the [public operations guide](../docs/self-hosting/advanced/authzed-operations.mdx) for the JSON contract, exit +codes, backup requirements, repair, and rollback rules. Repository development retains the equivalent +`pnpm authzed:*` commands. + +`AUTHZED_ENABLED` and `AUTHZED_INSECURE` accept `true`, `false`, `1`, and `0`. Unset means disabled and secure +TLS, respectively. `AUTHZED_ENDPOINT` is a bare `host:port` (including bracketed IPv6) with no scheme or path; +`AUTHZED_CONSISTENCY` accepts both client values, but released v6 Compose deployments require and default to +`fully_consistent`. + +To use the optional authenticated grpcui browser in development: + +```bash +docker compose -f docker-compose.dev.yml --profile authzed-ui up -d authzed-ui +``` + +Open `http://127.0.0.1:50052`. The browser UI and gRPC port are development-only. + +Existing one-click installations keep their customized Compose file during `formbricks.sh update`. Merge all +release-matched AuthZed services and the two generated secrets manually, pass `upgrade prepare` and `upgrade +check`, and only then set `FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true`. Back up both databases first and +never use `docker compose down -v` during migration or rollback. + +The bundled PostgreSQL service keeps `track_commit_timestamp` at its default `off` value. SpiceDB therefore +logs that its Watch API is disabled; schema, relationship, and permission-check APIs are unaffected. A future +consumer of the Watch API must explicitly enable that PostgreSQL setting and account for the required restart. + ## Smart Functionality AI with Qwen/vLLM The Docker stack can optionally run Qwen through vLLM as an OpenAI-compatible `/v1` endpoint. Baseline installs are unchanged: `docker compose up -d` does not start the vLLM service and Formbricks can still run without AI. diff --git a/docker/__tests__/formbricks-script.test.ts b/docker/__tests__/formbricks-script.test.ts index 359438eb4c83..685c9a40dab4 100644 --- a/docker/__tests__/formbricks-script.test.ts +++ b/docker/__tests__/formbricks-script.test.ts @@ -1,6 +1,15 @@ import { load } from "js-yaml"; -import { execFileSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -21,6 +30,17 @@ const createTempDir = (): string => { return tempDir; }; +const parseEnvFile = (contents: string): Map => + new Map( + contents + .trim() + .split("\n") + .map((line) => { + const separatorIndex = line.indexOf("="); + return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)]; + }) + ); + const addFormbricksTraefikLabels = ( composePath: string, hstsEnabled: "y" | "n", @@ -106,6 +126,119 @@ describe("docker/docker-compose.yml Cube configuration", () => { }); }); +describe("docker/formbricks.sh AuthZed setup", () => { + test("writes AuthZed secrets without printing them", () => { + const envPath = join(createTempDir(), ".env"); + const authzedToken = "authzed-token-value"; + const authzedDatabasePassword = "authzed-database-password"; + + const output = execFileSync( + "bash", + [ + "-lc", + 'source "$1"; write_base_env_file "$2" hub-key cube-secret "$3" "$4"', + "bash", + formbricksScriptPath, + envPath, + authzedToken, + authzedDatabasePassword, + ], + { encoding: "utf8" } + ); + + const env = parseEnvFile(readFileSync(envPath, "utf8")); + + expect(output).toBe(""); + expect(output).not.toContain(authzedToken); + expect(output).not.toContain(authzedDatabasePassword); + expect(env.get("AUTHZED_TOKEN")).toBe(authzedToken); + expect(env.get("AUTHZED_DATABASE_PASSWORD")).toBe(authzedDatabasePassword); + expect(env.get("AUTHZED_ENABLED")).toBe("true"); + expect(env.get("AUTHZED_CONSISTENCY")).toBe("fully_consistent"); + expect(env.get("FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED")).toBe("true"); + expect(statSync(envPath).mode & 0o777).toBe(0o600); + }); + + test("blocks customized updates until the AuthZed v6 contract is present and acknowledged", () => { + const script = readFileSync(formbricksScriptPath, "utf8"); + const updateFunction = script.slice( + script.indexOf("update_formbricks()"), + script.indexOf("restart_formbricks()") + ); + + expect(updateFunction).toContain( + "This installation does not yet contain the AuthZed v6 Compose services" + ); + expect(updateFunction).toContain("FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true"); + expect(updateFunction).toContain("authzed-ops upgrade prepare"); + expect(updateFunction).toContain("authzed-ops upgrade check"); + expect(updateFunction.indexOf("upgrade check")).toBeLessThan(updateFunction.indexOf("compose down")); + }); + + test("runs the upgrade gates before stopping an existing installation", () => { + const tempDir = createTempDir(); + const installationDir = join(tempDir, "formbricks"); + const binDir = join(tempDir, "bin"); + const commandLog = join(tempDir, "commands.log"); + mkdirSync(installationDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(installationDir, "docker-compose.yml"), "services:\n authzed-ops:\n spicedb:\n"); + writeFileSync(join(installationDir, ".env"), "FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true\n"); + writeFileSync(join(binDir, "sudo"), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$COMMAND_LOG"\n', { + mode: 0o700, + }); + + const result = spawnSync("bash", ["-c", 'source "$1"; update_formbricks', "bash", formbricksScriptPath], { + cwd: tempDir, + encoding: "utf8", + env: { ...process.env, COMMAND_LOG: commandLog, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + }); + + expect(result.status).toBe(0); + const commands = readFileSync(commandLog, "utf8").trim().split("\n"); + expect(commands).toEqual([ + "docker compose pull", + "docker compose run --rm formbricks-migrate", + "docker compose --profile authzed-ops run --rm authzed-ops upgrade prepare", + "docker compose --profile authzed-ops run --rm authzed-ops upgrade check", + "docker compose down", + "docker compose up -d", + ]); + }); + + test("waits for the source migration before preparing a fresh AuthZed graph", () => { + const script = readFileSync(formbricksScriptPath, "utf8"); + const setupStart = script.indexOf( + "docker compose up -d postgres authzed-db-bootstrap spicedb-migrate spicedb formbricks-migrate" + ); + const migrationWait = script.indexOf("docker compose wait formbricks-migrate", setupStart); + const upgradePrepare = script.indexOf("authzed-ops upgrade prepare", setupStart); + + expect(setupStart).toBeGreaterThanOrEqual(0); + expect(migrationWait).toBeGreaterThan(setupStart); + expect(upgradePrepare).toBeGreaterThan(migrationWait); + }); + + test("pins and verifies the downloaded bootstrap helper before making it executable", () => { + const script = readFileSync(formbricksScriptPath, "utf8"); + const downloadStart = script.indexOf( + 'authzed_bootstrap_commit="10d5ad908491a8a818aef3c6ada91fa4fdc30b03"' + ); + const checksumStart = script.indexOf("sha256sum --check --status -", downloadStart); + const chmodStart = script.indexOf("chmod 700 authzed-postgres-bootstrap.sh", checksumStart); + + expect(downloadStart).toBeGreaterThanOrEqual(0); + expect(script.slice(downloadStart, checksumStart)).not.toContain( + "formbricks/stable/docker/authzed-postgres-bootstrap.sh" + ); + expect(script.slice(downloadStart, checksumStart)).toContain( + "70975701cdf0dcffef5d3573a7514360e87428bb07cc4bfb4dbf47ae0c2e93a5" + ); + expect(checksumStart).toBeGreaterThan(downloadStart); + expect(chmodStart).toBeGreaterThan(checksumStart); + }); +}); + describe("docker/docker-compose.yml Redis/Valkey exposure (ENG-2184)", () => { // The bundled Valkey is Better Auth's session/token store (secondaryStorage). Publishing it to // the host binds 0.0.0.0:6379 with no password, exposing every live session token — and Docker's @@ -204,9 +337,21 @@ describe("docker/formbricks.sh Traefik label injection", () => { const composeContents = readFileSync(composePath, "utf8"); const formbricksMigrateBlock = getServiceBlock(composeContents, "formbricks-migrate"); const formbricksBlock = getServiceBlock(composeContents, "formbricks"); + const authzedBootstrapBlock = getServiceBlock(composeContents, "authzed-db-bootstrap"); + const authzedOpsBlock = getServiceBlock(composeContents, "authzed-ops"); + const authzedInitializeBlock = getServiceBlock(composeContents, "authzed-initialize"); + const spicedbBlock = getServiceBlock(composeContents, "spicedb"); expect(formbricksMigrateBlock).not.toContain(" labels:"); expect(formbricksMigrateBlock).not.toContain("traefik.enable=true"); + expect(authzedBootstrapBlock).toContain("authzed-postgres-bootstrap.sh"); + expect(authzedBootstrapBlock).not.toContain("traefik.enable=true"); + expect(spicedbBlock).toContain("authzed/spicedb:v1.52.0"); + expect(spicedbBlock).not.toContain("traefik.enable=true"); + expect(authzedOpsBlock).toContain('profiles: ["authzed-ops"]'); + expect(authzedOpsBlock).not.toContain("traefik.enable=true"); + expect(authzedInitializeBlock).toContain('command: ["upgrade", "prepare"]'); + expect(authzedInitializeBlock).not.toContain("traefik.enable=true"); expect(formbricksBlock).toContain(" labels:"); expect(formbricksBlock.indexOf(" labels:")).toBeLessThan(formbricksBlock.indexOf(" environment:")); expect(formbricksBlock).toContain("traefik.http.routers.formbricks.rule=Host(`example.com`)"); diff --git a/docker/authzed-compose-contract.sh b/docker/authzed-compose-contract.sh new file mode 100755 index 000000000000..7a48b07e189a --- /dev/null +++ b/docker/authzed-compose-contract.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +readonly DEV_COMPOSE_FILE="${REPO_ROOT}/docker-compose.dev.yml" +readonly PROD_COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml" + +readonly AUTHZED_TOKEN="${AUTHZED_TOKEN:-0000000000000000000000000000000000000000000000000000000000000001}" +readonly AUTHZED_DATABASE_PASSWORD="${AUTHZED_DATABASE_PASSWORD:-0000000000000000000000000000000000000000000000000000000000000002}" +readonly SPICEDB_IMAGE_REF="authzed/spicedb:v1.52.0" +readonly SPICEDB_GRPC_PORT="50051" +readonly AUTHZED_GRPCUI_PORT="50052" +readonly GRPCUI_IMAGE_REF="fullstorydev/grpcui:v1.5.2" +readonly ZED_IMAGE_REF="authzed/zed:v1.1.1" + +export AUTHZED_DATABASE_PASSWORD +export AUTHZED_GRPCUI_PORT +export AUTHZED_TOKEN +export GRPCUI_IMAGE_REF +export SPICEDB_GRPC_PORT +export SPICEDB_IMAGE_REF +export ZED_IMAGE_REF + +temp_dir="$(mktemp -d)" +trap 'rm -rf "${temp_dir}"' EXIT + +docker compose --file "${PROD_COMPOSE_FILE}" --profile authzed-ops config --format json >"${temp_dir}/production.json" +docker compose --file "${PROD_COMPOSE_FILE}" config --services >"${temp_dir}/production-services.txt" +docker compose --file "${DEV_COMPOSE_FILE}" --profile authzed-ui --profile authzed-tools config --format json >"${temp_dir}/development.json" + +jq --exit-status --arg token "${AUTHZED_TOKEN}" ' + .services.spicedb.image == "authzed/spicedb:v1.52.0" and + .services["spicedb-migrate"].image == .services.spicedb.image and + .services.spicedb.mem_limit == "536870912" and + (.services.spicedb | has("cpus") | not) and + (.services.spicedb | has("ports") | not) and + .services.spicedb.depends_on["spicedb-migrate"].condition == "service_completed_successfully" and + .services["spicedb-migrate"].depends_on["authzed-db-bootstrap"].condition == "service_completed_successfully" and + .services.spicedb.healthcheck.test == ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:50051"] and + .services.spicedb.environment.SPICEDB_GRPC_PRESHARED_KEY == $token and + .services.formbricks.environment.AUTHZED_ENABLED == "true" and + .services.formbricks.environment.AUTHZED_ENDPOINT == "spicedb:50051" and + .services.formbricks.environment.AUTHZED_TOKEN == $token and + .services.formbricks.environment.AUTHZED_SYSTEM_KEY == "formbricks" and + .services.formbricks.environment.AUTHZED_INSECURE == "true" and + .services.formbricks.environment.AUTHZED_CONSISTENCY == "fully_consistent" and + .services.formbricks.depends_on.spicedb? == null and + .services["authzed-ops"].image == .services.formbricks.image and + .services["authzed-ops"].profiles == ["authzed-ops"] and + .services["authzed-ops"].entrypoint == ["formbricks-authzed"] and + .services["authzed-ops"].command == ["health"] and + .services["authzed-ops"].depends_on.postgres.condition == "service_healthy" and + .services["authzed-ops"].depends_on.spicedb.condition == "service_healthy" and + .services["authzed-ops"].environment.DATABASE_URL == .services.formbricks.environment.DATABASE_URL and + .services["authzed-ops"].environment.AUTHZED_CONSISTENCY == "fully_consistent" and + .services["authzed-initialize"].image == .services.formbricks.image and + .services["authzed-initialize"].entrypoint == ["formbricks-authzed"] and + .services["authzed-initialize"].command == ["upgrade", "prepare"] and + .services["authzed-initialize"].restart == "no" and + .services["authzed-initialize"].depends_on["formbricks-migrate"].condition == "service_completed_successfully" and + .services["authzed-initialize"].depends_on.spicedb.condition == "service_healthy" and + (.services["authzed-initialize"] | has("ports") | not) and + (.services["authzed-initialize"] | has("volumes") | not) and + (.services["authzed-ops"] | has("ports") | not) and + (.services["authzed-ops"] | has("volumes") | not) and + (.services["authzed-ops"] | has("restart") | not) and + ([.services | to_entries[] | select(.value.environment.AUTHZED_TOKEN? != null) | .key] | sort) == ["authzed-initialize", "authzed-ops", "formbricks"] +' "${temp_dir}/production.json" >/dev/null + +if grep --fixed-strings --line-regexp "authzed-ops" "${temp_dir}/production-services.txt" >/dev/null; then + printf '%s\n' "authzed-ops must not start without its explicit profile." >&2 + exit 1 +fi + +jq --exit-status --arg token "${AUTHZED_TOKEN}" ' + .services.spicedb.image == "authzed/spicedb:v1.52.0" and + .services["spicedb-migrate"].image == .services.spicedb.image and + .services.spicedb.mem_limit == "536870912" and + (.services.spicedb | has("cpus") | not) and + .services.spicedb.ports == [{"mode":"ingress","host_ip":"127.0.0.1","target":50051,"published":"50051","protocol":"tcp"}] and + .services.spicedb.environment.SPICEDB_GRPC_PRESHARED_KEY == $token and + .services["authzed-cli"].image == "authzed/zed:v1.1.1" and + .services["authzed-ui"].profiles == ["authzed-ui"] and + .services["authzed-ui"].image == "fullstorydev/grpcui:v1.5.2" and + .services["authzed-ui"].ports == [{"mode":"ingress","host_ip":"127.0.0.1","target":8080,"published":"50052","protocol":"tcp"}] and + .services["authzed-ui"].depends_on.spicedb.condition == "service_healthy" +' "${temp_dir}/development.json" >/dev/null + +printf '%s\n' "AuthZed Compose contracts are valid." diff --git a/docker/authzed-postgres-bootstrap.sh b/docker/authzed-postgres-bootstrap.sh new file mode 100755 index 000000000000..16a5cc7b3353 --- /dev/null +++ b/docker/authzed-postgres-bootstrap.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +set -eu + +: "${POSTGRES_ADMIN_URL:?POSTGRES_ADMIN_URL is required}" +: "${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}" + +AUTHZED_DATABASE_NAME="${AUTHZED_DATABASE_NAME:-spicedb}" +AUTHZED_DATABASE_USERNAME="${AUTHZED_DATABASE_USERNAME:-spicedb}" + +psql "${POSTGRES_ADMIN_URL}" \ + --set=ON_ERROR_STOP=1 \ + --set=database_name="${AUTHZED_DATABASE_NAME}" \ + --set=database_username="${AUTHZED_DATABASE_USERNAME}" \ + --set=database_password="${AUTHZED_DATABASE_PASSWORD}" <<'SQL' +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'database_username', :'database_password') +WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = :'database_username' +) \gexec + +SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'database_username', :'database_password') \gexec + +SELECT format('CREATE DATABASE %I OWNER %I', :'database_name', :'database_username') +WHERE NOT EXISTS ( + SELECT 1 FROM pg_database WHERE datname = :'database_name' +) \gexec + +SELECT format('ALTER DATABASE %I OWNER TO %I', :'database_name', :'database_username') \gexec +SQL + +printf '%s\n' "AuthZed database bootstrap completed." diff --git a/docker/authzed-smoke.sh b/docker/authzed-smoke.sh new file mode 100755 index 000000000000..54e1158d9136 --- /dev/null +++ b/docker/authzed-smoke.sh @@ -0,0 +1,736 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +readonly COMPOSE_FILE="${REPO_ROOT}/docker-compose.dev.yml" +readonly PROJECT_NAME="formbricks-authzed-smoke-${$}" +readonly AUTHZED_TOKEN="0000000000000000000000000000000000000000000000000000000000000001" +readonly AUTHZED_DATABASE_PASSWORD="0000000000000000000000000000000000000000000000000000000000000002" +readonly WRONG_AUTHZED_TOKEN="0000000000000000000000000000000000000000000000000000000000000003" +readonly SCHEMA_LOG_SENTINEL="Canonical Formbricks authorization schema." +readonly RELATIONSHIP_USER_SENTINEL="application-graph-alice" +readonly RELATIONSHIP_API_KEY_SENTINEL="application-api-key-writer" +readonly RELATIONSHIP_RESOURCE_SENTINEL="application-graph-smoke" +readonly RELATIONSHIP_FEEDBACK_SENTINEL="application-feedback-directory" +SMOKE_TEMP_DIR="$(mktemp -d)" || exit 1 +readonly SMOKE_TEMP_DIR +readonly DRIFT_SCHEMA_FILE="${SMOKE_TEMP_DIR}/schema-with-drift.zed" + +compose() { + docker compose --project-name "${PROJECT_NAME}" --file "${COMPOSE_FILE}" "$@" +} + +wait_for_spicedb() { + for _ in $(seq 1 30); do + if [[ "$(compose ps --format json spicedb | tr -d '\n')" == *'"Health":"healthy"'* ]]; then + return 0 + fi + sleep 2 + done + + printf '%s\n' "SpiceDB did not become healthy." >&2 + return 1 +} + +refresh_spicedb_port() { + spicedb_binding="$(compose port spicedb 50051)" + spicedb_port="${spicedb_binding##*:}" +} + +authzed_health() { + local token="$1" + + authzed_cli "${token}" ./scripts/authzed-health.ts +} + +authzed_schema() { + local token="$1" + shift + + authzed_cli "${token}" ./scripts/authzed-schema.ts "$@" +} + +authzed_relationships() { + local token="$1" + shift + + authzed_cli "${token}" ./scripts/authzed-relationships-smoke.ts "$@" +} + +authzed_backfill() { + local token="$1" + shift + + authzed_cli "${token}" ./scripts/authzed-backfill-smoke.ts "$@" +} + +authzed_cli() { + local token="$1" + local script="$2" + shift 2 + + env \ + AUTHZED_CONSISTENCY="${AUTHZED_SMOKE_CONSISTENCY:-minimize_latency}" \ + AUTHZED_ENABLED=true \ + AUTHZED_ENDPOINT="localhost:${spicedb_port}" \ + AUTHZED_INSECURE=true \ + AUTHZED_SYSTEM_KEY=formbricks \ + AUTHZED_TOKEN="${token}" \ + CUBEJS_API_SECRET=authzed-smoke-cube-secret \ + CUBEJS_API_URL=https://cube.formbricks.local \ + DATABASE_URL=https://database.formbricks.local/formbricks \ + ENCRYPTION_KEY=authzed-smoke-encryption-key \ + HUB_API_KEY=authzed-smoke-hub-key \ + HUB_API_URL=https://hub.formbricks.local \ + LOG_LEVEL=fatal \ + NODE_ENV="${AUTHZED_SMOKE_NODE_ENV:-test}" \ + NODE_OPTIONS=--conditions=react-server \ + pnpm --dir "${REPO_ROOT}/apps/web" exec tsx "${script}" "$@" +} + +assert_health_result() { + local output="$1" + local expected_status="$2" + local expected_code="${3:-}" + + if [[ -n "${expected_code}" ]]; then + if jq --exit-status \ + --arg status "${expected_status}" \ + --arg code "${expected_code}" \ + '.status == $status and .code == $code and (.latencyMs | type == "number")' \ + <<<"${output}" >/dev/null; then + return + fi + elif jq --exit-status \ + --arg status "${expected_status}" \ + '.status == $status and (.latencyMs | type == "number")' \ + <<<"${output}" >/dev/null; then + return + fi + + printf '%s\n' "Application health CLI returned an invalid or unexpected result:" >&2 + printf '%s\n' "${output}" | sanitize_logs >&2 + return 1 +} + +assert_lookup_result() { + local output="$1" + local expected_count="$2" + + if jq --exit-status \ + --argjson expected_count "${expected_count}" \ + '.status == "looked_up" and .resourceCount == $expected_count' \ + <<<"${output}" >/dev/null; then + return + fi + + printf '%s\n' "Application workspace lookup returned an unexpected result:" >&2 + printf '%s\n' "${output}" | sanitize_logs >&2 + return 1 +} + +sanitize_logs() { + sed \ + -e "s/${AUTHZED_TOKEN}/[REDACTED_AUTHZED_TOKEN]/g" \ + -e "s/${WRONG_AUTHZED_TOKEN}/[REDACTED_WRONG_AUTHZED_TOKEN]/g" \ + -e "s/${AUTHZED_DATABASE_PASSWORD}/[REDACTED_AUTHZED_DATABASE_PASSWORD]/g" +} + +cleanup() { + local exit_code=$? + + if [[ ${exit_code} -ne 0 ]]; then + compose ps --all || true + compose logs --no-color postgres authzed-db-bootstrap spicedb-migrate spicedb 2>&1 | sanitize_logs || true + fi + + compose down --volumes --remove-orphans >/dev/null 2>&1 || true + rm -rf "${SMOKE_TEMP_DIR}" + exit "${exit_code}" +} + +on_error() { + local line_number="$1" + + printf '%s\n' "AuthZed smoke assertion failed at line ${line_number}." >&2 +} + +trap 'on_error "${LINENO}"' ERR +trap cleanup EXIT + +export AUTHZED_DATABASE_PASSWORD +export AUTHZED_TOKEN +export POSTGRES_PORT=0 +export SPICEDB_GRPC_PORT=0 + +compose config --quiet +compose up --detach postgres +compose up authzed-db-bootstrap spicedb-migrate + +# Prove both initialization stages are safe to repeat before starting the server. +compose run --rm authzed-db-bootstrap +compose run --rm spicedb-migrate + +compose up --detach spicedb +wait_for_spicedb + +refresh_spicedb_port + +if ! empty_schema_health="$(authzed_health "${AUTHZED_TOKEN}" 2>&1)"; then + printf '%s\n' "Application health CLI failed before schema installation." >&2 + printf '%s\n' "${empty_schema_health}" | sanitize_logs >&2 + exit 1 +fi +assert_health_result "${empty_schema_health}" "healthy" + +if wrong_token_health="$(authzed_health "${WRONG_AUTHZED_TOKEN}" 2>&1)"; then + printf '%s\n' "Application health CLI unexpectedly accepted an incorrect token." >&2 + exit 1 +fi +assert_health_result "${wrong_token_health}" "unhealthy" "authzed_permission_denied" +jq --exit-status '.retryable == false' <<<"${wrong_token_health}" >/dev/null + +if empty_schema_check="$(authzed_schema "${AUTHZED_TOKEN}" check 2>&1)"; then + printf '%s\n' "Schema check unexpectedly matched before schema installation." >&2 + exit 1 +else + empty_schema_check_exit_code=$? +fi +[[ "${empty_schema_check_exit_code}" -eq 2 ]] +jq --exit-status \ + '.status == "drifted" and .remoteState == "empty" and .remoteDigest == null and .differenceCount > 0' \ + <<<"${empty_schema_check}" >/dev/null + +initial_apply="$(authzed_schema "${AUTHZED_TOKEN}" apply)" +jq --exit-status \ + '.status == "applied" and .remoteState == "present" and .differenceCount == 0 and (.sourceDigest | startswith("sha256:"))' \ + <<<"${initial_apply}" >/dev/null + +matched_schema_check="$(authzed_schema "${AUTHZED_TOKEN}" check)" +jq --exit-status \ + '.status == "matched" and .differenceCount == 0 and (.remoteDigest | startswith("sha256:"))' \ + <<<"${matched_schema_check}" >/dev/null + +unchanged_apply="$(authzed_schema "${AUTHZED_TOKEN}" apply)" +jq --exit-status '.status == "unchanged" and .differenceCount == 0' <<<"${unchanged_apply}" >/dev/null + +zed() { + compose run --rm --no-deps authzed-cli "$@" \ + --endpoint spicedb:50051 \ + --token "${AUTHZED_TOKEN}" \ + --insecure \ + --skip-version-check +} + +cp "${REPO_ROOT}/authzed/schema.zed" "${DRIFT_SCHEMA_FILE}" +printf '\n/** Disposable smoke-test drift. */\ndefinition smoke_test_drift {}\n' >>"${DRIFT_SCHEMA_FILE}" +drift_schema_write="$( + compose run --rm --no-deps --volume "${DRIFT_SCHEMA_FILE}:/drift-schema.zed:ro" authzed-cli \ + schema write /drift-schema.zed \ + --endpoint spicedb:50051 \ + --token "${AUTHZED_TOKEN}" \ + --insecure \ + --skip-version-check 2>&1 +)" + +if drifted_schema_check="$(authzed_schema "${AUTHZED_TOKEN}" check 2>&1)"; then + printf '%s\n' "Schema check unexpectedly matched a deliberately drifted schema." >&2 + exit 1 +else + drifted_schema_check_exit_code=$? +fi +[[ "${drifted_schema_check_exit_code}" -eq 2 ]] +jq --exit-status \ + '.status == "drifted" and .remoteState == "present" and .differenceCount > 0 and (.remoteDigest | startswith("sha256:"))' \ + <<<"${drifted_schema_check}" >/dev/null +drifted_schema_digest="$(jq --raw-output '.remoteDigest' <<<"${drifted_schema_check}")" + +restored_apply="$( + authzed_schema "${AUTHZED_TOKEN}" apply --expected-current-digest "${drifted_schema_digest}" +)" +jq --exit-status '.status == "applied" and .differenceCount == 0' <<<"${restored_apply}" >/dev/null + +if refused_relationship_driver="$( + AUTHZED_SMOKE_NODE_ENV=production authzed_relationships "${AUTHZED_TOKEN}" set-owner 2>&1 +)"; then + printf '%s\n' "The application relationship smoke driver unexpectedly ran outside test mode." >&2 + exit 1 +fi +jq --exit-status \ + '.status == "failed" and .code == "authzed_smoke_refused" and .retryable == false' \ + <<<"${refused_relationship_driver}" >/dev/null + +owner_projection="$(authzed_relationships "${AUTHZED_TOKEN}" set-owner)" +jq --exit-status '.status == "projected"' <<<"${owner_projection}" >/dev/null +[[ "$( + zed permission check organization:application-relationship-smoke write \ + user:application-relationship-smoke --consistency-full +)" == *"true"* ]] + +billing_projection="$(authzed_relationships "${AUTHZED_TOKEN}" set-billing)" +jq --exit-status '.status == "projected"' <<<"${billing_projection}" >/dev/null +[[ "$( + zed permission check organization:application-relationship-smoke manage_billing \ + user:application-relationship-smoke --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check organization:application-relationship-smoke write \ + user:application-relationship-smoke --consistency-full +)" == *"false"* ]] + +idempotent_billing_projection="$(authzed_relationships "${AUTHZED_TOKEN}" set-billing)" +jq --exit-status '.status == "projected"' <<<"${idempotent_billing_projection}" >/dev/null + +deleted_projection="$(authzed_relationships "${AUTHZED_TOKEN}" delete)" +idempotent_deleted_projection="$(authzed_relationships "${AUTHZED_TOKEN}" delete)" +jq --exit-status '.status == "projected"' <<<"${deleted_projection}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${idempotent_deleted_projection}" >/dev/null +[[ "$( + zed permission check organization:application-relationship-smoke read \ + user:application-relationship-smoke --consistency-full +)" == *"false"* ]] + +api_key_seed="$(authzed_relationships "${AUTHZED_TOKEN}" seed-api-key)" +jq --exit-status '.status == "projected"' <<<"${api_key_seed}" >/dev/null +api_key_workspace_lookup="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent \ + authzed_relationships "${AUTHZED_TOKEN}" lookup-api-key-workspaces +)" +assert_lookup_result "${api_key_workspace_lookup}" 2 +api_key_allow_check="$( (AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-api-key-allow) )" +api_key_deny_check="$( (AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-api-key-deny) )" +jq --exit-status '.status == "checked" and .allowed == true' <<<"${api_key_allow_check}" >/dev/null +jq --exit-status '.status == "checked" and .allowed == false' <<<"${api_key_deny_check}" >/dev/null +if wrong_token_check="$( (AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${WRONG_AUTHZED_TOKEN}" check-api-key-allow) 2>&1)"; then + printf '%s\n' "Application permission check unexpectedly accepted an incorrect token." >&2 + exit 1 +fi +jq --exit-status \ + '.status == "failed" and .code == "authzed_permission_denied" and .retryable == false' \ + <<<"${wrong_token_check}" >/dev/null +[[ "$( + zed permission check organization:application-api-key-organization read_access \ + api_key:application-api-key-reader --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check organization:application-api-key-organization manage_access \ + api_key:application-api-key-reader --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check organization:application-api-key-organization manage_access \ + api_key:application-api-key-writer --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check organization:application-api-key-organization read_access \ + api_key:application-api-key-combined-access --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check organization:application-api-key-organization manage_access \ + api_key:application-api-key-combined-access --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary read \ + api_key:application-api-key-reader --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary write \ + api_key:application-api-key-reader --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary write \ + api_key:application-api-key-writer --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary manage \ + api_key:application-api-key-writer --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary manage \ + api_key:application-api-key-manager --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-api-key-secondary read \ + api_key:application-api-key-manager --consistency-full +)" == *"true"* ]] + +downgraded_api_key="$( + authzed_relationships "${AUTHZED_TOKEN}" downgrade-api-key-manager +)" +jq --exit-status '.status == "projected"' <<<"${downgraded_api_key}" >/dev/null +[[ "$( + zed permission check workspace:application-api-key-primary manage \ + api_key:application-api-key-manager --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary write \ + api_key:application-api-key-manager --consistency-full +)" == *"true"* ]] + +removed_api_key_scope="$( + authzed_relationships "${AUTHZED_TOKEN}" remove-api-key-scope +)" +jq --exit-status '.status == "projected"' <<<"${removed_api_key_scope}" >/dev/null +[[ "$( + zed permission check workspace:application-api-key-secondary read \ + api_key:application-api-key-manager --consistency-full +)" == *"false"* ]] + +deleted_api_key="$(authzed_relationships "${AUTHZED_TOKEN}" delete-api-key)" +idempotent_deleted_api_key="$( + authzed_relationships "${AUTHZED_TOKEN}" delete-api-key +)" +jq --exit-status '.status == "projected"' <<<"${deleted_api_key}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${idempotent_deleted_api_key}" >/dev/null +[[ "$( + zed permission check organization:application-api-key-organization read_access \ + api_key:application-api-key-writer --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary read \ + api_key:application-api-key-writer --consistency-full +)" == *"false"* ]] + +team_workspace_seed="$(authzed_relationships "${AUTHZED_TOKEN}" seed-team-workspace)" +jq --exit-status '.status == "projected"' <<<"${team_workspace_seed}" >/dev/null +user_workspace_lookup="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent \ + authzed_relationships "${AUTHZED_TOKEN}" lookup-user-workspaces +)" +empty_workspace_lookup="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent \ + authzed_relationships "${AUTHZED_TOKEN}" lookup-empty-workspaces +)" +assert_lookup_result "${user_workspace_lookup}" 2 +assert_lookup_result "${empty_workspace_lookup}" 0 +user_allow_check="$( (AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-user-allow) )" +user_deny_check="$( (AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-user-deny) )" +jq --exit-status '.status == "checked" and .allowed == true' <<<"${user_allow_check}" >/dev/null +jq --exit-status '.status == "checked" and .allowed == false' <<<"${user_deny_check}" >/dev/null +[[ "$( + zed permission check workspace:application-graph-smoke manage \ + user:application-graph-alice --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-bob --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke manage \ + user:application-graph-bob --consistency-full +)" == *"false"* ]] + +downgraded_manager_grant="$( + authzed_relationships "${AUTHZED_TOKEN}" downgrade-manager-grant +)" +jq --exit-status '.status == "projected"' <<<"${downgraded_manager_grant}" >/dev/null +[[ "$( + zed permission check workspace:application-graph-smoke manage \ + user:application-graph-alice --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-alice --consistency-full +)" == *"true"* ]] + +removed_reader_grant="$(authzed_relationships "${AUTHZED_TOKEN}" remove-reader-grant)" +jq --exit-status '.status == "projected"' <<<"${removed_reader_grant}" >/dev/null +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-bob --consistency-full +)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-alice --consistency-full +)" == *"true"* ]] + +removed_alice_memberships="$( + authzed_relationships "${AUTHZED_TOKEN}" remove-alice-memberships +)" +jq --exit-status '.status == "projected"' <<<"${removed_alice_memberships}" >/dev/null +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-alice --consistency-full +)" == *"false"* ]] + +team_workspace_reseed="$(authzed_relationships "${AUTHZED_TOKEN}" seed-team-workspace)" +deleted_manager_team="$(authzed_relationships "${AUTHZED_TOKEN}" delete-manager-team)" +idempotent_deleted_manager_team="$( + authzed_relationships "${AUTHZED_TOKEN}" delete-manager-team +)" +jq --exit-status '.status == "projected"' <<<"${team_workspace_reseed}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${deleted_manager_team}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${idempotent_deleted_manager_team}" >/dev/null +zed relationship create team:application-graph-manager admin user:application-graph-alice +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-alice --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke manage \ + user:application-graph-alice --consistency-full +)" == *"false"* ]] + +team_workspace_reseed_for_delete="$( + authzed_relationships "${AUTHZED_TOKEN}" seed-team-workspace +)" +deleted_graph_workspace="$(authzed_relationships "${AUTHZED_TOKEN}" delete-workspace)" +idempotent_deleted_graph_workspace="$( + authzed_relationships "${AUTHZED_TOKEN}" delete-workspace +)" +jq --exit-status '.status == "projected"' <<<"${team_workspace_reseed_for_delete}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${deleted_graph_workspace}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${idempotent_deleted_graph_workspace}" >/dev/null +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-bob --consistency-full +)" == *"false"* ]] + +feedback_seed="$(authzed_relationships "${AUTHZED_TOKEN}" seed-feedback-directory)" +jq --exit-status '.status == "projected"' <<<"${feedback_seed}" >/dev/null +feedback_initial="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-feedback +)" +jq --exit-status ' + .status == "checked" and + .managerManage == true and + .userRead == true and + .userWrite == false and + .userAssignmentARead == true and + .userAssignmentBRead == false and + .keyWrite == true and + .keyAssignmentAWrite == false and + .keyAssignmentBWrite == true +' <<<"${feedback_initial}" >/dev/null + +feedback_downgrade="$(authzed_relationships "${AUTHZED_TOKEN}" downgrade-feedback-api-key)" +jq --exit-status '.status == "projected"' <<<"${feedback_downgrade}" >/dev/null +feedback_after_downgrade="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-feedback +)" +jq --exit-status '.keyWrite == false and .keyAssignmentBWrite == false' \ + <<<"${feedback_after_downgrade}" >/dev/null + +feedback_remove_membership="$( + authzed_relationships "${AUTHZED_TOKEN}" remove-feedback-user-membership +)" +jq --exit-status '.status == "projected"' <<<"${feedback_remove_membership}" >/dev/null +feedback_after_membership_removal="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-feedback +)" +jq --exit-status '.userRead == false and .userAssignmentARead == false' \ + <<<"${feedback_after_membership_removal}" >/dev/null + +feedback_reseed_for_assignment_delete="$( + authzed_relationships "${AUTHZED_TOKEN}" seed-feedback-directory +)" +feedback_delete_assignment="$( + authzed_relationships "${AUTHZED_TOKEN}" delete-feedback-assignment-a +)" +jq --exit-status '.status == "projected"' <<<"${feedback_reseed_for_assignment_delete}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${feedback_delete_assignment}" >/dev/null +feedback_after_assignment_delete="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-feedback +)" +jq --exit-status ' + .userRead == false and + .userAssignmentARead == false and + .keyWrite == true and + .keyAssignmentBWrite == true +' <<<"${feedback_after_assignment_delete}" >/dev/null + +feedback_delete_directory="$(authzed_relationships "${AUTHZED_TOKEN}" delete-feedback-directory)" +feedback_delete_directory_idempotent="$( + authzed_relationships "${AUTHZED_TOKEN}" delete-feedback-directory +)" +jq --exit-status '.status == "projected"' <<<"${feedback_delete_directory}" >/dev/null +jq --exit-status '.status == "projected"' <<<"${feedback_delete_directory_idempotent}" >/dev/null +feedback_after_directory_delete="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-feedback +)" +jq --exit-status ' + .managerManage == false and + .userRead == false and + .keyWrite == false and + .userAssignmentARead == false and + .keyAssignmentBWrite == false +' <<<"${feedback_after_directory_delete}" >/dev/null + +persisted_team_workspace_seed="$( + authzed_relationships "${AUTHZED_TOKEN}" seed-team-workspace +)" +jq --exit-status '.status == "projected"' <<<"${persisted_team_workspace_seed}" >/dev/null +persisted_api_key_seed="$(authzed_relationships "${AUTHZED_TOKEN}" seed-api-key)" +jq --exit-status '.status == "projected"' <<<"${persisted_api_key_seed}" >/dev/null +persisted_feedback_seed="$(authzed_relationships "${AUTHZED_TOKEN}" seed-feedback-directory)" +jq --exit-status '.status == "projected"' <<<"${persisted_feedback_seed}" >/dev/null + +zed relationship create organization:smoke owner user:alice +zed relationship create workspace:smoke organization organization:smoke +zed relationship create survey:smoke workspace workspace:smoke + +alice_result="$(zed permission check survey:smoke read user:alice --consistency-full)" +bob_result="$(zed permission check survey:smoke read user:bob --consistency-full)" + +[[ "${alice_result}" == *"true"* ]] +[[ "${bob_result}" == *"false"* ]] + +compose stop spicedb + +if unavailable_health="$(authzed_health "${AUTHZED_TOKEN}" 2>&1)"; then + printf '%s\n' "Application health CLI unexpectedly succeeded while SpiceDB was stopped." >&2 + exit 1 +fi +assert_health_result "${unavailable_health}" "unhealthy" "authzed_unavailable" +jq --exit-status '.latencyMs <= 4000' <<<"${unavailable_health}" >/dev/null + +if unavailable_projection="$(authzed_relationships "${AUTHZED_TOKEN}" set-owner 2>&1)"; then + printf '%s\n' "Application relationship projection unexpectedly succeeded while SpiceDB was stopped." >&2 + exit 1 +fi +jq --exit-status \ + '.status == "failed" and .code == "authzed_unavailable" and .attempts == 3 and .retryable == true and .latencyMs <= 4000' \ + <<<"${unavailable_projection}" >/dev/null + +if unavailable_permission_check="$( (AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-user-allow) 2>&1)"; then + printf '%s\n' "Application permission check unexpectedly succeeded while SpiceDB was stopped." >&2 + exit 1 +fi +jq --exit-status \ + '.status == "failed" and .code == "authzed_unavailable" and .attempts == 3 and .retryable == true and .latencyMs <= 4000' \ + <<<"${unavailable_permission_check}" >/dev/null + +compose up --detach --force-recreate spicedb +wait_for_spicedb +refresh_spicedb_port + +if ! restored_health="$(authzed_health "${AUTHZED_TOKEN}" 2>&1)"; then + printf '%s\n' "Application health CLI did not recover after SpiceDB recreation." >&2 + exit 1 +fi +assert_health_result "${restored_health}" "healthy" + +restored_projection="$(authzed_relationships "${AUTHZED_TOKEN}" set-owner)" +jq --exit-status '.status == "projected"' <<<"${restored_projection}" >/dev/null +[[ "$( + zed permission check organization:application-relationship-smoke write \ + user:application-relationship-smoke --consistency-full +)" == *"true"* ]] + +[[ "$(zed permission check survey:smoke read user:alice --consistency-full)" == *"true"* ]] +[[ "$(zed permission check survey:smoke read user:bob --consistency-full)" == *"false"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke manage \ + user:application-graph-alice --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-graph-smoke read \ + user:application-graph-bob --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check organization:application-api-key-organization manage_access \ + api_key:application-api-key-writer --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check organization:application-api-key-organization manage_access \ + api_key:application-api-key-combined-access --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-api-key-primary manage \ + api_key:application-api-key-manager --consistency-full +)" == *"true"* ]] +[[ "$( + zed permission check workspace:application-api-key-secondary read \ + api_key:application-api-key-manager --consistency-full +)" == *"true"* ]] +restored_feedback="$( + AUTHZED_SMOKE_CONSISTENCY=fully_consistent authzed_relationships "${AUTHZED_TOKEN}" check-feedback +)" +jq --exit-status ' + .managerManage == true and + .userRead == true and + .userAssignmentARead == true and + .userAssignmentBRead == false and + .keyWrite == true and + .keyAssignmentAWrite == false and + .keyAssignmentBWrite == true +' <<<"${restored_feedback}" >/dev/null + +persisted_schema_check="$(authzed_schema "${AUTHZED_TOKEN}" check)" +jq --exit-status '.status == "matched" and .differenceCount == 0' <<<"${persisted_schema_check}" >/dev/null + +# Backfill and repair. Seeds more relationships than one read page holds, so the drainer has to page +# and hold a single revision across pages — behaviour only a real engine can confirm. +# Subshell: an assignment prefixing a *function* call persists in the calling shell under `set -o +# posix`, which would leave every later driver invocation running as production and refusing to run. +refused_backfill_driver="$( (AUTHZED_SMOKE_NODE_ENV=production authzed_backfill "${AUTHZED_TOKEN}" report) || true)" +jq --exit-status '.status == "failed" and .code == "authzed_backfill_smoke_refused"' \ + <<<"${refused_backfill_driver}" >/dev/null + +backfill_seed="$(authzed_backfill "${AUTHZED_TOKEN}" seed 300)" +jq --exit-status '.status == "seeded" and .seeded == 300' <<<"${backfill_seed}" >/dev/null + +backfill_observation="$(authzed_backfill "${AUTHZED_TOKEN}" observe)" +# The non-zero count matters as much as the paging: an accidentally empty observation would let every +# assertion below pass while proving nothing. +jq --exit-status '.status == "observed" and .relationshipCount >= 300 and .snapshotPinned == true' \ + <<<"${backfill_observation}" >/dev/null + +backfill_report="$(authzed_backfill "${AUTHZED_TOKEN}" report)" +jq --exit-status '.status == "drifted" and .orphaned >= 300 and .pruned == 0 and .handedOverCount == 0' \ + <<<"${backfill_report}" >/dev/null + +# The cap exists because a large orphan count is a symptom rather than a big cleanup job. Exceeding it +# must hand over nothing at all, not prune an arbitrary prefix. +backfill_capped="$(authzed_backfill "${AUTHZED_TOKEN}" prune-capped)" +jq --exit-status '.pruned == 0 and .skipped == 1 and .handedOverCount == 0' <<<"${backfill_capped}" >/dev/null + +# The cap has to be decided against the whole sweep, not per page. 280 is above one 250-relationship +# page and below the seeded total, so a per-page check would delete the first page and halt on the +# second — revoking a cap's worth of live access on a run aimed at the wrong database, where the guard +# exists to revoke none. This is the multi-page case; the assertion above only exceeds the cap on page +# one, so it cannot distinguish the two. +backfill_page_capped="$(authzed_backfill "${AUTHZED_TOKEN}" prune-page-capped)" +jq --exit-status '.pruned == 0 and .skipped == 1 and .handedOverCount == 0 and .orphaned >= 300' \ + <<<"${backfill_page_capped}" >/dev/null + +backfill_prune="$(authzed_backfill "${AUTHZED_TOKEN}" prune)" +jq --exit-status \ + '.status == "reconciled" and .pruned >= 300 and .handedOverCount > 0 and .truncated == false and (.completedAtSnapshot | type == "string")' \ + <<<"${backfill_prune}" >/dev/null +completed_at_snapshot="$(jq --raw-output '.completedAtSnapshot' <<<"${backfill_prune}")" +snapshot_floor_check="$( (AUTHZED_SMOKE_CONSISTENCY=minimize_latency AUTHZED_SMOKE_MINIMUM_SNAPSHOT="${completed_at_snapshot}" authzed_relationships "${AUTHZED_TOKEN}" check-user-allow) )" +jq --exit-status '.status == "checked" and .allowed == true' <<<"${snapshot_floor_check}" >/dev/null + +backfill_cleanup="$(authzed_backfill "${AUTHZED_TOKEN}" cleanup)" +jq --exit-status '.status == "cleaned"' <<<"${backfill_cleanup}" >/dev/null + +# The store also holds the fixtures the projection assertions above created, so the absolute orphan +# count is not zero here. Asserting the delta is the stronger claim anyway: exactly the 300 seeded +# relationships disappeared and nothing else was touched. +backfill_orphans_before_cleanup="$(jq -r '.orphaned' <<<"${backfill_prune}")" +backfill_after_cleanup="$(authzed_backfill "${AUTHZED_TOKEN}" report)" +jq --exit-status --argjson before "${backfill_orphans_before_cleanup}" \ + '.status == "drifted" and .orphaned == ($before - 300)' <<<"${backfill_after_cleanup}" >/dev/null + +# Idempotency, byte for byte: a second pass over unchanged state reports exactly the same thing rather +# than doing a fresh round of work. +backfill_repeated="$(authzed_backfill "${AUTHZED_TOKEN}" report)" +[[ "${backfill_repeated}" == "${backfill_after_cleanup}" ]] + +service_logs="$(compose logs --no-color postgres authzed-db-bootstrap spicedb-migrate spicedb)" +application_outputs="${empty_schema_health}${wrong_token_health}${empty_schema_check}${initial_apply}${matched_schema_check}${unchanged_apply}${drift_schema_write}${drifted_schema_check}${restored_apply}${refused_relationship_driver}${owner_projection}${billing_projection}${idempotent_billing_projection}${deleted_projection}${idempotent_deleted_projection}${api_key_seed}${api_key_workspace_lookup}${api_key_allow_check}${api_key_deny_check}${wrong_token_check}${downgraded_api_key}${removed_api_key_scope}${deleted_api_key}${idempotent_deleted_api_key}${team_workspace_seed}${user_workspace_lookup}${empty_workspace_lookup}${user_allow_check}${user_deny_check}${downgraded_manager_grant}${removed_reader_grant}${removed_alice_memberships}${team_workspace_reseed}${deleted_manager_team}${idempotent_deleted_manager_team}${team_workspace_reseed_for_delete}${deleted_graph_workspace}${idempotent_deleted_graph_workspace}${feedback_seed}${feedback_initial}${feedback_downgrade}${feedback_after_downgrade}${feedback_remove_membership}${feedback_after_membership_removal}${feedback_reseed_for_assignment_delete}${feedback_delete_assignment}${feedback_after_assignment_delete}${feedback_delete_directory}${feedback_delete_directory_idempotent}${feedback_after_directory_delete}${persisted_team_workspace_seed}${persisted_api_key_seed}${persisted_feedback_seed}${unavailable_health}${unavailable_projection}${unavailable_permission_check}${restored_health}${restored_projection}${restored_feedback}${persisted_schema_check}${refused_backfill_driver}${backfill_seed}${backfill_observation}${backfill_report}${backfill_capped}${backfill_page_capped}${backfill_prune}${snapshot_floor_check}${backfill_cleanup}${backfill_after_cleanup}${backfill_repeated}" +if [[ "${service_logs}${application_outputs}" == *"${AUTHZED_TOKEN}"* || \ + "${service_logs}${application_outputs}" == *"${WRONG_AUTHZED_TOKEN}"* || \ + "${service_logs}${application_outputs}" == *"${AUTHZED_DATABASE_PASSWORD}"* || \ + "${service_logs}${application_outputs}" == *"${SCHEMA_LOG_SENTINEL}"* || \ + "${service_logs}${application_outputs}" == *"${RELATIONSHIP_USER_SENTINEL}"* || \ + "${service_logs}${application_outputs}" == *"${RELATIONSHIP_API_KEY_SENTINEL}"* || \ + "${service_logs}${application_outputs}" == *"${RELATIONSHIP_RESOURCE_SENTINEL}"* || \ + "${service_logs}${application_outputs}" == *"${RELATIONSHIP_FEEDBACK_SENTINEL}"* ]]; then + printf '%s\n' "AuthZed logs exposed a configured secret, schema, or relationship identifier." >&2 + exit 1 +fi + +printf '%s\n' "AuthZed smoke test passed: schema lifecycle, organization/team/workspace/API-key/feedback-dataset projection, exact assignment scoping, application user/API-key permission checks and workspace lookups, fully-consistent and snapshot-floor reads, permission ladders, grant and membership revocation, idempotent cascade cleanup, paginated relationship reads, backfill orphan detection and prune guards, health, authentication failure, bounded outage handling, migrations, and persistence were verified." diff --git a/docker/cube/schema/FeedbackRecords.js b/docker/cube/schema/FeedbackRecords.js index cd646dff6c81..4aad61999edd 100644 --- a/docker/cube/schema/FeedbackRecords.js +++ b/docker/cube/schema/FeedbackRecords.js @@ -6,19 +6,19 @@ cube(`FeedbackRecords`, { measures: { count: { type: `count`, - description: `Total number of feedback responses`, + description: `Total number of feedback records`, }, uniqueRespondents: { type: `countDistinct`, sql: `${CUBE}.user_id`, - description: `Number of unique users who provided feedback`, + description: `Unique identified people who gave feedback, deduplicated by person — one respondent answering 3 questions counts once. Anonymous feedback (no identified respondent) isn't counted here, even though it counts as a Feedback Record.`, }, uniqueResponses: { type: `countDistinct`, sql: `${CUBE}.submission_id`, - description: `Number of unique survey submissions (a submission can produce multiple feedback records)`, + description: `Unique survey submissions, deduplicated by submission — one respondent submitting twice counts twice`, }, promoterCount: { @@ -325,13 +325,13 @@ cube(`FeedbackRecords`, { valueText: { sql: `value_text`, type: `string`, - description: `Text answer value (open text, or the label of a multiple-choice / categorical answer). Pair with a fieldType filter to keep types consistent.`, + description: `Text answer value (open text, or the label of a multiple-choice/categorical answer). Buckets by the exact text, so a translated label, an edited label or a free-text 'other' answer each becomes its own bucket — for choice questions prefer valueId. Pair with a fieldType filter to keep types consistent.`, }, valueId: { sql: `value_id`, type: `string`, - description: `Stable id of a selected choice (single/multi-select). Group by this instead of valueText to consolidate the same option across languages / after a label edit.`, + description: `Recommended for single-select and multi-select answers: the stable option id keeps one option in one bucket across languages, after a label edit, and for free-text 'other' answers. Charts show the option's label, not the id.`, }, valueBoolean: { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aa0559be88c7..043fac007a6e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,3 +1,6 @@ +x-formbricks-database: &formbricks-database + DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" + x-environment: &environment environment: &app-environment ######################################################## REQUIRED ######################################################## @@ -10,7 +13,7 @@ x-environment: &environment NEXTAUTH_URL: # PostgreSQL DB for Formbricks to connect to - DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" + <<: *formbricks-database # NextJS Auth # @see: https://next-auth.js.org/configuration/options#nextauth_secret @@ -252,6 +255,54 @@ services: retries: 30 start_period: 30s + authzed-db-bootstrap: + image: pgvector/pgvector:pg18 + restart: "no" + entrypoint: ["/bin/sh", "/usr/local/bin/authzed-postgres-bootstrap.sh"] + environment: + POSTGRES_ADMIN_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable + AUTHZED_DATABASE_NAME: spicedb + AUTHZED_DATABASE_USERNAME: spicedb + AUTHZED_DATABASE_PASSWORD: ${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required} + volumes: + - ./authzed-postgres-bootstrap.sh:/usr/local/bin/authzed-postgres-bootstrap.sh:ro + depends_on: + postgres: + condition: service_healthy + + spicedb-migrate: + image: ${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0} + restart: "no" + command: datastore migrate head + environment: + SPICEDB_DATASTORE_ENGINE: postgres + SPICEDB_DATASTORE_CONN_URI: postgresql://spicedb:${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}@postgres:5432/spicedb?sslmode=disable + depends_on: + authzed-db-bootstrap: + condition: service_completed_successfully + + spicedb: + image: ${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0} + restart: always + command: serve + mem_limit: 512m + environment: + SPICEDB_DATASTORE_ENGINE: postgres + SPICEDB_DATASTORE_CONN_URI: postgresql://spicedb:${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required}@postgres:5432/spicedb?sslmode=disable + SPICEDB_GRPC_PRESHARED_KEY: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required} + SPICEDB_LOG_FORMAT: json + SPICEDB_LOG_LEVEL: info + SPICEDB_TELEMETRY_ENDPOINT: "" + depends_on: + spicedb-migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:50051"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + # Redis/Valkey service for caching, rate limiting, and audit logging # Remove this service if you want to use an external Redis/Valkey instance # No host port is published: this instance is Better Auth's session/token store and is reached @@ -308,6 +359,71 @@ services: environment: <<: *app-environment SKIP_STARTUP_MIGRATION: "true" + AUTHZED_ENABLED: ${AUTHZED_ENABLED:-true} + AUTHZED_ENDPOINT: ${AUTHZED_ENDPOINT:-spicedb:50051} + AUTHZED_TOKEN: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required} + AUTHZED_SYSTEM_KEY: ${AUTHZED_SYSTEM_KEY:-formbricks} + AUTHZED_INSECURE: ${AUTHZED_INSECURE:-true} + AUTHZED_CONSISTENCY: ${AUTHZED_CONSISTENCY:-fully_consistent} + + # Opt-in AuthZed health, schema, and relationship-repair commands. This profile never starts with + # the normal stack. Example: docker compose --profile authzed-ops run --rm authzed-ops health + authzed-ops: + profiles: ["authzed-ops"] + image: ghcr.io/formbricks/formbricks:latest + entrypoint: ["formbricks-authzed"] + command: ["health"] + environment: + # Backfill is the only operation that reads Formbricks PostgreSQL. The other required values are + # inert placeholders for the shared server environment validator and are not application secrets. + <<: *formbricks-database + ENCRYPTION_KEY: authzed-ops-unused + CUBEJS_API_SECRET: authzed-ops-unused + CUBEJS_API_URL: http://localhost + HUB_API_KEY: authzed-ops-unused + HUB_API_URL: http://localhost + REDIS_URL: redis://localhost + LOG_LEVEL: fatal + AUTHZED_ENABLED: ${AUTHZED_ENABLED:-true} + AUTHZED_ENDPOINT: ${AUTHZED_ENDPOINT:-spicedb:50051} + AUTHZED_TOKEN: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required} + AUTHZED_SYSTEM_KEY: ${AUTHZED_SYSTEM_KEY:-formbricks} + AUTHZED_INSECURE: ${AUTHZED_INSECURE:-true} + AUTHZED_CONSISTENCY: ${AUTHZED_CONSISTENCY:-fully_consistent} + depends_on: + postgres: + condition: service_healthy + spicedb: + condition: service_healthy + + # Idempotently prepares a fresh authorization datastore without making Formbricks startup, health, + # readiness, or liveness depend on SpiceDB. Existing installations are gated by formbricks.sh and + # the explicit v6 migration acknowledgement before this release is started. + authzed-initialize: + image: ghcr.io/formbricks/formbricks:latest + restart: "no" + entrypoint: ["formbricks-authzed"] + command: ["upgrade", "prepare"] + environment: + <<: *formbricks-database + ENCRYPTION_KEY: authzed-initialize-unused + CUBEJS_API_SECRET: authzed-initialize-unused + CUBEJS_API_URL: http://localhost + HUB_API_KEY: authzed-initialize-unused + HUB_API_URL: http://localhost + REDIS_URL: redis://localhost + LOG_LEVEL: fatal + AUTHZED_ENABLED: ${AUTHZED_ENABLED:-true} + AUTHZED_ENDPOINT: ${AUTHZED_ENDPOINT:-spicedb:50051} + AUTHZED_TOKEN: ${AUTHZED_TOKEN:?AUTHZED_TOKEN is required} + AUTHZED_SYSTEM_KEY: ${AUTHZED_SYSTEM_KEY:-formbricks} + AUTHZED_INSECURE: ${AUTHZED_INSECURE:-true} + AUTHZED_CONSISTENCY: ${AUTHZED_CONSISTENCY:-fully_consistent} + depends_on: + formbricks-migrate: + condition: service_completed_successfully + spicedb: + condition: service_healthy # Run Hub DB migrations (goose + river) before the API starts. Uses same image; migrations are idempotent. # Default tracks :latest so self-host updates (compose pull) advance Hub alongside the app image. diff --git a/docker/formbricks.sh b/docker/formbricks.sh index 9bb6d9066a70..eb385c897bc8 100755 --- a/docker/formbricks.sh +++ b/docker/formbricks.sh @@ -165,6 +165,27 @@ write_rustfs_env_file() { upsert_dotenv_var "FORMBRICKS_RUSTFS_REGION" "us-east-1" "$env_file" } +write_base_env_file() { + local env_file="${1:-.env}" + local hub_key="$2" + local cube_secret="$3" + local authzed_token="$4" + local authzed_database_password="$5" + + umask 077 + : >"$env_file" + upsert_dotenv_var "HUB_API_KEY" "$hub_key" "$env_file" + upsert_dotenv_var "CUBEJS_API_SECRET" "$cube_secret" "$env_file" + upsert_dotenv_var "CUBEJS_JWT_ISSUER" "formbricks-web" "$env_file" + upsert_dotenv_var "CUBEJS_JWT_AUDIENCE" "formbricks-cube" "$env_file" + upsert_dotenv_var "AUTHZED_TOKEN" "$authzed_token" "$env_file" + upsert_dotenv_var "AUTHZED_DATABASE_PASSWORD" "$authzed_database_password" "$env_file" + upsert_dotenv_var "AUTHZED_ENABLED" "true" "$env_file" + upsert_dotenv_var "AUTHZED_CONSISTENCY" "fully_consistent" "$env_file" + upsert_dotenv_var "FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED" "true" "$env_file" + chmod 600 "$env_file" +} + add_formbricks_traefik_labels() { local compose_file="${1:-docker-compose.yml}" local formbricks_domain_name="$2" @@ -569,6 +590,13 @@ EOT echo "📥 Downloading docker-compose.yml from Formbricks GitHub repository..." curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/docker-compose.yml + echo "📥 Downloading AuthZed database bootstrap helper..." + authzed_bootstrap_commit="10d5ad908491a8a818aef3c6ada91fa4fdc30b03" + authzed_bootstrap_sha256="70975701cdf0dcffef5d3573a7514360e87428bb07cc4bfb4dbf47ae0c2e93a5" + curl -fsSL -o authzed-postgres-bootstrap.sh \ + "https://raw.githubusercontent.com/formbricks/formbricks/${authzed_bootstrap_commit}/docker/authzed-postgres-bootstrap.sh" + printf '%s %s\n' "$authzed_bootstrap_sha256" authzed-postgres-bootstrap.sh | sha256sum --check --status - + chmod 700 authzed-postgres-bootstrap.sh mkdir -p cube/schema echo "📥 Downloading Cube.js configuration for XM Suite v5 analytics..." curl -fsSL -o cube/cube.js https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/cube/cube.js @@ -589,13 +617,10 @@ EOT hub_api_key=$(openssl rand -hex 32) cubejs_api_secret=$(openssl rand -hex 32) -cat < .env -HUB_API_KEY=$hub_api_key -CUBEJS_API_SECRET=$cubejs_api_secret -CUBEJS_JWT_ISSUER=formbricks-web -CUBEJS_JWT_AUDIENCE=formbricks-cube -EOF - echo "🚗 Generated Hub and Cube secrets in .env successfully!" + authzed_token=$(openssl rand -hex 32) + authzed_database_password=$(openssl rand -hex 32) + write_base_env_file ".env" "$hub_api_key" "$cubejs_api_secret" "$authzed_token" "$authzed_database_password" + echo "🚗 Generated Hub, Cube, and AuthZed secrets in .env successfully!" if [[ -n $mail_from ]]; then sed -i "s|# MAIL_FROM:|MAIL_FROM: \"$mail_from\"|" docker-compose.yml @@ -937,6 +962,11 @@ EOF newgrp docker < ## Next steps diff --git a/docs/platform/mcp/setup.mdx b/docs/platform/mcp/setup.mdx index 4b3dbaa7c530..11a26d3c9209 100644 --- a/docs/platform/mcp/setup.mdx +++ b/docs/platform/mcp/setup.mdx @@ -165,6 +165,13 @@ revoke — the client re-runs the sign-in and consent flow. Approving the write correct, public **HTTPS** origin and set `WEBAPP_URL` (and `BETTER_AUTH_URL`) to it. An `http://localhost` or mismatched origin breaks the flow for remote clients. See the [environment variables](/self-hosting/configuration/environment-variables) reference. + + If the Formbricks container or pod cannot resolve that public origin when it verifies a token, set + `MCP_OAUTH_JWKS_URL` to the internal `/api/auth/jwks` endpoint. Keep `WEBAPP_URL`, `NEXTAUTH_URL`, and + `BETTER_AUTH_URL` public: the internal override is only used for the server-side signing-key fetch. + `/api/auth/jwks` is correct only when no custom application subpath is configured. If Formbricks is + mounted at `/formbricks`, use the complete internal URL, such as + `http://formbricks:3000/formbricks/api/auth/jwks`. diff --git a/docs/self-hosting/advanced/authzed-operations.mdx b/docs/self-hosting/advanced/authzed-operations.mdx new file mode 100644 index 000000000000..70ba1f6ff382 --- /dev/null +++ b/docs/self-hosting/advanced/authzed-operations.mdx @@ -0,0 +1,639 @@ +--- +title: "AuthZed Operations" +description: "Configure, monitor, back up, and repair the SpiceDB authorization dependency used by Formbricks." +icon: "shield-halved" +--- + +Formbricks uses [AuthZed SpiceDB](https://authzed.com/docs/spicedb) to store a relationship graph for +authorization. PostgreSQL remains the source of truth. Formbricks projects membership, team, workspace, and +API-key changes into SpiceDB after their PostgreSQL transaction commits. + + + Formbricks v6 makes SpiceDB the sole authorization decision engine and has no runtime legacy fallback. + PostgreSQL remains the relationship source of truth, with durable outbox delivery into SpiceDB. Existing + installations must complete the release-matched preparation and read-only gate before upgrading; a healthy + connection alone is not sufficient. + + + + Treat SpiceDB as a private infrastructure dependency. Do not publish its gRPC port through an Ingress, + reverse proxy, load balancer, or public Docker port. + + +## Understand the dependency + +| Component | Responsibility | +| ---------------------------- | ------------------------------------------------------------------------------- | +| Formbricks PostgreSQL | Authoritative organizations, memberships, teams, workspaces, and API-key scopes | +| SpiceDB PostgreSQL datastore | Persistent SpiceDB schema, relationships, and revisions | +| SpiceDB | Evaluates the relationship graph | +| Formbricks projection layer | Copies committed PostgreSQL authorization changes into SpiceDB | +| AuthZed operator commands | Check health and schema state, then audit or repair relationship drift | + +Formbricks writes projection intent in the same PostgreSQL transaction as the source mutation. BullMQ wakes the +delivery worker, but PostgreSQL is the durable queue. A failed delivery is retried idempotently and a stale or +dead-lettered revocation makes protected operations fail closed. A six-hour audit repairs attributable missing +or mismatched edges; operators must investigate state that cannot be repaired safely. + +The general Formbricks `/health` endpoint, application startup, and Kubernetes readiness and liveness probes +do not depend on SpiceDB. This prevents a SpiceDB outage from restarting unrelated Formbricks workloads. It +also means `/health` alone cannot prove that authorization data is healthy. + +There is no browser health or administration page. Use the release-matched operator commands below. + +## Configure AuthZed + +| Variable | Purpose | Default | +| --------------------------- | ------------------------------------------------------------------- | --------------------------------------------------- | +| `AUTHZED_ENABLED` | Enables the required AuthZed authorization engine | `true` in bundled Docker and Helm | +| `AUTHZED_ENDPOINT` | Bare gRPC `host:port` endpoint | `spicedb:50051` in Docker | +| `AUTHZED_TOKEN` | SpiceDB preshared authentication token | No default | +| `AUTHZED_SYSTEM_KEY` | Stable Formbricks authorization namespace identifier | `formbricks` | +| `AUTHZED_INSECURE` | Uses plaintext gRPC when enabled | `true` for bundled Docker and chart-managed SpiceDB | +| `AUTHZED_CONSISTENCY` | Authorization read consistency | `fully_consistent` | +| `AUTHZED_DATABASE_PASSWORD` | Password for Docker's dedicated `spicedb` PostgreSQL role | No default | +| `SPICEDB_IMAGE_REF` | Reviewed override used by both Docker migration and server services | `authzed/spicedb:v1.52.0` | + +`AUTHZED_ENABLED` and `AUTHZED_INSECURE` accept `true`, `false`, `1`, and `0`. The endpoint must contain an +explicit port and no scheme, path, query, credentials, or whitespace. Examples include `spicedb:50051`, +`grpc.authzed.com:443`, and `[::1]:50051`. + +Use `AUTHZED_INSECURE=false` for AuthZed Cloud or any endpoint outside a trusted private network. Plaintext +gRPC transmits the preshared token without TLS protection. Changing AuthZed configuration requires restarting +the Formbricks process. + +Generate independent credentials with: + +```bash +openssl rand -hex 32 # AUTHZED_TOKEN +openssl rand -hex 32 # AUTHZED_DATABASE_PASSWORD for bundled Docker +``` + +Store them in a mode-`0600` `.env`, Docker secret, Kubernetes Secret, or external secret manager. Never put +the token in a `NEXT_PUBLIC_*` variable, command output, documentation, or source control. + +See [Environment Variables](/self-hosting/configuration/environment-variables#authzed--spicedb-authorization) +for the complete validation contract. + +## Operate Docker and one-click installations + +The released Compose stack runs one SpiceDB instance using a dedicated `spicedb` database and login in the +bundled PostgreSQL container. Startup is ordered as follows: + +```text +postgres → authzed-db-bootstrap → spicedb-migrate → spicedb +postgres → formbricks-migrate +spicedb + formbricks-migrate → authzed-initialize +``` + +Database bootstrap and datastore migration are idempotent. Migration and serving always use the same +`SPICEDB_IMAGE_REF`. SpiceDB is reachable only as `spicedb:50051` inside the Compose network. + +`authzed-initialize` is an idempotent, one-shot service that prepares fresh installations. Formbricks does not +depend on it, so application startup and `/health` remain independent from SpiceDB. The `authzed-ops` profile is +a short-lived operator container using the same release image and never starts during a normal Compose run. + +```bash +docker compose --profile authzed-ops run --rm authzed-ops health +docker compose --profile authzed-ops run --rm authzed-ops schema check +docker compose --profile authzed-ops run --rm authzed-ops backfill +docker compose --profile authzed-ops run --rm authzed-ops upgrade check +``` + +Fresh one-click installations generate `AUTHZED_TOKEN` and `AUTHZED_DATABASE_PASSWORD`, prepare the graph, and +run the read-only gate before reporting success. The update command preserves customized Compose files. An older +installation must merge the released AuthZed services and environment manually, then explicitly acknowledge the +v6 migration. The updater refuses to stop the existing application until `upgrade prepare` and `upgrade check` +succeed. + + + `docker compose down` preserves PostgreSQL data. `docker compose down -v` deletes the volume containing both + the Formbricks and SpiceDB databases. Do not use `-v` during an upgrade or incident response. + + +The bundled PostgreSQL service leaves `track_commit_timestamp=off`, so the SpiceDB Watch API is disabled. This +does not affect schema operations, relationship projection, permission checks, or the repair workflow. + +## Operate Kubernetes and Helm installations + +The Formbricks chart supports two modes: + +- `authzed.mode=selfHosted` creates a `SpiceDBCluster` and points Formbricks at its private Kubernetes Service. +- `authzed.mode=external` configures the Formbricks client for AuthZed Cloud or another external SpiceDB. + +For a chart-managed cluster: + +```yaml +authzed: + mode: selfHosted + operator: + install: true +``` + +Install only one SpiceDB operator per Kubernetes cluster. If a platform operator already watches the +Formbricks namespace, set `authzed.operator.install=false`. Apply the matching SpiceDB CRDs before upgrading +the operator because Helm does not upgrade CRDs during a normal release upgrade. + +Production deployments should use a dedicated PostgreSQL database and role. Provide a Secret containing +`datastore_uri` and `preshared_key`, then reference it through `authzed.datastore.existingSecret` and +`authzed.auth.existingSecret`. Require `sslmode=require`, `verify-ca`, or `verify-full` for managed PostgreSQL. + +### Bootstrap the SpiceDB role and database + +A short-lived Job creates the dedicated `spicedb` role and database before SpiceDB starts. It creates each only +when it is absent, so re-running it against an already initialised database is safe — but not inert: the role's +password is reconciled to the chart's Secret on every run. Rotate that password outside Helm and the next +upgrade will set it back, so update the Secret alongside it. + +By default it connects as the `postgres` superuser the bundled PostgreSQL subchart creates. **An existing +PostgreSQL installed with `postgresql.auth.enablePostgresUser=false` has no such role**, and the upgrade fails +until the Job is told which role to use instead: + +```yaml +authzed: + bundledPostgresqlBootstrap: + adminUsername: fbadmin + adminDatabase: formbricks # maintenance database to attach to + adminPasswordSecretName: existing-pg-admin + adminPasswordKey: password +``` + +The role needs `CREATEROLE` and `CREATEDB`; it does not need to be a superuser. `adminPasswordSecretName` is +required whenever `adminUsername` is overridden, and `adminPasswordKey` whenever that Secret is configured +explicitly. **Both are checked while the chart renders**, so omitting one fails the render with the missing +value named — no Job is created. Without those guards the first would silently fall back to the bundled admin +password and the second would look up the subchart's key name inside your own Secret, and neither shows up +until the Pod fails to start in the cluster. + + + `CREATEROLE` and `CREATEDB` are sufficient on their own only when the administrator also creates the + `spicedb` role — which is the normal case, including on re-runs. `CREATE DATABASE ... OWNER spicedb` + additionally requires the administrator to be able to `SET ROLE` to that owner, so the Job grants itself the + `spicedb` role before creating the database. From PostgreSQL 16 that grant is only possible for a role the + administrator holds `ADMIN OPTION` on, which it gets automatically by creating it. + + The one case this cannot repair is a `spicedb` role that already exists and was created by *someone else*, + on PostgreSQL 16 or newer: the administrator then holds no `ADMIN OPTION` on it and the Job fails rather than + silently skipping work. Grant it explicitly — `GRANT spicedb TO fbadmin WITH ADMIN OPTION` as a superuser or + as the role's owner — or run the bootstrap as a superuser once. PostgreSQL 15 and older are unaffected: + `CREATEROLE` there carries authority over every non-superuser role. + + +Rendering fails fast with the three available remedies named — configure an administrative role, enable +`postgresql.auth.enablePostgresUser`, or set `authzed.bundledPostgresqlBootstrap.enabled=false` — rather than +letting the Job fail inside the cluster. Disabling bootstrap remains the correct choice when the role and +database are provisioned by hand or by a platform team; the SpiceDB `datastore_uri` must then already point at +them. + +For an externally managed PostgreSQL server, use `authzed.externalPostgresqlBootstrap` instead, which takes a +full administrator URL from a Secret and enforces TLS on it. + +For an external AuthZed endpoint: + +```yaml +authzed: + mode: external + operator: + install: false + endpoint: grpc.authzed.com:443 + insecure: false + auth: + existingSecret: formbricks-authzed +``` + +AuthZed and `fully_consistent` authorization are enabled by default. The operator runs SpiceDB datastore +migrations. A fresh Helm installation runs a release-matched, aggregate-only initialization Job after the +datastore and Formbricks migrations become available. Application startup and probes do not depend on this Job. +Existing releases never run it as an upgrade hook; they must use the explicit upgrade commands below. + +The release notes print the exact deployment command. With the default release: + +```bash +kubectl exec -n formbricks deployment/formbricks -- formbricks-authzed health +kubectl exec -n formbricks deployment/formbricks -- formbricks-authzed schema check +kubectl exec -n formbricks deployment/formbricks -- formbricks-authzed backfill +kubectl exec -n formbricks deployment/formbricks -- formbricks-authzed upgrade check +``` + +Replace the namespace and deployment name if you override them. These commands execute inside the existing +application pod and do not require exposing PostgreSQL or SpiceDB. + +## Upgrade an existing installation to v6 + +Formbricks v6 has no legacy authorization fallback. Do not deploy it over an installation whose graph has not +been proven complete. First upgrade to the bridge-compatible v5 release named in the v6 release notes, enable +durable projection, and take coordinated Formbricks and SpiceDB backups. + +Run the release-matched v6 image as the operator container or executable while the bridge release still serves +traffic: + +```bash +formbricks-authzed upgrade prepare +formbricks-authzed upgrade check +``` + +`prepare` verifies configuration and datastore readiness, applies an empty or already-matching canonical schema, +drains the outbox, reconciles attributable relationships, and runs a final audit. If the remote schema is +non-empty and differs, first run `schema check`, review the diff and backup, then pass its exact `remoteDigest`: + +```bash +formbricks-authzed upgrade prepare \ + --expected-current-digest sha256: +``` + +`check` is read-only. It exits `0` only when AuthZed is enabled with `fully_consistent`, authenticated health is +good, the canonical schema matches, the outbox has no pending or dead-lettered work, no revocation has crossed a +warning threshold, and a complete dry-run relationship audit is clean. Exit `2` means the release remains +blocked; exit `1` means configuration or an operation failed. Output contains only aggregate counters and stable +error codes. + +For one-click, set `FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true` in `.env` only after those commands pass. +For Helm, set `authzed.migrationAcknowledged=true` only in the v6 upgrade values. The chart refuses an upgrade +without it and refuses `authzed.enabled=false` or consistency weaker than `fully_consistent`. + +## Activate or upgrade SpiceDB + +Use the following sequence for a new deployment, schema change, SpiceDB version change, or datastore restore. + + + + Create a PostgreSQL-consistent backup of the Formbricks database and the dedicated `spicedb` database. + Record the deployed Formbricks and SpiceDB versions, configuration, and schema digest. + + + Deploy the reviewed SpiceDB version and wait for `spicedb datastore migrate head` to complete before the + new SpiceDB server starts. Never run migration and serving from different image references. + + + + ```bash + formbricks-authzed health + ``` + + A healthy command exits `0`. Disabled or unhealthy results exit `1`. + + + + + ```bash + formbricks-authzed schema check + ``` + + A match exits `0`, drift exits `2`, and an operational failure exits `1`. Initialize an empty SpiceDB with: + + ```bash + formbricks-authzed schema apply + ``` + + Replacing a non-empty schema requires the `remoteDigest` returned by the immediately preceding check: + + ```bash + formbricks-authzed schema apply \ + --expected-current-digest sha256: + ``` + + Ensure no other schema writer runs between check and apply. SpiceDB does not provide an atomic schema + compare-and-swap operation. + + + + + ```bash + formbricks-authzed backfill + formbricks-authzed backfill --apply + ``` + + Require a clean result before relying on SpiceDB authorization decisions. + + + + + ```bash + formbricks-authzed upgrade check + ``` + + Do not start a direct-authority release unless this exits `0`. + + + + Restart Formbricks after environment changes. Recheck AuthZed health, schema status, projection metrics, + retry metrics, and SpiceDB logs. + + + +The Docker examples use the `docker compose --profile authzed-ops run --rm authzed-ops` prefix. Kubernetes +examples use `kubectl exec ... --`. The command names and arguments after those prefixes are identical. + +### Roll back a v6 upgrade + +Keep the exact bridge-compatible v5 image, Compose or Helm values, application database backup, SpiceDB backup, +and schema digest until v6 acceptance is complete. Rollback means restoring that bridge image and its matching +configuration; do not disable AuthZed inside a v6 image. Verify outbox delivery and a clean audit before resuming +mutations. If either datastore was restored, use coordinated restore points or rebuild SpiceDB from PostgreSQL. + +The moving `latest` tag remains on the bridge-compatible v5 release for at least 30 days after v6 stable is +published. Existing installations must select v6 explicitly during that window; this prevents an unattended +image pull from bypassing the migration gate. + +## Back up and restore + +Back up the following together: + +- the authoritative Formbricks PostgreSQL database; +- the dedicated `spicedb` PostgreSQL database; +- AuthZed and datastore credentials in your secret manager; +- the deployed Formbricks and SpiceDB image versions; +- Compose or Helm configuration; and +- the schema digest reported by `schema check`. + +For bundled Docker PostgreSQL: + +```bash +umask 077 +docker compose exec -T postgres pg_dump -U postgres -d formbricks > formbricks.sql +docker compose exec -T postgres pg_dump -U postgres -d spicedb > spicedb.sql +``` + +Use your managed PostgreSQL provider's consistent snapshot mechanism for external databases. Define retention, +restore testing, RPO, and RTO according to your operating requirements. + +There are two supported recovery approaches: + +1. Restore coordinated Formbricks and SpiceDB database backups from the same recovery point. +2. Restore Formbricks PostgreSQL, initialize an empty compatible SpiceDB datastore, apply the release-matched + schema, and rebuild the relationship graph with a full applying backfill. + +After either restore, complete datastore migrations, check health, check the schema, and run a full +relationship audit. Do not rely on SpiceDB-backed authorization until the audit is clean. An older SpiceDB backup +can be repaired from PostgreSQL; a SpiceDB database restored without its matching Formbricks source must never +be assumed current. + +AuthZed Cloud owns datastore backup and restore guarantees. You still need Formbricks PostgreSQL backups, +recoverable client credentials, release configuration, schema verification, and the relationship repair +procedure. + +## Inspect and drain durable delivery + +The v6 bridge writes projection intent in the same PostgreSQL transaction as an authorization source change. +BullMQ wakes the delivery worker, but PostgreSQL remains the durable queue. Deletes, and updates that are not +provably grants, are treated as revocations; direct authority fails closed when a revocation remains unresolved +for 60 seconds or enters dead letter. + +```bash +formbricks-authzed outbox status +formbricks-authzed outbox drain +formbricks-authzed outbox drain --max-batches=500 +formbricks-authzed outbox replay +``` + +`status` prints aggregate queue counts and ages only. It exits `0` when healthy, `2` at the 15-second warning or +45-second critical thresholds (and for any dead letter), and `1` for an operational failure. `drain` processes +revocations first and stops at the first batch that delivers nothing; a partially delivered batch is normal, +because a failure is charged only to the events it is attributable to. `replay` resets unresolved dead letters +so the normal idempotent reconcilers can retry them; investigate the cause before replaying. Dead-lettering +requires ten solitary failures carrying a code an event can actually cause, so no SpiceDB outage — unreachable, +rejected credential or internal error — produces one, and a dead letter always means PostgreSQL and SpiceDB +genuinely disagree. The six-hour audit +replays dead letters on its own after a clean run, which bounds a fail-closed denial at six hours. + +These results contain no source IDs, relationship strings, credentials, or raw errors. This is different from +the detailed backfill report below, which intentionally contains operational identifiers. + +Every six hours Formbricks also runs a full applying audit without prune. It automatically repairs attributable +missing and mismatched-permission relationships. It never deletes orphaned or unmanaged data and never repairs +a mismatched parent automatically. + +## Audit and repair relationships + +The command prints one JSON result. It never prints credentials, database passwords, raw SDK errors, schema +text, or raw relationship strings. + +It does print identifiers, deliberately: naming the affected records is what makes a drift report actionable. +Most are Formbricks record IDs, but not all — `unmanaged` can surface object IDs belonging to no Formbricks +record at all. Treat the result as sensitive operational data. + +- `orphans`, `mismatchedParents` and `mismatchedPermissions` name the records that disagree, carrying + organization, user, workspace, team, API-key, feedback-directory and feedback-directory-assignment IDs. + `mismatchedPermissions` adds the expected and observed relation names alongside the record it names. +- `unmanaged` reports relationships outside the managed vocabulary as an object type, an object ID and a + relation name. Those object IDs are not limited to the kinds above — anything else sharing the SpiceDB + instance appears here. +- `failures` carries an organization ID for each failed unit that has one, so even a failed run emits + identifiers. It is empty when the read that would have identified the organization is what failed. +- `lastOrganizationId` is the resume cursor: the last organization the sweep reached, or `null` if it reached + none. + +The cursor is an intentional operational identifier, not an oversight. An interrupted sweep resumes with +`--after-organization-id=` taken straight from it, so an opaque token would have to be stored and mapped +back somewhere to stay useful. It is also the one field that can name an organization with nothing wrong: every +other identifier here comes from a record or relationship that drifted or failed, while the cursor is simply +wherever the sweep stopped. Redact it on the same terms as the rest. + + + Handle the result like a database export, not like a log line. Store it in the same place you keep other + sensitive operational output, restrict access to operators, and delete it once the drift it describes is + resolved — retain it no longer than your incident records. Do not paste it into shared chat, ticket + descriptions, or support bundles without redacting the identifier fields above. + + +Redirecting to a file keeps the result out of terminal scrollback and out of CI logs that capture stdout. Set +`umask 077` and remove any earlier export first. The umask governs file *creation* only, so under a typical +`umask 022` a new `backfill.json` lands world-readable — and redirecting over one that already exists truncates +it while leaving its existing mode untouched: + +```bash +umask 077 +rm -f backfill.json +formbricks-authzed backfill > backfill.json +``` + +Redirection does not keep identifiers out of your shell history, which records the command rather than its +output — and the resume invocation is the one to watch there, because the cursor is an argument: +`--after-organization-id=` is recorded verbatim. On a shared or session-recorded host, read the cursor +from the saved result instead of retyping it. + +### Interpret exit codes + +| Exit code | Meaning | +| --------- | -------------------------------------------------------------- | +| `0` | Every observed drift category is clean or was reconciled | +| `1` | Invalid command, configuration failure, or operational failure | +| `2` | Drift remains and operator action is required | + +### Start with a dry run + +```bash +formbricks-authzed backfill +formbricks-authzed backfill --organization-id= +formbricks-authzed backfill --workspace-id= +``` + +An organization or workspace run limits the repair blast radius, but reports +`orphanScope: "known_resources"`. Only a complete deployment sweep can find relationships for resources that +no longer exist in PostgreSQL or parent edges pointing from another tenant's resource. + +### Reconcile PostgreSQL state + +```bash +formbricks-authzed backfill --apply +formbricks-authzed backfill --apply --organization-id= +formbricks-authzed backfill --apply --workspace-id= +``` + +Applying fixes missing and incorrect current-state relationships. It does not remove relationships with no +remaining PostgreSQL source record. + +If a sweep is interrupted, resume after the reported `lastOrganizationId`. A `null` cursor means the run +reached no organization, so there is nothing to resume after — rerun the sweep from the start instead: + +```bash +formbricks-authzed backfill --apply --after-organization-id= +``` + +The cursor advances past every organization the sweep reached, and an organization-specific failure does not +stop the sweep — so each failure carrying a non-empty `organizationId` sits behind the cursor, and a resume +will not retry it. Rerun those explicitly, in addition to resuming from the cursor: + +```bash +formbricks-authzed backfill --apply --organization-id= +``` + +A failure with an **empty** `organizationId` is not one of those: the read that would have identified the +organization is itself what failed, so there is nothing to target. Within a sweep those also set `truncated` — +rerun the sweep rather than a single organization. + +### Prune orphans + +Pruning is the only mode that removes relationships observed only in SpiceDB. It requires every safeguard: + +```bash +formbricks-authzed backfill --apply --prune --confirm-prune \ + --scope=all \ + --expected-endpoint= +``` + +Use `--organization-id` or `--workspace-id` instead of `--scope=all` when the known problem permits a narrower +repair. A complete prune requires a SpiceDB datastore dedicated to one Formbricks deployment because object +IDs are not currently namespaced by `AUTHZED_SYSTEM_KEY`. + +The default destructive cap is 500 orphaned resources. `--max-prune=` may lower but never raise it. If +the run observes more than the cap, it deletes nothing. Investigate a wrong endpoint, wrong database, partial +restore, or shared SpiceDB before proceeding. + +### Interpret drift categories + +| Category | Meaning and response | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `missing` | PostgreSQL contains a source record without the expected SpiceDB relationship. An applying run repairs it. | +| `mismatchedPermissions` | An existing source record's projected role or grant relation differs from PostgreSQL. An applying run repairs it deterministically. | +| `orphaned` | SpiceDB contains a managed relationship whose source record is gone. A confirmed prune removes it. | +| `invalid` | PostgreSQL contains a cross-organization source row. Investigate and correct PostgreSQL manually. | +| `unmanaged` | A relationship outside Formbricks' managed vocabulary exists. Investigate its writer; the tool never deletes it. | +| `mismatchedParents` | A resource points to an organization PostgreSQL does not identify as its owner. Treat this as a possible cross-tenant privilege escalation. | +| `failures` | One or more units failed. Use the stable code and attempt count, correct the cause, and rerun. | +| `truncated` | Observation did not produce a complete, exact result. Do not prune or declare the graph clean; rerun. | + + + Never automatically repair `mismatchedParents`. Confirm the authoritative owner and inspect the reported + relation direction before deleting an edge. A wrong parent edge can grant another tenant's owners and + managers access to the resource. + + +## Diagnose failures + +| Result or symptom | Meaning | Operator response | +| ---------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `authzed_disabled` | The client is intentionally disabled | Confirm whether this deployment should project relationships, then configure and restart it | +| `authzed_unauthenticated` or `authzed_permission_denied` during health/schema operations | Token rejected or lacks the required API capability | Verify the secret reference and coordinated token configuration without printing the token | +| `authzed_timeout` | An attempt exceeded its deadline | Check network latency, datastore saturation, and SpiceDB load | +| `authzed_overloaded` | SpiceDB reported resource exhaustion | Inspect datastore connections, dispatch, cache, CPU, and memory | +| `authzed_unavailable` | SpiceDB or its network path is unavailable | Restore the service, verify health, then run a full relationship audit | +| `authzed_internal` | Unexpected client or runtime failure | Check sanitized Formbricks and SpiceDB logs and the deployed version | +| Schema status `drifted` | Connected schema differs from this Formbricks release | Back up first, review the diff counts and digest, then perform a guarded schema apply | +| Projection status `failed` | A fast-path or outbox reconciliation attempt failed | Restore connectivity, inspect outbox status, and drain or replay durable delivery | +| `authzed_projection_stale` | A revocation exceeded 60 seconds or entered dead letter | Investigate immediately, restore delivery, replay, drain, and require a clean audit | + +Formbricks AuthZed logs use `component="authzed"`, stable `errorCode` values, and bounded fields such as +`operation`, `projection`, `status`, `retryable`, `attemptCount`, `grpcStatus`, and `durationMs`. They must not +contain tokens, database credentials, schema text, raw SDK errors, relationship strings, or actor/resource IDs. + +## Monitor AuthZed + +Enable the existing Formbricks Prometheus or OTLP metrics exporter. The release-matched direct-authority +signals are: + +| Metric | What it measures | +| ---------------------------------------------------- | --------------------------------------------------------------------------- | +| `formbricks_authzed_projection_total` | Projected, failed, and disabled projection outcomes | +| `formbricks_authzed_projection_duration_seconds` | Request-path projection latency | +| `formbricks_authzed_request_failures_total` | AuthZed requests that exhausted their retry budget | +| `formbricks_authzed_request_retries_total` | Retry attempts caused by transient failures | +| `formbricks_authzed_authorization_decisions_total` | Authoritative allow, deny, and operational-error outcomes | +| `formbricks_authzed_authorization_decision_duration_seconds` | End-to-end authoritative authorization latency | +| `formbricks_authzed_authorization_checks_per_request` | Central authorization operations per authenticated request | +| `formbricks_authzed_projection_outbox_delivery_total` | Durable outbox events delivered or failed | +| `formbricks_authzed_projection_outbox_delivery_duration_seconds` | Durable delivery batch latency | +| `formbricks_authzed_projection_revocation_delivery_duration_seconds` | Commit-to-SpiceDB revocation propagation time | +| `formbricks_authzed_projection_outbox_status` | Point-in-time pending, dead-letter, warning, and critical counts | +| `formbricks_authzed_projection_outbox_oldest_pending_age_seconds` | Age of the oldest pending event | +| `formbricks_authzed_reconciliation_audit_total` | Scheduled audit outcomes | +| `formbricks_authzed_reconciliation_drift_total` | Attributable drift and failures observed by scheduled audits | +| `formbricks_authzed_reconciliation_repair_total` | Repaired and failed attributable relationship repair results | + +Starting PromQL checks: + +```promql +# Projection failures introduce possible drift. +sum(rate(formbricks_authzed_projection_total{status="failed"}[5m])) + +# Terminal unavailability errors. +sum(rate(formbricks_authzed_request_failures_total{code="authzed_unavailable"}[5m])) + +# Projection latency p95. +histogram_quantile( + 0.95, + sum by (le) (rate(formbricks_authzed_projection_duration_seconds_bucket[5m])) +) + +# Direct-authority operational-error rate. Product denials remain a separate outcome. +sum(rate(formbricks_authzed_authorization_decisions_total{outcome="operational_error"}[5m])) +/ +sum(rate(formbricks_authzed_authorization_decisions_total[5m])) + +# Direct-authority latency p95 by surface. +histogram_quantile( + 0.95, + sum by (le, surface) (rate(formbricks_authzed_authorization_decision_duration_seconds_bucket[5m])) +) + +# Revocations that missed the warning or critical delivery thresholds. +formbricks_authzed_projection_outbox_status{state=~"revocation_warning|revocation_critical"} + +# Dead letters and failed scheduled repairs both block cutover. +formbricks_authzed_projection_outbox_status{state="dead_lettered"} +or +sum(rate(formbricks_authzed_reconciliation_repair_total{status="failed"}[5m])) +``` + +Starting gates are: operational-error rate at or below 0.1%, p95 below 250 ms, p99 below one second, no +dead-letter revocations, no normal-operation 60-second freshness guard, and clean scheduled audits. Alert on +revocation delivery at 15 seconds (warning) and 45 seconds (critical). The application on-call owns decision, +delivery, and repair alerts; the infrastructure on-call owns SpiceDB/datastore availability and capacity, with +joint escalation whenever either side cannot restore a clean graph. + +On the SpiceDB side, monitor pod restarts, failed datastore migrations, PostgreSQL connection saturation such +as `pgxpool_empty_acquire`, dispatch load, cache behavior, and datastore latency. Divide the datastore +connection budget across replicas and their read/write pools. + +## Use the incident checklist + +1. Confirm Formbricks `/health` independently. +2. Run `formbricks-authzed health`. +3. Check SpiceDB pods or containers and the last datastore migration. +4. Check Formbricks projection failures, AuthZed terminal errors, retries, and latency. +5. Restore SpiceDB or its datastore without changing authorization data manually. +6. Run `schema check`. +7. Run a full dry-run backfill. +8. Apply repair and, only when justified, a guarded prune. +9. Require a clean result before relying on SpiceDB authorization decisions. +10. Preserve the sanitized command result, timeline, versions, and root cause for follow-up. diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx index a1895377ce07..478f7c064fe5 100644 --- a/docs/self-hosting/advanced/migration.mdx +++ b/docs/self-hosting/advanced/migration.mdx @@ -4,6 +4,32 @@ description: "Formbricks Self-hosted version migration" icon: "arrow-right" --- +## v6 + +Formbricks v6 replaces the legacy authorization evaluator with AuthZed SpiceDB. PostgreSQL remains the source +of roles and grants, but SpiceDB is the sole decision engine. There is no runtime fallback, so an existing +installation must migrate through the bridge-compatible v5 release identified in the v6 release notes. + +Before selecting a v6 image: + +1. Back up the Formbricks and SpiceDB PostgreSQL databases, secrets, deployment configuration, image versions, + and canonical schema digest. +2. Merge the release-matched AuthZed deployment services or Helm values without overwriting proxy, storage, + SMTP, or external-datastore customization. +3. Run the v6 database migrations while the bridge application remains available. +4. Run `formbricks-authzed upgrade prepare`, then require `formbricks-authzed upgrade check` to exit `0`. +5. For one-click or Compose, set `FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true`. For Helm, set + `authzed.migrationAcknowledged=true` in the v6 upgrade values. +6. Deploy v6 and verify critical allow, deny, revocation, and cross-tenant cases. + +The update scripts and chart fail before replacing the running release when the deployment contract, +acknowledgement, schema, outbox, or relationship graph is not ready. Do not bypass the acknowledgement to silence +a failed gate. See [AuthZed Operations](/self-hosting/advanced/authzed-operations#upgrade-an-existing-installation-to-v6) +for Docker and Kubernetes commands, guarded schema replacement, backups, repair, and rollback. + +The moving `latest` image tag remains on the bridge-compatible v5 release for at least 30 days after v6 stable +publication. Existing installations opt into v6 explicitly during that migration window. + ## v5.4 v5.4 carries one application-level breaking change, and it is narrow: it affects **API keys** used against diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index d6d16920a480..a0de51780747 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -25,6 +25,7 @@ For `AI_PROVIDER=openai-compatible`, the LLM GA v1 self-hosted path is Qwen serv | PUBLIC_URL | Base URL for the public domain where surveys and public-facing content are served. If not set, uses WEBAPP_URL. OAuth and MCP do not use PUBLIC_URL. | optional | WEBAPP_URL | | NEXTAUTH_URL | Legacy-compatible auth base URL. This should normally be the same as WEBAPP_URL; Formbricks derives the Better Auth issuer under `/api/auth`. | required | http://localhost:3000 | | BETTER_AUTH_URL | Optional Better Auth base URL override. Set this only when the auth server base differs from NEXTAUTH_URL; preserve any custom subpath. | optional | NEXTAUTH_URL | +| MCP_OAUTH_JWKS_URL | Optional server-only URL used to fetch Better Auth signing keys for MCP OAuth. It does not change the public issuer, redirects, cookies, or token audience. Docker Compose and Helm leave it unset unless explicitly configured. Use an internal HTTP URL only on a trusted application network and include any custom application subpath. | optional | `{BETTER_AUTH_URL or NEXTAUTH_URL}/jwks` | | DATABASE_URL | Database URL with credentials. | required | | | NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user, must not exceed 32 bytes, `openssl rand -hex 32`) | | BETTER_AUTH_SECRET | Optional Better Auth secret override. If unset, Formbricks uses NEXTAUTH_SECRET for Better Auth session signing. | optional | NEXTAUTH_SECRET | @@ -152,6 +153,71 @@ bundled Docker Compose or Helm assets, the following variables apply: | HUB_API_URL | Base URL the Formbricks app uses to call Hub. With the bundled Docker stack, keep this at `http://hub:8080` unless Hub runs elsewhere. | required | `http://hub:8080` (bundled Docker), `http://localhost:8080` (local dev) | | HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) | +#### AuthZed / SpiceDB Authorization + +These variables define the supported authorization-client contract. Formbricks v6 enables AuthZed by default +for fresh Docker, one-click, and Helm installations. Existing installations must pass the release-matched +upgrade gate before enabling the v6 application image. + +| Variable | Description | Required when enabled | Default | +| ------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------ | +| AUTHZED_ENABLED | Enables the required AuthZed authorization engine. | required | `true` in bundled Docker, Helm, and dev | +| AUTHZED_ENDPOINT | AuthZed gRPC endpoint as `host:port`. The default Helm-managed service is `formbricks-spicedb:50051`. | required | `spicedb:50051` in Docker; `localhost:50051` in dev | +| AUTHZED_TOKEN | Preshared API token. Always load this from a secret. | required | | +| AUTHZED_SYSTEM_KEY | Stable namespace for Formbricks authorization objects and relationships. | required | `formbricks` | +| AUTHZED_INSECURE | Allows plaintext gRPC for an in-cluster service. Keep disabled for endpoints outside the trusted cluster network. | optional | `true` in Helm and bundled Docker/dev | +| AUTHZED_CONSISTENCY | Consistency policy used by authorization decisions. | optional | `fully_consistent` in released Docker and Helm | + +Docker Compose additionally requires `AUTHZED_DATABASE_PASSWORD` for its dedicated `spicedb` PostgreSQL +login. Generate both it and `AUTHZED_TOKEN` with `openssl rand -hex 32`. `SPICEDB_IMAGE_REF` can override the +bundled `authzed/spicedb:v1.52.0` image for an explicitly reviewed upgrade; the migration and server services +always use the same reference. + +`AUTHZED_ENABLED` and `AUTHZED_INSECURE` accept `true`, `false`, `1`, or `0`. When `AUTHZED_ENABLED` is unset, +the application client is disabled; when `AUTHZED_INSECURE` is unset, it uses TLS. A configured endpoint must +be a bare `host:port` with an explicit port from 1 through 65535, for example `spicedb:50051`, +`grpc.authzed.com:443`, or `[::1]:50051`. Do not include a URL scheme, path, query, credentials, or whitespace. +`AUTHZED_SYSTEM_KEY` must be a 3–64 character lowercase SpiceDB identifier made from letters, digits, and +underscores; it must start with a letter or underscore and end with a letter or digit. The supported +consistency values are `minimize_latency` and `fully_consistent`. Released v6 Docker and Helm deployments +require `fully_consistent`; the upgrade gate rejects weaker consistency. + +Valid endpoint, token, and system-key values may be set before enabling AuthZed. Invalid supplied values are +rejected even while it is disabled. Changing any AuthZed setting requires restarting the Formbricks process. +Configuration validation never prints the token. + +In a released Docker installation, validate the configured connection without installing a schema or changing +data: + +```bash +docker compose --profile authzed-ops run --rm authzed-ops health +docker compose --profile authzed-ops run --rm authzed-ops upgrade check +``` + +For Helm, run `formbricks-authzed health` inside a Formbricks application pod. Repository development can keep +using `pnpm authzed:health`. See [AuthZed Operations](/self-hosting/advanced/authzed-operations) for the complete +Docker and Kubernetes command set, schema lifecycle, backups, and relationship repair. + +Only the repository-development `pnpm authzed:health` command reads the repository `.env`. Docker Compose +uses the `authzed-ops` container environment, while Helm and Kubernetes use the Formbricks Pod environment. +Each invocation performs an authenticated read-only schema request, prints exactly one JSON result, and exits +`0` only for `{"status":"healthy","latencyMs":12}` (the latency varies). Disabled or failed checks exit `1`. +Failures use stable codes: `authzed_unauthenticated` or +`authzed_permission_denied` for rejected credentials or schema-read access (the bundled SpiceDB preshared-key +check reports a wrong token as permission denied), `authzed_timeout` when an attempt exceeds its deadline, +`authzed_overloaded` when SpiceDB is resource constrained, `authzed_unavailable` when the service cannot be +reached, and `authzed_internal` for an unexpected client failure. Schema text, tokens, raw SDK errors, and stack +traces are never included. + +This check is intentionally a CLI rather than a browser page or HTTP endpoint. AuthZed is not part of the +general `/health` response, Kubernetes readiness, or application startup, so a transient SpiceDB outage does +not restart or mark the rest of Formbricks unhealthy. + +Failed or unavailable permission checks fail the protected operation closed; there is no runtime fallback to +legacy authorization. Existing installations must complete `formbricks-authzed upgrade prepare` followed by +`upgrade check` before upgrading. The general application readiness probe remains independent from AuthZed so a +transient authorization outage does not restart unrelated Formbricks workloads. + #### Cube Analytics Cube is part of the baseline Formbricks v5 stack and is required. Formbricks generates the backend diff --git a/docs/self-hosting/setup/docker.mdx b/docs/self-hosting/setup/docker.mdx index 1150839633c5..431416e8f7e1 100644 --- a/docs/self-hosting/setup/docker.mdx +++ b/docs/self-hosting/setup/docker.mdx @@ -6,7 +6,8 @@ icon: "docker" Use this guide for a manual Docker Compose setup. It downloads the production Compose file and starts the baseline Formbricks stack: Formbricks Web, PostgreSQL, Redis/Valkey, Formbricks Hub, and Cube. Optional -services such as Qwen/vLLM, AI taxonomy, and RustFS are documented after the baseline stack is running. +services such as Qwen/vLLM, AI taxonomy, and RustFS are documented after the baseline stack is running. The +baseline also runs a single SpiceDB instance for Formbricks authorization data. ### Requirements @@ -49,13 +50,18 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and 1. **Download the Docker Files** - Get the Docker Compose file plus the Cube configuration shipped with the baseline stack: + Get the Docker Compose file, AuthZed database bootstrap helper, and Cube configuration shipped with the + baseline stack: ```bash mkdir -p cube/schema curl -fsSL \ -o docker-compose.yml \ https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/docker-compose.yml + curl -fsSL \ + -o authzed-postgres-bootstrap.sh \ + https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/authzed-postgres-bootstrap.sh + chmod 700 authzed-postgres-bootstrap.sh curl -fsSL \ -o cube/cube.js \ https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/cube/cube.js @@ -81,6 +87,8 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and CUBEJS_API_SECRET=$(openssl rand -hex 32) CUBEJS_JWT_ISSUER=formbricks-web CUBEJS_JWT_AUDIENCE=formbricks-cube + AUTHZED_TOKEN=$(openssl rand -hex 32) + AUTHZED_DATABASE_PASSWORD=$(openssl rand -hex 32) EOF chmod 600 .env ``` @@ -99,7 +107,7 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and 1. **Start the Docker Setup** Now, you're ready to run Formbricks with Docker. Use the command below to start Formbricks together with - PostgreSQL, Redis, Formbricks Hub, and Cube. + PostgreSQL, Redis, Formbricks Hub, Cube, and SpiceDB. ```bash docker compose up -d @@ -114,11 +122,12 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and ```bash docker compose ps -a curl -fsS http://localhost:3000/health - docker compose logs --tail=100 formbricks-migrate hub-migrate formbricks hub cube + docker compose logs --tail=100 formbricks-migrate hub-migrate authzed-db-bootstrap spicedb-migrate + docker compose logs --tail=100 formbricks hub cube spicedb ``` - `formbricks-migrate` and `hub-migrate` should complete successfully. `postgres`, `redis`, `cube`, - `hub`, and `formbricks` should be running or healthy. + `formbricks-migrate`, `hub-migrate`, `authzed-db-bootstrap`, and `spicedb-migrate` should complete + successfully. `postgres`, `redis`, `cube`, `hub`, `spicedb`, and `formbricks` should be running or healthy. 1. **Open Formbricks in Your Browser** @@ -132,6 +141,57 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and internally through `http://taxonomy:8000`. +## AuthZed and SpiceDB + +The bundled SpiceDB service stores its data in a dedicated `spicedb` database and login inside the existing +PostgreSQL container. Database bootstrap, datastore migration, and the release-matched `authzed-initialize` +service are idempotent. Fresh installs apply the canonical schema and verify an empty or reconciled graph +without making Formbricks startup or `/health` depend on SpiceDB. SpiceDB is reachable only inside the Compose +network at `spicedb:50051`; it is not published on the host or routed through Traefik. + +The Formbricks container receives the supported AuthZed variables automatically. Keep `AUTHZED_TOKEN` and +`AUTHZED_DATABASE_PASSWORD` private and include `.env` and the PostgreSQL volume in your backup plan. Running +`docker compose down` preserves the volume; `docker compose down -v` deletes both Formbricks and SpiceDB data. + +The release image includes an opt-in operations CLI. It does not start during normal installation: + +```bash +docker compose --profile authzed-ops run --rm authzed-ops health +docker compose --profile authzed-ops run --rm authzed-ops schema check +docker compose --profile authzed-ops run --rm authzed-ops backfill +docker compose --profile authzed-ops run --rm authzed-ops upgrade check +``` + +See [AuthZed Operations](/self-hosting/advanced/authzed-operations) before applying a schema, repairing +relationships, upgrading SpiceDB, or restoring a backup. + +The bundled PostgreSQL service leaves `track_commit_timestamp` at its default `off` value, so SpiceDB reports +that its Watch API is disabled. This does not affect schema writes, relationship writes, or permission checks. +If a future integration consumes the Watch API, enable that PostgreSQL setting separately and plan for the +required database restart. + +Repository development exposes SpiceDB on `127.0.0.1:50051`. To start the optional authenticated browser UI: + +```bash +docker compose -f docker-compose.dev.yml --profile authzed-ui up -d authzed-ui +``` + +Then open [http://127.0.0.1:50052](http://127.0.0.1:50052). Run `pnpm authzed:smoke` for an isolated +application-health, authentication-failure, bounded-outage, schema, permission, migration-idempotency, and +persistence check. The smoke command uses its own Compose project and random host port, then removes its test +data afterward. + +To check the developer stack through the Formbricks AuthZed client without writing data, run: + +```bash +pnpm authzed:health +``` + +It reads the repository `.env`, prints one JSON result, and returns exit code `0` only when healthy. An empty +SpiceDB schema is still a healthy connection. There is no AuthZed browser health page, and the normal +Formbricks `/health` endpoint deliberately remains independent of SpiceDB. Restart Formbricks after changing +any `AUTHZED_*` value. + ## Optional Services Start and verify the baseline stack before enabling optional services. @@ -285,6 +345,41 @@ memory, and LLM capacity. See our [migration guide](/self-hosting/advanced/migration) for version-specific steps to update Formbricks. +### Add SpiceDB to an Existing Docker Installation + +The normal image update commands do not add new services to an existing customized Compose file. Before the +first release that requires the AuthZed runtime: + +1. Back up `.env`, `docker-compose.yml`, and the PostgreSQL volume. +2. Download `authzed-postgres-bootstrap.sh` from the same Formbricks release as the Compose file. +3. Generate `AUTHZED_TOKEN` and `AUTHZED_DATABASE_PASSWORD` with `openssl rand -hex 32` and add them to `.env`. +4. Merge `authzed-db-bootstrap`, `spicedb-migrate`, `spicedb`, `authzed-initialize`, and the profiled + `authzed-ops` service, plus the six `AUTHZED_*` Formbricks variables, from the released Compose file into your + customized file. Use `AUTHZED_CONSISTENCY=fully_consistent`. Preserve proxy, storage, and SMTP settings. +5. Run `docker compose config` and `docker compose pull`. Apply the release's Formbricks database migrations + while the old application remains available: `docker compose run --rm formbricks-migrate`. +6. Start PostgreSQL and SpiceDB, then run the guarded preparation and read-only gate: + + ```bash + docker compose up -d postgres authzed-db-bootstrap spicedb-migrate spicedb + docker compose --profile authzed-ops run --rm authzed-ops upgrade prepare + docker compose --profile authzed-ops run --rm authzed-ops upgrade check + ``` + + A non-empty mismatched schema requires the reviewed `remoteDigest` as described in + [AuthZed Operations](/self-hosting/advanced/authzed-operations#upgrade-an-existing-installation-to-v6). +7. Only after `upgrade check` exits `0`, set `FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true` in `.env` and + run `docker compose up -d`. The one-click updater performs the same preparation before it stops the old app. +8. Confirm `authzed-db-bootstrap`, `spicedb-migrate`, and `authzed-initialize` completed and `spicedb` is healthy + with `docker compose ps -a`. +9. Back up the new authorization database with + `docker compose exec -T postgres pg_dump -U postgres -d spicedb > spicedb-backup.sql` after validation and + include it in future database backup procedures. + +To roll back v6, restore the exact bridge-compatible v5 image and configuration kept for the migration. Keep +outbox delivery enabled, require a clean audit, and do not delete or replace either PostgreSQL database. See the +rollback procedure in [AuthZed Operations](/self-hosting/advanced/authzed-operations#roll-back-a-v6-upgrade). + For a major migration such as Formbricks 4.x to 5.0, update your compose structure and configuration first. Pulling images alone is not enough if your stack does not yet include Hub (`HUB_API_KEY`), Cube (`cube/` diff --git a/docs/self-hosting/setup/kubernetes.mdx b/docs/self-hosting/setup/kubernetes.mdx index a4a0ee0bce9d..0605e1fe0718 100644 --- a/docs/self-hosting/setup/kubernetes.mdx +++ b/docs/self-hosting/setup/kubernetes.mdx @@ -26,17 +26,18 @@ Ensure you have the following before proceeding: ## 1. Install The Chart - + ```yaml formbricks: webappUrl: https://surveys.example.com ``` -Add any additional overrides you need for ingress, external services, secrets, or Enterprise license features. - + Add any additional overrides you need for ingress, external services, secrets, or Enterprise license features. - + + + ```sh helm install formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \ @@ -45,15 +46,16 @@ helm install formbricks oci://ghcr.io/formbricks/helm-charts/formbricks \ -f values.yaml ``` -By default, the chart deploys: + By default, the chart deploys: + + - the Formbricks application + - Formbricks Hub + - Cube + - PostgreSQL + - Redis + - generated Kubernetes Secrets -- the Formbricks application -- Formbricks Hub -- Cube -- PostgreSQL -- Redis -- generated Kubernetes Secrets - + @@ -105,6 +107,55 @@ If your cluster already uses an external secret manager, enable `externalSecret` SecretStore. Ensure the resulting app secret exposes the values your deployment needs, including `DATABASE_URL`, `REDIS_URL`, and `HUB_API_KEY`. +### AuthZed / SpiceDB + +Formbricks v6 uses AuthZed as its authorization engine. Fresh chart installations enable a private, +two-replica SpiceDB cluster and `fully_consistent` decisions by default. If the cluster already has a compatible +SpiceDB operator, keep the authorization runtime enabled but disable this release's operator installation: + +```yaml +authzed: + operator: + install: false +``` + +The default `authzed.operator.install: true` installs the pinned operator, creates the cluster, and bootstraps a +dedicated `spicedb` database and login. Install only one operator in a Kubernetes cluster. + +For managed PostgreSQL, create a separate SpiceDB database and login first. Store its connection URI and a +strong API token in a Kubernetes Secret using the keys `datastore_uri` and `preshared_key`, then configure: + +```yaml +authzed: + enabled: true + auth: + existingSecret: formbricks-authzed + datastore: + existingSecret: formbricks-authzed +``` + +Use `authzed.mode: external`, `authzed.operator.install: false`, `authzed.endpoint: :`, and +`authzed.insecure: false` when connecting +to an externally managed AuthZed endpoint. The external endpoint must serve TLS because the preshared token is +sent on every authenticated request. The application endpoint remains internal and uses plaintext gRPC by +default when the chart owns SpiceDB. + +Helm prints release-specific commands that execute the release-matched operator CLI +inside a Formbricks pod. Start with: + +```bash +kubectl exec -n deployment/ -- formbricks-authzed health +kubectl exec -n deployment/ -- formbricks-authzed schema check +kubectl exec -n deployment/ -- formbricks-authzed upgrade check +``` + +A fresh install runs an idempotent initialization Job, but application startup and probes remain independent +from it. Upgrades never run relationship preparation automatically. Before the first v6 upgrade, run +`formbricks-authzed upgrade prepare`, require `upgrade check` to exit `0`, and then set +`authzed.migrationAcknowledged: true`. The chart refuses an unacknowledged upgrade, disabled AuthZed, or weaker +consistency. Follow [AuthZed Operations](/self-hosting/advanced/authzed-operations) and do not expose SpiceDB +through an Ingress. + ## 3. v5-Specific Deployment Notes ### Hub Is Mandatory @@ -279,20 +330,20 @@ For a Formbricks 4.x to 5.0 migration, confirm the following before running the ## 5. Key Values -| Field | Description | -| ----------------------------- | ------------------------------------------------------------- | -| `formbricks.webappUrl` | Public base URL for the Formbricks app | -| `deployment.image.tag` | Formbricks image tag override | -| `hub.enabled` | Must stay `true` in Formbricks v5 | -| `hub.image.tag` | Hub image tag override | -| `envoy.enabled` | Enables chart-managed Envoy Gateway resources | -| `envoy.controller.enabled` | Installs the bundled Envoy controller when `true` | -| `envoyRedis.enabled` | Deploys a dedicated Redis backend for Envoy rate limiting | -| `llm.enabled` | Deploys the optional bundled Qwen/vLLM runtime | -| `llm.autoConfigureApp` | Injects Formbricks OpenAI-compatible env vars for bundled Qwen | -| `taxonomy.enabled` | Deploys the optional standalone AI taxonomy service | -| `postgresql.externalDatabaseUrl` | Uses an external PostgreSQL service instead of in-cluster | -| `redis.externalRedisUrl` | Uses an external Redis/Valkey service instead of in-cluster | +| Field | Description | +| -------------------------------- | -------------------------------------------------------------- | +| `formbricks.webappUrl` | Public base URL for the Formbricks app | +| `deployment.image.tag` | Formbricks image tag override | +| `hub.enabled` | Must stay `true` in Formbricks v5 | +| `hub.image.tag` | Hub image tag override | +| `envoy.enabled` | Enables chart-managed Envoy Gateway resources | +| `envoy.controller.enabled` | Installs the bundled Envoy controller when `true` | +| `envoyRedis.enabled` | Deploys a dedicated Redis backend for Envoy rate limiting | +| `llm.enabled` | Deploys the optional bundled Qwen/vLLM runtime | +| `llm.autoConfigureApp` | Injects Formbricks OpenAI-compatible env vars for bundled Qwen | +| `taxonomy.enabled` | Deploys the optional standalone AI taxonomy service | +| `postgresql.externalDatabaseUrl` | Uses an external PostgreSQL service instead of in-cluster | +| `redis.externalRedisUrl` | Uses an external Redis/Valkey service instead of in-cluster | For the complete values surface, refer to the chart README in the repository: [charts/formbricks/README.md](https://github.com/formbricks/formbricks/tree/main/charts/formbricks). diff --git a/docs/self-hosting/setup/monitoring.mdx b/docs/self-hosting/setup/monitoring.mdx index 127f94461790..a71bca1b5c9c 100644 --- a/docs/self-hosting/setup/monitoring.mdx +++ b/docs/self-hosting/setup/monitoring.mdx @@ -154,14 +154,12 @@ The exporter listens on all network interfaces (0.0.0.0) and exposes metrics at The metrics exported by the Prometheus integration include: - **Host Metrics**: - - CPU usage (user, system, idle) - Memory usage (used, free, cached) - Disk I/O (reads, writes) - Network I/O (bytes in/out, packets in/out) - **HTTP Metrics**: - - Request counts - Request durations - Error rates @@ -201,3 +199,25 @@ GET /health ``` Use these endpoints for monitoring system health in container orchestration and monitoring tools. + +## AuthZed and SpiceDB + +Current v5 bridge releases expose bounded projection and authorization-comparison metrics when AuthZed is +enabled. Monitor projection failures, disabled outcomes, migration mismatches, authorization operational errors, +and authorization latency. + +The v6 direct-authority release exposes authoritative allow/deny/operational-error and latency metrics plus +outbox backlog, revocation delivery latency, oldest pending item, dead-letter, reconciliation drift, and repair +signals. The fixed revocation thresholds warn at 15 seconds, become critical at 45 seconds, and make protected +authorization fail closed at 60 seconds; the 60-second bound is not configurable. A dead-lettered revocation +triggers warning, critical, and fail-closed states immediately, regardless of its age. Follow the release-matched +[AuthZed Operations guide](/self-hosting/advanced/authzed-operations) during upgrade rather than treating v5 +comparison metrics as a v6 rollout gate. + +For every release, also monitor SpiceDB pod availability, datastore migrations, PostgreSQL pool saturation, +dispatch latency, and cache behavior. + +The general Formbricks `/health` endpoint intentionally stays independent from SpiceDB. Use the release-matched +`formbricks-authzed health` command for authenticated authorization health checks. The +[AuthZed Operations guide](/self-hosting/advanced/authzed-operations) contains metric names, PromQL examples, +starting alert thresholds, stable error codes, and the outage-repair runbook. diff --git a/docs/self-hosting/setup/one-click.mdx b/docs/self-hosting/setup/one-click.mdx index aa3fba394078..1b65ac1ad0ec 100644 --- a/docs/self-hosting/setup/one-click.mdx +++ b/docs/self-hosting/setup/one-click.mdx @@ -6,7 +6,7 @@ icon: "rocket" This only works with an Ubuntu machine, so ensure the underlying OS is verified beforehand! -If you’re looking to quickly set up a production instance of Formbricks on an Ubuntu server, this guide is for you. Using a convenient shell script, you can install everything—including Docker, Postgres DB, and an SSL certificate—in just a few steps. The script takes care of all the dependencies and configuration for your server, making the process smooth and simple. +If you’re looking to quickly set up a production instance of Formbricks on an Ubuntu server, this guide is for you. Using a convenient shell script, you can install everything—including Docker, Postgres DB, SpiceDB, and an SSL certificate—in just a few steps. The script takes care of all the dependencies and configuration for your server, making the process smooth and simple. This setup uses **Traefik** as a **reverse proxy**, essential for directing incoming traffic to the correct @@ -53,11 +53,13 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker ``` - The current v5 one-click stack is based on the production Docker Compose file and includes Formbricks Hub + The v6 one-click stack is based on the production Docker Compose file and includes Formbricks Hub and Cube as part of the baseline (Cube configuration lives under `formbricks/cube/`). Ensure your generated `formbricks/docker-compose.yml` contains a non-empty `HUB_API_KEY` and that `formbricks/.env` contains - `CUBEJS_API_SECRET` before treating the v5 stack as ready. If either value is missing after the script - finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`. + `CUBEJS_API_SECRET`, `AUTHZED_TOKEN`, and `AUTHZED_DATABASE_PASSWORD` before treating the stack as ready. + The installer generates these values without printing them and restricts `.env` to `0600`. `HUB_API_URL` + should normally stay at `http://hub:8080`. Bundled SpiceDB is installed for every new setup and does not + require an Enterprise license check. @@ -330,7 +332,8 @@ chmod +x formbricks.sh ./formbricks.sh update ``` -The script pulls the latest images, stops the running containers, and starts the stack again. +The script pulls the latest images, runs database migrations, executes the AuthZed v6 preparation and read-only +gate, and only then stops and restarts the running stack. A failed gate leaves the existing application running. Starting with v5.4, the updater also recognizes the old amd64-only bundled Valkey digest. It backs up your Compose file, replaces only that exact image reference with the native amd64/arm64 digest, and validates the @@ -344,7 +347,9 @@ and its AOF data are preserved. your existing deployment, confirm `HUB_API_KEY` is set, and only then run the update command. If your older one-click install also uses bundled MinIO for file uploads, review that storage path separately before the first v5 restart; newer self-hosting updates move the bundled object-storage path to RustFS, while external - S3-compatible storage keeps the same `S3_*` app contract. + S3-compatible storage keeps the same `S3_*` app contract. The v6 updater also refuses to continue until the + Compose file contains the release-matched AuthZed services and `.env` contains + `FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true` after a clean preparation. The same applies to pinned third-party images. Because the update command only re-pulls what your Compose file already names, a bundled `rustfs` service stays on whatever version it was installed with — `docker @@ -354,6 +359,41 @@ and its AOF data are preserved. roll back by restoring the previous `image:` line. +### Add SpiceDB to an Existing One-Click Installation + +The update command intentionally preserves your customized Compose file, so it cannot add SpiceDB by itself. +Before upgrading to a release that uses AuthZed: + +1. Back up `formbricks/.env`, `formbricks/docker-compose.yml`, and the PostgreSQL volume. +2. Download the release-matched `authzed-postgres-bootstrap.sh` into the `formbricks` directory. +3. Generate and add `AUTHZED_TOKEN` and `AUTHZED_DATABASE_PASSWORD` to `.env` using `openssl rand -hex 32`. +4. Merge `authzed-db-bootstrap`, `spicedb-migrate`, `spicedb`, `authzed-initialize`, and the profiled + `authzed-ops` service from the released Compose file while preserving installer-added Traefik, storage, and + mail configuration. Add the six application variables and use `AUTHZED_CONSISTENCY=fully_consistent`. +5. Run `docker compose config`, `docker compose pull`, and `docker compose run --rm formbricks-migrate` from the + `formbricks` directory. The current application can remain running during these backward-compatible database + migrations. +6. Start the datastore services, then prepare and verify the graph: + + ```bash + docker compose up -d postgres authzed-db-bootstrap spicedb-migrate spicedb + docker compose --profile authzed-ops run --rm authzed-ops upgrade prepare + docker compose --profile authzed-ops run --rm authzed-ops upgrade check + ``` + +7. If `upgrade check` exits `0`, add `FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED=true` to `.env` and run + `./formbricks.sh update`. Do not set the acknowledgement to bypass a blocked check. +8. Verify `authzed-db-bootstrap`, `spicedb-migrate`, and `authzed-initialize` completed and `spicedb` is healthy. +9. Back up the authorization database with + `docker compose exec -T postgres pg_dump -U postgres -d spicedb > spicedb-backup.sql` and add it to your + regular backup procedure. + +Do not use `docker compose down -v` during this migration or rollback because the existing PostgreSQL volume +stores both Formbricks and SpiceDB data. + +For upgrades, restoration, schema changes, and relationship repair, follow the canonical +[AuthZed Operations guide](/self-hosting/advanced/authzed-operations). + ## Stop To stop Formbricks, simply run the following command: diff --git a/package.json b/package.json index 7768e4f2975c..c02fadf929cb 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,14 @@ "db:push": "turbo run db:push", "db:seed": "turbo run db:seed", "db:seed:clear": "turbo run db:seed -- -- --clear", - "db:up": "docker compose -f docker-compose.dev.yml up -d", + "db:up": "pnpm dev:setup && docker compose -f docker-compose.dev.yml up -d", "db:down": "docker compose -f docker-compose.dev.yml down", + "authzed:backfill": "dotenv -e .env -v NODE_OPTIONS=--conditions=react-server -v LOG_LEVEL=fatal -- tsx --tsconfig apps/web/tsconfig.json ./apps/web/scripts/authzed-backfill.ts", + "authzed:perf": "dotenv -e .env -v NODE_OPTIONS=--conditions=react-server -v LOG_LEVEL=fatal -- tsx --tsconfig apps/web/tsconfig.json ./apps/web/scripts/authzed-perf.ts", + "authzed:health": "dotenv -e .env -v NODE_OPTIONS=--conditions=react-server -v LOG_LEVEL=fatal -- tsx --tsconfig apps/web/tsconfig.json ./apps/web/scripts/authzed-health.ts", + "authzed:schema": "dotenv -e .env -v NODE_OPTIONS=--conditions=react-server -v LOG_LEVEL=fatal -- tsx --tsconfig apps/web/tsconfig.json ./apps/web/scripts/authzed-schema.ts", + "authzed:smoke": "bash docker/authzed-smoke.sh", + "authzed:validate": "bash authzed/validate.sh", "go": "pnpm db:up && turbo run go --concurrency 20", "dev": "turbo run dev --parallel", "pre-commit": "lint-staged", diff --git a/packages/database/README.md b/packages/database/README.md index 336a597cb8c0..9810423d1fc6 100644 --- a/packages/database/README.md +++ b/packages/database/README.md @@ -181,6 +181,26 @@ By default, the seed script uses `upsert` to ensure it can be run multiple times - Copies the file to Prisma's internal directory - Applies the migration to the database +### Indexes Prisma cannot express + +Prisma's `@@index` has no `where`, so a **partial index** can only live in hand-written migration SQL. When a +model needs one, declare **no** `@@index` for it at all and leave a comment on the model pointing at the +migration — do not also declare an approximate non-partial copy. + +Both ways of keeping one are worse than keeping none. Under the same index name, `prisma db push` sees a name +match with a different definition, drops the index and recreates it without the predicate, and the migration's +`CREATE INDEX IF NOT EXISTS` then skips it — leaving a silently wrong shape. Under a different name, dev +databases accumulate both sets. + +Deployments are unaffected either way: `prisma migrate deploy` never reads the schema file. A developer who runs +`db push` must first inspect `pg_indexes.indexdef`, drop any same-name non-partial replacement, and only then rerun +the migration (or its exact partial-index statements). `CREATE INDEX IF NOT EXISTS` cannot repair a same-name +index with the wrong predicate because PostgreSQL treats the existing name as success. This is the same contract +any hand-written trigger or function in a migration already lives under — make the migration rerunnable, but do +not mistake idempotency for shape validation. + +`AuthzedProjectionOutbox` is the worked example. + ### Adding a Data Migration 1. Navigate to the `packages/database` directory diff --git a/packages/database/migration/20260818120000_add_authzed_projection_outbox/migration.sql b/packages/database/migration/20260818120000_add_authzed_projection_outbox/migration.sql new file mode 100644 index 000000000000..07ce77ff4e9b --- /dev/null +++ b/packages/database/migration/20260818120000_add_authzed_projection_outbox/migration.sql @@ -0,0 +1,242 @@ +-- ENG-2408: authorization source changes and their projection intent must commit atomically. +-- PostgreSQL is the durable queue; BullMQ only wakes a worker that claims rows from this table. + +CREATE TABLE IF NOT EXISTS "AuthzedProjectionOutbox" ( + "id" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "primaryId" TEXT NOT NULL, + "secondaryId" TEXT, + "isRevocation" BOOLEAN NOT NULL DEFAULT false, + "attempts" INTEGER NOT NULL DEFAULT 0, + "permanentFailures" INTEGER NOT NULL DEFAULT 0, + "availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "leasedAt" TIMESTAMP(3), + "leaseExpiresAt" TIMESTAMP(3), + "leaseOwner" TEXT, + "processedAt" TIMESTAMP(3), + "deadLetteredAt" TIMESTAMP(3), + "lastAttemptAt" TIMESTAMP(3), + "lastErrorCode" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AuthzedProjectionOutbox_pkey" PRIMARY KEY ("id") +); + +-- `attempts` drives the retry backoff and tells an operator how many times delivery was tried. +-- `permanentFailures` is the separate, much smaller budget that gates dead-lettering, so that a +-- SpiceDB outage of any duration can never dead-letter healthy events. See outbox-repository.ts. +ALTER TABLE "AuthzedProjectionOutbox" + ADD COLUMN IF NOT EXISTS "permanentFailures" INTEGER NOT NULL DEFAULT 0; + +-- One index per access pattern, each partial to the rows that pattern can ever match. Processed rows +-- are retained for seven days, so only the prune index below is allowed to carry that history — +-- otherwise every hot-path lookup pays for a week of delivered events. +-- +-- These predicates cannot be expressed in Prisma (`@@index` has no `where`), so the model in +-- packages/database/schema/main.prisma deliberately declares no indexes and points here instead. +-- `prisma db push` on a dev database therefore drops them; rerunning this migration restores them, +-- which is the same contract the triggers below already live under. +DROP INDEX IF EXISTS "AuthzedProjectionOutbox_pending_idx"; +DROP INDEX IF EXISTS "AuthzedProjectionOutbox_revocation_idx"; +DROP INDEX IF EXISTS "AuthzedProjectionOutbox_leaseExpiresAt_idx"; +DROP INDEX IF EXISTS "AuthzedProjectionOutbox_target_idx"; +DROP INDEX IF EXISTS "AuthzedProjectionOutbox_dead_letter_idx"; + +-- The claim. Its key columns ARE the claim's ORDER BY, so the LIMIT is served by an ordered index +-- scan with no sort. Also serves the freshness guard's overdue-revocation EXISTS (equality on the +-- leading key, range on the second) and every pending counter in the status query. +CREATE INDEX IF NOT EXISTS "AuthzedProjectionOutbox_claim_idx" + ON "AuthzedProjectionOutbox"("isRevocation" DESC, "createdAt" ASC) + WHERE "processedAt" IS NULL AND "deadLetteredAt" IS NULL; + +-- Everything still undelivered: the freshness guard's dead-letter EXISTS, `outbox replay`, and the +-- status aggregate the delivery job runs every five seconds. +-- +-- The predicate is deliberately the whole undelivered set rather than the dead letters alone. The +-- status aggregate's own WHERE is a bare `processedAt IS NULL`, which does not imply a narrower +-- predicate, so PostgreSQL cannot use a dead-letters-only index for it and would fall back to +-- scanning all seven days of retained history twelve times a minute. Widened to this, the aggregate +-- is an index-only scan and the dead-letter probe still has `isRevocation` as its leading key. +-- +-- A dead-lettered row always has a NULL `processedAt` — the claim skips dead letters, and replay +-- clears `deadLetteredAt` before delivery is possible — so no dead letter is lost here. +CREATE INDEX IF NOT EXISTS "AuthzedProjectionOutbox_undelivered_idx" + ON "AuthzedProjectionOutbox"("isRevocation", "deadLetteredAt", "createdAt") + WHERE "processedAt" IS NULL; + +-- History prune. The only index that carries delivered rows. +CREATE INDEX IF NOT EXISTS "AuthzedProjectionOutbox_processed_idx" + ON "AuthzedProjectionOutbox"("processedAt") + WHERE "processedAt" IS NOT NULL; + +/** + * Does this UPDATE provably leave the projected relationship set a superset of what it was? + * + * `isRevocation` has exactly one reader: the fail-closed freshness guard. So the question it must + * answer is "could an undelivered copy of this event leave SpiceDB granting access that PostgreSQL + * has taken away?" — a property of how the projectors *write*, not of the permission closure in + * authzed/schema.zed. Deriving it from that closure would put a second, untestable copy of the + * schema here; deriving it from the write shape keeps it checkable against the reconcilers. + * + * Deny by default. An unmapped target type, an unmapped column, or any enum move is a revocation. + * In particular the role ladder is deliberately NOT encoded: OrganizationRole is rankable, but a + * rank table here has no compile-time backstop the way relationship-map.ts does, so adding a role + * to schema.zed would silently make this wrong in the fail-OPEN direction. Role changes are + * one-at-a-time admin actions rather than the bulk operations this classifier exists to keep off + * the guard, so denying them costs nothing. Revisit only if a bulk re-roling path appears. + */ +CREATE OR REPLACE FUNCTION authzed_projection_is_grant( + target_type text, + previous_source jsonb, + source jsonb +) RETURNS boolean AS $$ + SELECT CASE target_type + -- reconcileUser deletes every relationship while `isActive` is false, so false -> true can only + -- add them back. `isActive` is the only column the User trigger watches. + WHEN 'user' THEN + (previous_source ->> 'isActive') IS DISTINCT FROM 'true' + AND (source ->> 'isActive') = 'true' + + -- feedback-directory.ts writes assignment edges as `delete` while the directory is archived and + -- `touch` once it is not. Its parent edge is only ever touched, never re-pointed, so an + -- organizationId move leaves the old organization's administrators in place: still a revocation. + WHEN 'feedback_directory' THEN + (previous_source ->> 'isArchived') = 'true' + AND (source ->> 'isArchived') IS DISTINCT FROM 'true' + AND previous_source ->> 'organizationId' IS NOT DISTINCT FROM source ->> 'organizationId' + + -- organization-membership.ts projects every membership row regardless of `accepted` (see the + -- comment on its readSnapshot), so accepting an invite writes byte-identical relationships. + -- A `role` move always deletes the relation for the old role, so it stays a revocation. + WHEN 'membership' THEN + previous_source ->> 'role' IS NOT DISTINCT FROM source ->> 'role' + + ELSE false + END; +$$ LANGUAGE sql IMMUTABLE; + +CREATE OR REPLACE FUNCTION enqueue_authzed_projection() +RETURNS trigger AS $$ +DECLARE + source jsonb; + previous_source jsonb; + is_revocation boolean; + target_type text := TG_ARGV[0]; + primary_field text := TG_ARGV[1]; + secondary_field text := NULLIF(TG_ARGV[2], ''); +BEGIN + source := CASE WHEN TG_OP = 'DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END; + + -- A relationship source can move from one logical pair to another. Reconcile the old pair as a + -- revocation before reconciling the current pair; otherwise the old edge is no longer discoverable + -- from PostgreSQL and could survive with stale access. + IF TG_OP = 'UPDATE' THEN + previous_source := to_jsonb(OLD); + IF previous_source ->> primary_field IS DISTINCT FROM source ->> primary_field + OR ( + secondary_field IS NOT NULL + AND previous_source ->> secondary_field IS DISTINCT FROM source ->> secondary_field + ) + THEN + INSERT INTO "AuthzedProjectionOutbox" ( + "id", + "targetType", + "primaryId", + "secondaryId", + "isRevocation", + "updatedAt" + ) VALUES ( + gen_random_uuid()::text, + target_type, + previous_source ->> primary_field, + CASE WHEN secondary_field IS NULL THEN NULL ELSE previous_source ->> secondary_field END, + true, + NOW() + ); + END IF; + END IF; + + is_revocation := CASE + WHEN TG_OP = 'INSERT' THEN false + WHEN TG_OP = 'DELETE' THEN true + ELSE NOT authzed_projection_is_grant(target_type, previous_source, source) + END; + + INSERT INTO "AuthzedProjectionOutbox" ( + "id", + "targetType", + "primaryId", + "secondaryId", + "isRevocation", + "updatedAt" + ) VALUES ( + gen_random_uuid()::text, + target_type, + source ->> primary_field, + CASE WHEN secondary_field IS NULL THEN NULL ELSE source ->> secondary_field END, + is_revocation, + NOW() + ); + + RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS "authzed_projection_organization" ON "Organization"; +CREATE TRIGGER "authzed_projection_organization" +AFTER INSERT OR DELETE ON "Organization" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('organization', 'id', ''); + +DROP TRIGGER IF EXISTS "authzed_projection_membership" ON "Membership"; +CREATE TRIGGER "authzed_projection_membership" +AFTER INSERT OR DELETE OR UPDATE OF "role", "accepted", "organizationId", "userId" ON "Membership" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('membership', 'organizationId', 'userId'); + +DROP TRIGGER IF EXISTS "authzed_projection_user" ON "User"; +CREATE TRIGGER "authzed_projection_user" +AFTER INSERT OR DELETE OR UPDATE OF "isActive" ON "User" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('user', 'id', ''); + +DROP TRIGGER IF EXISTS "authzed_projection_team" ON "Team"; +CREATE TRIGGER "authzed_projection_team" +AFTER INSERT OR DELETE OR UPDATE OF "organizationId" ON "Team" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('team', 'id', ''); + +DROP TRIGGER IF EXISTS "authzed_projection_team_user" ON "TeamUser"; +CREATE TRIGGER "authzed_projection_team_user" +AFTER INSERT OR DELETE OR UPDATE OF "role", "teamId", "userId" ON "TeamUser" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('team_membership', 'teamId', 'userId'); + +DROP TRIGGER IF EXISTS "authzed_projection_workspace" ON "Workspace"; +CREATE TRIGGER "authzed_projection_workspace" +AFTER INSERT OR DELETE OR UPDATE OF "organizationId" ON "Workspace" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('workspace', 'id', ''); + +DROP TRIGGER IF EXISTS "authzed_projection_workspace_team" ON "WorkspaceTeam"; +CREATE TRIGGER "authzed_projection_workspace_team" +AFTER INSERT OR DELETE OR UPDATE OF "permission", "workspaceId", "teamId" ON "WorkspaceTeam" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('workspace_team', 'workspaceId', 'teamId'); + +DROP TRIGGER IF EXISTS "authzed_projection_api_key" ON "ApiKey"; +CREATE TRIGGER "authzed_projection_api_key" +AFTER INSERT OR DELETE OR UPDATE OF "organizationId", "organizationAccess" ON "ApiKey" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('api_key', 'id', ''); + +DROP TRIGGER IF EXISTS "authzed_projection_api_key_workspace" ON "ApiKeyWorkspace"; +CREATE TRIGGER "authzed_projection_api_key_workspace" +AFTER INSERT OR DELETE OR UPDATE OF "permission", "apiKeyId", "workspaceId" ON "ApiKeyWorkspace" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('api_key_workspace', 'apiKeyId', 'workspaceId'); + +DROP TRIGGER IF EXISTS "authzed_projection_feedback_directory" ON "FeedbackDirectory"; +CREATE TRIGGER "authzed_projection_feedback_directory" +AFTER INSERT OR DELETE OR UPDATE OF "isArchived", "organizationId" ON "FeedbackDirectory" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection('feedback_directory', 'id', ''); + +DROP TRIGGER IF EXISTS "authzed_projection_feedback_directory_workspace" ON "FeedbackDirectoryWorkspace"; +CREATE TRIGGER "authzed_projection_feedback_directory_workspace" +AFTER INSERT OR DELETE OR UPDATE OF "feedbackDirectoryId", "workspaceId" ON "FeedbackDirectoryWorkspace" +FOR EACH ROW EXECUTE FUNCTION enqueue_authzed_projection( + 'feedback_directory_assignment', + 'feedbackDirectoryId', + 'workspaceId' +); diff --git a/packages/database/migration/20260826120000_eng_2612_merge_line_chart_type_into_area/migration.sql b/packages/database/migration/20260826120000_eng_2612_merge_line_chart_type_into_area/migration.sql new file mode 100644 index 000000000000..35a8f335a9aa --- /dev/null +++ b/packages/database/migration/20260826120000_eng_2612_merge_line_chart_type_into_area/migration.sql @@ -0,0 +1,61 @@ +/* + Warnings: + + - The values [line] on the enum `ChartType` will be removed. + + Line and Area rendered the same Recharts area series and differed only in how the band under + the stroke was painted, so they collapse into one `area` type carrying a `config.areaDisplay` + style. Existing `line` charts are backfilled to `area` + `areaDisplay: "line"` first, which is + what lets the enum value be dropped below without data loss. + + `area` charts keep an absent `areaDisplay`: the app defaults it to "filled", so their rendering + is unchanged and no row without a `line` type needs touching. +*/ +-- AlterEnum. The backfill shares the transaction with the swap so a failure in either leaves the +-- column and the enum consistent, rather than rows already rewritten against the old type. +BEGIN; +-- Guarded on the catalog rather than run bare, so this is convergent: on a second run, and on a +-- database created with `db:push`, `ChartType` already lacks `line` and the whole block is a no-op. +-- Without the guard both cases fail — and they fail at the *backfill*, before the enum swap, because +-- `"type" = 'line'` cannot even be parsed once `line` is not a member of the enum +-- (`invalid input value for enum ChartType: "line"`), which reads as data corruption rather than as +-- "already applied". +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' + AND t.typname = 'ChartType' + AND e.enumlabel = 'line' + ) THEN + RETURN; + END IF; + + -- Backfill: every line chart becomes an area chart displayed as a line. + -- + -- The `jsonb_typeof` guard is not paranoia about NULL — `config` is `Json @default("{}")` and + -- NOT NULL. It is about the shape *inside* the column, which Postgres does not constrain: for a + -- non-object `jsonb`, `||` does not fail, it concatenates as an array. A row holding `'null'`, + -- a scalar or an array would silently become `[null, {"areaDisplay": "line"}]` and stop parsing + -- as ZChartConfig. Every row the app writes is an object, so this should match nothing; if one + -- ever does, it gets a valid config carrying the display style instead of a corrupt one, since + -- the value it replaces was already unusable. + UPDATE "public"."Chart" + SET "type" = 'area', + "config" = CASE + WHEN jsonb_typeof("config") = 'object' + THEN "config" || '{"areaDisplay": "line"}'::jsonb + ELSE '{"areaDisplay": "line"}'::jsonb + END + WHERE "type" = 'line'; + + CREATE TYPE "public"."ChartType_new" AS ENUM ('area', 'bar', 'pie', 'big_number'); + ALTER TABLE "public"."Chart" ALTER COLUMN "type" TYPE "public"."ChartType_new" USING ("type"::text::"public"."ChartType_new"); + ALTER TYPE "public"."ChartType" RENAME TO "ChartType_old"; + ALTER TYPE "public"."ChartType_new" RENAME TO "ChartType"; + DROP TYPE "public"."ChartType_old"; +END $$; +COMMIT; diff --git a/packages/database/schema/main.prisma b/packages/database/schema/main.prisma index 92ab6ae0c74c..4599167535cb 100644 --- a/packages/database/schema/main.prisma +++ b/packages/database/schema/main.prisma @@ -1340,7 +1340,6 @@ model WorkspaceTeam { enum ChartType { area bar - line pie big_number } @@ -1609,3 +1608,39 @@ model FeedbackDirectoryWorkspace { @@id([feedbackDirectoryId, workspaceId]) @@index([workspaceId]) } + +/// Durable PostgreSQL queue for rebuilding SpiceDB relationships after an authorization source changes. +/// Source-table triggers insert these rows in the same transaction as the mutation. Identifiers are +/// operationally sensitive and must never be included in logs, metrics, or CLI output. +/// +/// This model declares NO indexes on purpose. All three live in +/// `migration/20260818120000_add_authzed_projection_outbox/migration.sql`, because each one is +/// partial (`WHERE "processedAt" IS NULL AND ...`) and Prisma's `@@index` has no `where`. Declaring +/// approximate non-partial copies here would be worse than declaring none: `prisma db push` would +/// see a name match with a different definition, drop and recreate them without the predicate, and +/// the migration's `CREATE INDEX IF NOT EXISTS` would then skip them — leaving a silently wrong +/// shape that carries a week of delivered rows on every hot-path lookup. Deployments are unaffected +/// either way (`prisma migrate deploy` never reads this file); a dev who runs `db push` restores +/// them by rerunning the migration, which is the same contract its triggers already live under. +model AuthzedProjectionOutbox { + id String @id @default(uuid()) + targetType String + primaryId String + secondaryId String? + isRevocation Boolean @default(false) + /// Total delivery attempts. Drives the retry backoff and reports how hard delivery was tried. + attempts Int @default(0) + /// Non-retryable failures attributable to this event alone. The only input to dead-lettering, so + /// that a SpiceDB outage of any duration cannot dead-letter events that were never the problem. + permanentFailures Int @default(0) + availableAt DateTime @default(now()) + leasedAt DateTime? + leaseExpiresAt DateTime? + leaseOwner String? + processedAt DateTime? + deadLetteredAt DateTime? + lastAttemptAt DateTime? + lastErrorCode String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/packages/database/types/error.ts b/packages/database/types/error.ts index 51887d2a393f..39f5187b73e6 100644 --- a/packages/database/types/error.ts +++ b/packages/database/types/error.ts @@ -7,4 +7,5 @@ export enum PrismaErrorType { ForeignKeyConstraintViolation = "P2003", RelatedRecordNotFound = "P2015", RecordNotFound = "P2025", + TransactionConflict = "P2034", } diff --git a/packages/jobs/src/constants.ts b/packages/jobs/src/constants.ts index 4212e5f589bb..b0671916d6ad 100644 --- a/packages/jobs/src/constants.ts +++ b/packages/jobs/src/constants.ts @@ -4,6 +4,8 @@ export const JOBS_QUEUE_NAME = "background-jobs"; export const JOBS_PREFIX = "{formbricks:jobs}"; export const JOB_NAMES = { + authzedProjectionDelivery: "authzed-projection.deliver", + authzedReconciliationAudit: "authzed-reconciliation.audit", testLog: "system.test-log", responsePipeline: "response-pipeline.process", surveyScheduling: "survey-scheduling.reconcile", diff --git a/packages/jobs/src/processors.test.ts b/packages/jobs/src/processors.test.ts index 050175633736..91b4661e571d 100644 --- a/packages/jobs/src/processors.test.ts +++ b/packages/jobs/src/processors.test.ts @@ -276,9 +276,10 @@ describe("@formbricks/jobs processor registry", () => { ); }); - // One factory backs all three recurring fallbacks, so they are covered together — survey-archive-purge - // and workflow-run.reconcile previously had no test at all. + // One factory backs every recurring fallback, so they are covered together. test.each([ + [JOB_NAMES.authzedProjectionDelivery, "AuthZed projection delivery"], + [JOB_NAMES.authzedReconciliationAudit, "AuthZed reconciliation audit"], [JOB_NAMES.surveyArchivePurge, "survey archive purge"], [JOB_NAMES.surveyScheduling, "survey scheduling"], [JOB_NAMES.workflowRunReconcile, "workflow run reconcile"], diff --git a/packages/jobs/src/queue.test.ts b/packages/jobs/src/queue.test.ts index ee7ce577d7bf..cfa6842dcf1b 100644 --- a/packages/jobs/src/queue.test.ts +++ b/packages/jobs/src/queue.test.ts @@ -340,6 +340,8 @@ describe("@formbricks/jobs queue helpers", () => { // These ids address schedules that already exist in production Redis. Changing one orphans the live // schedule instead of updating it, so they are pinned as literals here rather than derived. test.each([ + ["authzedProjectionDelivery", "authzed-projection.deliver:global:authzed-projection-delivery"], + ["authzedReconciliationAudit", "authzed-reconciliation.audit:global:authzed-reconciliation-audit"], ["surveyArchivePurge", "survey-archive-purge.process:global:daily-survey-archive-purge"], ["surveyScheduling", "survey-scheduling.reconcile:global:daily-survey-scheduling"], ["workflowRunReconcile", "workflow-run.reconcile:global:workflow-run-reconcile"], diff --git a/packages/jobs/src/recurring.ts b/packages/jobs/src/recurring.ts index 0674f029a4f5..18733371baa7 100644 --- a/packages/jobs/src/recurring.ts +++ b/packages/jobs/src/recurring.ts @@ -58,6 +58,16 @@ export const defineRecurringJob = ({ /** Every recurring job in the system. Adding one here wires the registry, the producer and the exports. */ export const recurringJobDescriptors = { + authzedProjectionDelivery: defineRecurringJob({ + label: "AuthZed projection delivery", + name: JOB_NAMES.authzedProjectionDelivery, + scheduleId: "authzed-projection-delivery", + }), + authzedReconciliationAudit: defineRecurringJob({ + label: "AuthZed reconciliation audit", + name: JOB_NAMES.authzedReconciliationAudit, + scheduleId: "authzed-reconciliation-audit", + }), surveyArchivePurge: defineRecurringJob({ label: "survey archive purge", name: JOB_NAMES.surveyArchivePurge, diff --git a/packages/types/analysis.ts b/packages/types/analysis.ts index bf339771f73a..b8b9f70c3487 100644 --- a/packages/types/analysis.ts +++ b/packages/types/analysis.ts @@ -58,6 +58,8 @@ export const ZChartConfig = z.object({ barOrientation: z.enum(["vertical", "horizontal"]).optional(), /** Pie charts only: the classic pie ("pie", default) or one bar split by share ("breakdown"). */ pieDisplay: z.enum(["pie", "breakdown"]).optional(), + /** Area charts only: a solid band under the stroke ("filled", default) or a line with a soft fade ("line"). */ + areaDisplay: z.enum(["filled", "line"]).optional(), colors: z.array(z.string()).optional(), xAxisLabel: z.string().optional(), yAxisLabel: z.string().optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3f9d5a66331..9506194d42b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,6 +347,9 @@ importers: apps/web: dependencies: + '@authzed/authzed-node': + specifier: 1.6.1 + version: 1.6.1 '@better-auth/core': specifier: 1.7.0 version: 1.7.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.4.0(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.3.0) @@ -419,6 +422,9 @@ importers: '@formkit/auto-animate': specifier: 'catalog:' version: 0.9.0 + '@grpc/grpc-js': + specifier: 1.14.4 + version: 1.14.4 '@hookform/resolvers': specifier: 5.2.2 version: 5.2.2(react-hook-form@7.71.2(react@19.2.6)) @@ -458,6 +464,9 @@ importers: '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 + '@opentelemetry/api': + specifier: 1.9.0 + version: 1.9.0 '@opentelemetry/auto-instrumentations-node': specifier: 0.75.0 version: 0.75.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0)) @@ -1736,6 +1745,9 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@authzed/authzed-node@1.6.1': + resolution: {integrity: sha512-Rj3rMtWOjo3igxY/2fpPrIedCTfDq3e+weykuxNBzV/y6azBCoXp8SzpjCEJXVcWyBD8bu/EKY3cye3kOLsKpQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -4343,6 +4355,12 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@protobuf-ts/runtime-rpc@2.11.1': + resolution: {integrity: sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==} + + '@protobuf-ts/runtime@2.11.1': + resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -8733,6 +8751,9 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + google-protobuf@4.0.2: + resolution: {integrity: sha512-yD2fqbNgvJPuQwdKJiPdbUcXveNRxgqy070gzsBsCyFJA8Qdj9oxa9xtkddb/JEhcDk0RD5SfGUWg+nhINfMxA==} + googleapis-common@8.0.1: resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} engines: {node: '>=18.0.0'} @@ -12657,6 +12678,13 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@authzed/authzed-node@1.6.1': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@protobuf-ts/runtime': 2.11.1 + '@protobuf-ts/runtime-rpc': 2.11.1 + google-protobuf: 4.0.2 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -14079,7 +14107,7 @@ snapshots: '@better-auth/utils@0.5.0': dependencies: - '@noble/hashes': 2.0.1 + '@noble/hashes': 2.3.0 '@better-fetch/fetch@1.3.1': {} @@ -16461,6 +16489,12 @@ snapshots: transitivePeerDependencies: - '@types/react-dom' + '@protobuf-ts/runtime-rpc@2.11.1': + dependencies: + '@protobuf-ts/runtime': 2.11.1 + + '@protobuf-ts/runtime@2.11.1': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -21406,6 +21440,8 @@ snapshots: google-logging-utils@1.1.3: {} + google-protobuf@4.0.2: {} + googleapis-common@8.0.1: dependencies: extend: 3.0.2 diff --git a/scripts/setup-dev-env.sh b/scripts/setup-dev-env.sh index 22becaf9c73f..e8dbb0eb3ef8 100755 --- a/scripts/setup-dev-env.sh +++ b/scripts/setup-dev-env.sh @@ -3,9 +3,16 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly ENV_TEMPLATE_PATH="${REPO_ROOT}/.env.example" -readonly ENV_PATH="${REPO_ROOT}/.env" -readonly REQUIRED_GENERATED_KEYS=("ENCRYPTION_KEY" "NEXTAUTH_SECRET" "CRON_SECRET" "CUBEJS_API_SECRET") +readonly ENV_TEMPLATE_PATH="${FORMBRICKS_ENV_TEMPLATE_PATH:-${REPO_ROOT}/.env.example}" +readonly ENV_PATH="${FORMBRICKS_ENV_PATH:-${REPO_ROOT}/.env}" +readonly REQUIRED_GENERATED_KEYS=( + "ENCRYPTION_KEY" + "NEXTAUTH_SECRET" + "CRON_SECRET" + "CUBEJS_API_SECRET" + "AUTHZED_TOKEN" + "AUTHZED_DATABASE_PASSWORD" +) TEMP_FILE="" diff --git a/scripts/setup-dev-env.test.ts b/scripts/setup-dev-env.test.ts new file mode 100644 index 000000000000..d069b661510a --- /dev/null +++ b/scripts/setup-dev-env.test.ts @@ -0,0 +1,71 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, test } from "vitest"; + +const setupDevEnvScriptPath = fileURLToPath(new URL("./setup-dev-env.sh", import.meta.url)); +const tempDirs: string[] = []; + +const createTempDir = (): string => { + const tempDir = mkdtempSync(join(tmpdir(), "formbricks-authzed-dev-")); + tempDirs.push(tempDir); + return tempDir; +}; + +const parseEnvFile = (contents: string): Map => + new Map( + contents + .trim() + .split("\n") + .map((line) => { + const separatorIndex = line.indexOf("="); + return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)]; + }) + ); + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe("scripts/setup-dev-env.sh AuthZed setup", () => { + test("generates and preserves AuthZed secrets", () => { + const tempDir = createTempDir(); + const templatePath = join(tempDir, ".env.example"); + const envPath = join(tempDir, ".env"); + + writeFileSync( + templatePath, + [ + "ENCRYPTION_KEY=", + "NEXTAUTH_SECRET=", + "CRON_SECRET=", + "CUBEJS_API_SECRET=", + "AUTHZED_TOKEN=", + "AUTHZED_DATABASE_PASSWORD=", + "", + ].join("\n") + ); + + const commandEnv = { + ...process.env, + FORMBRICKS_ENV_PATH: envPath, + FORMBRICKS_ENV_TEMPLATE_PATH: templatePath, + }; + + execFileSync("bash", [setupDevEnvScriptPath], { env: commandEnv }); + const firstEnv = parseEnvFile(readFileSync(envPath, "utf8")); + + expect(firstEnv.get("AUTHZED_TOKEN")).toMatch(/^[a-f0-9]{64}$/); + expect(firstEnv.get("AUTHZED_DATABASE_PASSWORD")).toMatch(/^[a-f0-9]{64}$/); + + execFileSync("bash", [setupDevEnvScriptPath], { env: commandEnv }); + const secondEnv = parseEnvFile(readFileSync(envPath, "utf8")); + + expect(secondEnv.get("AUTHZED_TOKEN")).toBe(firstEnv.get("AUTHZED_TOKEN")); + expect(secondEnv.get("AUTHZED_DATABASE_PASSWORD")).toBe(firstEnv.get("AUTHZED_DATABASE_PASSWORD")); + }); +}); diff --git a/scripts/start-authzed-ci.sh b/scripts/start-authzed-ci.sh new file mode 100644 index 000000000000..0802c0143e32 --- /dev/null +++ b/scripts/start-authzed-ci.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly CONTAINER_NAME="formbricks-authzed-ci" +readonly SPICEDB_IMAGE="${SPICEDB_IMAGE_REF:-authzed/spicedb:v1.52.0}" +readonly ZED_IMAGE="${ZED_IMAGE_REF:-authzed/zed:v1.1.1}" + +AUTHZED_TOKEN="${AUTHZED_TOKEN:-}" +if [[ -z "${AUTHZED_TOKEN}" && -f .env ]]; then + AUTHZED_TOKEN="$(sed -n 's/^AUTHZED_TOKEN=//p' .env | tail -n 1)" +fi +if [[ -z "${AUTHZED_TOKEN}" ]]; then + printf '%s\n' "AUTHZED_TOKEN is missing from the environment and repository .env file." >&2 + exit 1 +fi +readonly AUTHZED_TOKEN + +docker rm --force "${CONTAINER_NAME}" >/dev/null 2>&1 || true + +docker run --detach \ + --name "${CONTAINER_NAME}" \ + --network host \ + --memory 512m \ + --env SPICEDB_DATASTORE_ENGINE=memory \ + --env SPICEDB_GRPC_PRESHARED_KEY="${AUTHZED_TOKEN}" \ + --env SPICEDB_LOG_FORMAT=json \ + --env SPICEDB_LOG_LEVEL=info \ + --env SPICEDB_TELEMETRY_ENDPOINT= \ + "${SPICEDB_IMAGE}" \ + serve >/dev/null + +for _ in $(seq 1 30); do + if docker exec "${CONTAINER_NAME}" \ + /usr/local/bin/grpc_health_probe -addr=localhost:50051 >/dev/null 2>&1; then + docker run --rm \ + --network host \ + --entrypoint zed \ + --volume "${PWD}/authzed/schema.zed:/schema.zed:ro" \ + "${ZED_IMAGE}" \ + schema write /schema.zed \ + --endpoint localhost:50051 \ + --token "${AUTHZED_TOKEN}" \ + --insecure \ + --skip-version-check >/dev/null + + printf '%s\n' "AuthZed CI fixture is healthy and the canonical schema is installed." + exit 0 + fi + sleep 2 +done + +printf '%s\n' "AuthZed CI fixture did not become healthy." >&2 +docker logs "${CONTAINER_NAME}" >&2 || true +exit 1 diff --git a/turbo.json b/turbo.json index 5128bee8bda3..7bb806b0f7eb 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "cacheMaxSize": "10GB", "globalDependencies": [ ".nvmrc", + "authzed/schema.zed", "pnpm-lock.yaml", "pnpm-workspace.yaml", "prisma.config.mjs", @@ -244,6 +245,12 @@ ], "outputs": ["dist/**", ".next/**", "!.next/cache/**", "!.next/dev/**"], "passThroughEnv": [ + "AUTHZED_CONSISTENCY", + "AUTHZED_ENABLED", + "AUTHZED_ENDPOINT", + "AUTHZED_INSECURE", + "AUTHZED_SYSTEM_KEY", + "AUTHZED_TOKEN", "DATABASE_URL", "DEBUG", "ENCRYPTION_KEY", @@ -261,6 +268,7 @@ "OTEL_SERVICE_NAME", "OTEL_TRACES_SAMPLER", "OTEL_TRACES_SAMPLER_ARG", + "MCP_OAUTH_JWKS_URL", "PROMETHEUS_ENABLED", "PROMETHEUS_EXPORTER_PORT", "REDIS_URL",