diff --git a/.agents/skills/ask-the-docs/SKILL.md b/.agents/skills/ask-the-docs/SKILL.md index d214049957a..0de0ac2ec2a 100644 --- a/.agents/skills/ask-the-docs/SKILL.md +++ b/.agents/skills/ask-the-docs/SKILL.md @@ -87,7 +87,7 @@ at hand — they cite each other where context matters. | [`reference/llm-agent-parity.md`](./reference/llm-agent-parity.md) | HTML↔markdown fidelity (e.g. AI prompts), search caveat, agent onboarding guides, in-flux wiring. | | [`reference/federated-docs.md`](./reference/federated-docs.md) | How docs pulls markdown from external repos at build time. Routes, `pageMap`, remark/rehype plugins, link transforms, known failure modes. | | [`reference/ci-and-lint.md`](./reference/ci-and-lint.md) | GitHub Actions on every PR — `docs_lint`, `Docs Tests`, typecheck, prettier, Vercel preview gate. Where to add a check before creating a new one. | -| [`reference/management-api-reference.md`](./reference/management-api-reference.md) | Management API OpenAPI download → Redocly bundle → codegen → `ApiEndpointSection`; why not to swap in Scalar/Redoc. | +| [`reference/management-api-reference.md`](./reference/management-api-reference.md) | Management API OpenAPI → reference generation, including scoped PAT permission tables; why not to swap in Scalar/Redoc. | | [`reference/gotchas.md`](./reference/gotchas.md) | Specific traps to watch for. One-liner per item. | ## How to use during a chat diff --git a/.agents/skills/ask-the-docs/reference/ci-and-lint.md b/.agents/skills/ask-the-docs/reference/ci-and-lint.md index fc8c039f48e..19e3705ea56 100644 --- a/.agents/skills/ask-the-docs/reference/ci-and-lint.md +++ b/.agents/skills/ask-the-docs/reference/ci-and-lint.md @@ -14,7 +14,7 @@ PR opened / updated │ ├── docs_lint (MDX/content linting; required) ├── docs_lint_comment_external (posts results as PR comments for external PRs) - ├── Docs Tests (pnpm test:docs on .ts* file changes) + ├── Docs Tests (pnpm test:docs on relevant docs code/spec changes) ├── TypeScript & Lint (tsc + eslint) ├── Prettier (format check) ├── reviewdog (inline annotations) @@ -36,15 +36,16 @@ check before merging.** Backed by `supa-mdx-lint`. ### 2. Docs Tests (`docs-tests.yml`) -Triggered on PRs to master when files matching `apps/docs/**/*.ts*` or -`apps/docs/spec/**/*.json` change. Runs on a Blacksmith 4-vCPU Ubuntu runner -with concurrency controls to cancel stale builds. +Triggered on relevant docs code/spec changes, including the generated scoped +PAT partials and their shared permission catalog. Runs on a Blacksmith 4-vCPU +Ubuntu runner with concurrency controls to cancel stale builds. - Sparse checkout of `apps/docs`, `examples`, `packages`, `supabase`, and `patches`. - Install pnpm (pinned hash). - Set up Node.js from `.nvmrc`. - `pnpm install --frozen-lockfile`. +- Regenerate the scoped PAT partials and fail if the committed output drifts. - Run `pnpm run test:docs` (with dummy GitHub OAuth env vars to prevent local Supabase startup errors). diff --git a/.agents/skills/ask-the-docs/reference/management-api-reference.md b/.agents/skills/ask-the-docs/reference/management-api-reference.md index 98f0b1f009d..f4707a8af61 100644 --- a/.agents/skills/ask-the-docs/reference/management-api-reference.md +++ b/.agents/skills/ask-the-docs/reference/management-api-reference.md @@ -92,6 +92,15 @@ the custom cycle-safe resolve remains required. | `apps/docs/features/docs/Reference.sections.tsx` | `ApiEndpointSection` UI | | `apps/docs/internals/generate-reference-markdown.ts` | agent markdown export | +## Scoped personal access token permission tables + +The "Personal Access Tokens" guide's permission and MCP tables are generated +from the Management API specs, MCP permission map, and the same shared catalog +Studio uses. Regenerate `content/_partials/access-control/scoped_pat_*.mdx` with +`make -C apps/docs/spec generate.partials.access-control`. Docs Tests runs this +on relevant pull requests and fails if the partials drift; the weekly Management +API update also runs it after refreshing the live inputs. + ## Related - [`build-pipeline.md`](./build-pipeline.md) — where `codegen:references` diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5ad8d8d0bf7..144e60df965 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -41,7 +41,7 @@ pnpm api:codegen # platform Management API types → packages/api-ty Every PR must pass typecheck + lint (one workflow), Prettier, and a typos check. Other checks are path-filtered: Studio unit tests/build and the lint ratchet (ESLint warning count must not increase) run on `apps/studio/**` changes; app-specific test suites run on their own paths. -Never hand-edit generated files: `packages/api-types/types/**`, `**/routeTree.gen.ts`, `**/__generated__/**`, `apps/docs/features/docs/generated/**`, `apps/www/.generated/**`, `supabase/functions/common/database-types.ts`. +Never hand-edit generated files: `packages/api-types/types/**`, `**/routeTree.gen.ts`, `**/__generated__/**`, `apps/docs/features/docs/generated/**`, `apps/www/.generated/**`, `supabase/functions/common/database-types.ts`, `apps/docs/content/_partials/access-control/scoped_pat_*.mdx` (run `make -C apps/docs/spec generate.partials.access-control`). ## Conventions diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 79e3919a771..a72095d25c9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,7 @@ /packages/ui/ @supabase/design /packages/shared-data/pricing.ts @supabase/billing /packages/shared-data/plans.ts @supabase/billing +/packages/shared-data/scoped-access-token-permissions.ts @supabase/Dashboard @supabase/docs /packages/common/telemetry-constants.ts @supabase/growth-eng /packages/dev-tools/ @supabase/growth-eng # /packages/pg-meta @supabase/postgres @avallete diff --git a/.github/workflows/docs-mgmt-api-update.yml b/.github/workflows/docs-mgmt-api-update.yml index 6b868b1b37b..c0f3add857f 100644 --- a/.github/workflows/docs-mgmt-api-update.yml +++ b/.github/workflows/docs-mgmt-api-update.yml @@ -3,7 +3,7 @@ name: Update Mgmt Api Docs on: schedule: # Run at 00:00 UTC every Monday - - cron: "0 0 * * 1" + - cron: '0 0 * * 1' workflow_dispatch: permissions: @@ -19,10 +19,13 @@ jobs: with: persist-credentials: false ref: ${{ github.ref }} + # The PAT tables generator imports the shared catalog and its base tsconfig. sparse-checkout: | apps/docs patches packages/generator + packages/shared-data + packages/tsconfig - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 name: Install pnpm @@ -32,15 +35,15 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version-file: ".nvmrc" - cache: "pnpm" + node-version-file: '.nvmrc' + cache: 'pnpm' - name: Install deps run: pnpm install --frozen-lockfile - - name: Change to apps/docs/spec directory and run make command + - name: Refresh Management API docs working-directory: apps/docs/spec - run: make download.api.v1 dereference.api.v1 generate.sections.api.v1 format + run: make download.api.v1 download.mcp-tools-permissions dereference.api.v1 generate.sections.api.v1 generate.partials.access-control format - name: Generate token id: app-token @@ -55,8 +58,18 @@ jobs: uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 with: token: ${{ steps.app-token.outputs.token }} - commit-message: "feat: update mgmt api docs" - title: "feat: update mgmt api docs" - body: "This PR updates mgmt api docs automatically." - branch: "gha/auto-update-mgmt-api-docs" - base: "master" + commit-message: 'feat: update mgmt api docs' + title: 'feat: update mgmt api docs' + body: | + This PR updates Management API docs automatically. + + This regenerates: + + - Management API specs and sections + - Personal Access Tokens permission-to-endpoint table + - Personal Access Tokens MCP tool permissions table + + Sources include the live Management API specs, MCP permission map, + and Studio's shared permission catalog. + branch: 'gha/auto-update-mgmt-api-docs' + base: 'master' diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index 51c6b3a277a..967b2875f01 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -6,8 +6,13 @@ on: paths: - 'apps/docs/**/*.ts*' - 'apps/docs/spec/**/*.json' + - 'apps/docs/spec/Makefile' + - 'apps/docs/spec/sections/generateAccessControlPartials.mts' + - 'apps/docs/content/_partials/access-control/**' - 'apps/docs/.env.development' - 'apps/docs/package.json' + - 'packages/shared-data/package.json' + - 'packages/shared-data/scoped-access-token-permissions.ts' - 'e2e/docs/local-smoke/**' - 'e2e/docs/playwright.local-smoke.config.ts' - 'e2e/docs/package.json' @@ -52,6 +57,13 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + - name: Check access-control partials + run: | + make -C apps/docs/spec generate.partials.access-control + git diff --exit-code -- \ + apps/docs/content/_partials/access-control/scoped_pat_permissions.mdx \ + apps/docs/content/_partials/access-control/scoped_pat_mcp_tools.mdx + - name: Download JS reference TypeDoc dumps # The source dumps under apps/docs/spec/reference///*.json are # gitignored — `make download.tsdoc.v2` re-fetches them from diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index ecfa09baf28..28501353fc0 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2728,6 +2728,11 @@ export const platform: NavMenuConstant = { name: 'Access Control', url: '/guides/platform/access-control' as `/${string}`, }, + { + name: 'Personal Access Tokens', + url: '/guides/platform/personal-access-tokens' as `/${string}`, + enabled: fullPlatformEnabled, + }, { name: 'Multi-factor Authentication', url: '/guides/platform/multi-factor-authentication', diff --git a/apps/docs/content/_partials/access-control/scoped_pat_mcp_tools.mdx b/apps/docs/content/_partials/access-control/scoped_pat_mcp_tools.mdx new file mode 100644 index 00000000000..acc7a113486 --- /dev/null +++ b/apps/docs/content/_partials/access-control/scoped_pat_mcp_tools.mdx @@ -0,0 +1,37 @@ +{/* Generated by `make -C apps/docs/spec generate.partials.access-control`. Do not hand-edit; see supabase/platform#37175 and apps/docs/spec/Makefile. */} + +| MCP tool | Required permission | +| --------------------------- | ----------------------------------------------------------------------------- | +| `apply_migration` | **Migrations** (Read-write) | +| `confirm_cost` | None (always available) | +| `create_branch` | **Development Branches** (Read-write) or **Production Branches** (Read-write) | +| `create_project` | **Organization Projects** (Read-write) | +| `delete_branch` | **Production Branches** (Read-write) or **Development Branches** (Read-write) | +| `deploy_edge_function` | **Edge Functions** (Read-write) | +| `execute_sql` | **Database** (Read) | +| `generate_typescript_types` | **Database** (Read) | +| `get_advisors` | **Advisors** (Read) | +| `get_cost` | **Organization Settings** (Read) and **Projects (account-wide)** (Read) | +| `get_edge_function` | **Edge Functions** (Read) | +| `get_logs` | **Logs** (Read) | +| `get_organization` | **Organization Settings** (Read) | +| `get_project` | **Project Settings** (Read) | +| `get_project_url` | **Project Settings** (Read) | +| `get_publishable_keys` | **API Keys** (Read) | +| `get_storage_config` | **Storage Config** (Read) | +| `list_branches` | **Development Branches** (Read) or **Production Branches** (Read) | +| `list_edge_functions` | **Edge Functions** (Read) | +| `list_extensions` | **Database** (Read) | +| `list_migrations` | **Migrations** (Read) | +| `list_organizations` | **Organizations** (Read) | +| `list_projects` | **Projects (account-wide)** (Read) | +| `list_storage_buckets` | **Storage** (Read) | +| `list_tables` | **Database** (Read) | +| `merge_branch` | **Production Branches** (Read-write) or **Development Branches** (Read-write) | +| `pause_project` | **Project Settings** (Read-write) | +| `query_logs` | **Logs** (Read) | +| `rebase_branch` | **Production Branches** (Read-write) or **Development Branches** (Read-write) | +| `reset_branch` | **Production Branches** (Read-write) or **Development Branches** (Read-write) | +| `restore_project` | **Project Settings** (Read-write) | +| `search_docs` | None (always available) | +| `update_storage_config` | **Storage Config** (Read-write) | diff --git a/apps/docs/content/_partials/access-control/scoped_pat_permissions.mdx b/apps/docs/content/_partials/access-control/scoped_pat_permissions.mdx new file mode 100644 index 00000000000..e674bde3f4e --- /dev/null +++ b/apps/docs/content/_partials/access-control/scoped_pat_permissions.mdx @@ -0,0 +1,236 @@ +{/* Generated by `make -C apps/docs/spec generate.partials.access-control`. Do not hand-edit; see supabase/platform#37175 and apps/docs/spec/Makefile. */} + +| Permission | Access required | Management API endpoint | +| -------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------- | +| **Project** | | | +| Project Settings | Read | [Get JIT access config](/docs/reference/api/v1-get-jit-access-config) | +| | | [Get postgres upgrade eligibility](/docs/reference/api/v1-get-postgres-upgrade-eligibility)[^1] | +| | | [Get postgres upgrade status](/docs/reference/api/v1-get-postgres-upgrade-status)[^1] | +| | | [Get project](/docs/reference/api/v1-get-project) | +| | | [Get services health](/docs/reference/api/v1-get-services-health) | +| | | [List available restore versions](/docs/reference/api/v1-list-available-restore-versions) | +| | | [List private link associations](/docs/reference/api/v2-list-private-link-associations) | +| | | [Preview a project transfer](/docs/reference/api/v2-preview-a-project-transfer) | +| | Read-write | [Cancel a project restoration](/docs/reference/api/v1-cancel-a-project-restoration) | +| | | [Create private link association](/docs/reference/api/v2-create-private-link-association) | +| | | [Delete a project](/docs/reference/api/v1-delete-a-project) | +| | | [Delete private link association](/docs/reference/api/v2-delete-private-link-association) | +| | | [Delete private link association for database](/docs/reference/api/v2-delete-private-link-association-for-database) | +| | | [Get pgsodium config](/docs/reference/api/v1-get-pgsodium-config) | +| | | [OAuth authorize project claim](/docs/reference/api/v1-oauth-authorize-project-claim)[^2] | +| | | [Pause a project](/docs/reference/api/v1-pause-a-project) | +| | | [Restart a project](/docs/reference/api/v1-restart-a-project) | +| | | [Restore a project](/docs/reference/api/v1-restore-a-project) | +| | | [Update a project](/docs/reference/api/v1-update-a-project) | +| | | [Update auth service config](/docs/reference/api/v1-update-auth-service-config)[^3] | +| | | [Update JIT access config](/docs/reference/api/v1-update-jit-access-config) | +| | | [Update pgsodium config](/docs/reference/api/v1-update-pgsodium-config) | +| | | [Upgrade postgres version](/docs/reference/api/v1-upgrade-postgres-version)[^4] | +| Action Runs | Read | [Count action runs](/docs/reference/api/v1-count-action-runs) | +| | | [Get action run](/docs/reference/api/v1-get-action-run) | +| | | [Get action run logs](/docs/reference/api/v1-get-action-run-logs) | +| | | [List action runs](/docs/reference/api/v1-list-action-runs) | +| | Read-write | [Update action run status](/docs/reference/api/v1-update-action-run-status) | +| Advisors | Read | [Get performance advisors](/docs/reference/api/v1-get-performance-advisors) | +| | | [Get security advisors](/docs/reference/api/v1-get-security-advisors) | +| Analytics Config | Read | [List log drains](/docs/reference/api/v2-list-log-drains) | +| | Read-write | [Create log drain](/docs/reference/api/v2-create-log-drain) | +| | | [Delete log drain](/docs/reference/api/v2-delete-log-drain) | +| | | [Update log drain](/docs/reference/api/v2-update-log-drain) | +| Logs | Read | [Get project logs](/docs/reference/api/v1-get-project-logs) | +| | | [Get project logs all](/docs/reference/api/v1-get-project-logs-all) | +| | | [Scrape project metrics](/docs/reference/api/v1-scrape-project-metrics) | +| Usage Analytics | Read | [Get project function combined stats](/docs/reference/api/v1-get-project-function-combined-stats) | +| | | [Get project usage API count](/docs/reference/api/v1-get-project-usage-api-count) | +| | | [Get project usage request count](/docs/reference/api/v1-get-project-usage-request-count) | +| Platform Webhooks | Read | [Get delivery](/docs/reference/api/v2-projects-ref-webhooks-deliveries-id-get) | +| | | [Get endpoint](/docs/reference/api/v2-projects-ref-webhooks-endpoints-id-get) | +| | | [List deliveries](/docs/reference/api/v2-projects-ref-webhooks-endpoints-id-deliveries-get) | +| | | [List endpoints](/docs/reference/api/v2-projects-ref-webhooks-endpoints-get) | +| | Read-write | [Create endpoint](/docs/reference/api/v2-projects-ref-webhooks-endpoints-post) | +| | | [Delete all endpoints](/docs/reference/api/v2-projects-ref-webhooks-endpoints-delete) | +| | | [Delete endpoint](/docs/reference/api/v2-projects-ref-webhooks-endpoints-id-delete) | +| | | [Retry delivery](/docs/reference/api/v2-projects-ref-webhooks-deliveries-id-retry-post) | +| | | [Send test event](/docs/reference/api/v2-projects-ref-webhooks-endpoints-id-test-post) | +| | | [Update endpoint](/docs/reference/api/v2-projects-ref-webhooks-endpoints-id-patch) | +| **Database** | | | +| Backups | Read | [Get backup schedule](/docs/reference/api/v1-get-backup-schedule) | +| | | [List all backups](/docs/reference/api/v1-list-all-backups) | +| | Read-write | [Restore PITR backup](/docs/reference/api/v1-restore-pitr-backup) | +| | | [Update backup schedule](/docs/reference/api/v1-update-backup-schedule) | +| Database | Read | [Generate typescript types](/docs/reference/api/v1-generate-typescript-types) | +| | | [Get database metadata](/docs/reference/api/v1-get-database-metadata) | +| | | [Get database openapi](/docs/reference/api/v1-get-database-openapi) | +| | | [Get postgres upgrade eligibility](/docs/reference/api/v1-get-postgres-upgrade-eligibility)[^1] | +| | | [Get postgres upgrade status](/docs/reference/api/v1-get-postgres-upgrade-status)[^1] | +| | | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | | [Get project PgBouncer config](/docs/reference/api/v1-get-project-pgbouncer-config) | +| | | [Read only query](/docs/reference/api/v1-read-only-query) | +| | | [Run a query](/docs/reference/api/v1-run-a-query) | +| | Read-write | [Create login role](/docs/reference/api/v1-create-login-role) | +| | | [Delete login roles](/docs/reference/api/v1-delete-login-roles) | +| | | [Run a query](/docs/reference/api/v1-run-a-query) | +| | | [Upgrade postgres version](/docs/reference/api/v1-upgrade-postgres-version)[^4] | +| Database Config | Read | [Get postgres config](/docs/reference/api/v1-get-postgres-config) | +| | | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | Read-write | [Update database password](/docs/reference/api/v1-update-database-password) | +| | | [Update postgres config](/docs/reference/api/v1-update-postgres-config) | +| Database JIT | Read | [Authorize JIT access](/docs/reference/api/v1-authorize-jit-access) | +| | | [Get JIT access](/docs/reference/api/v1-get-jit-access) | +| | Read-write | [Delete invite external JIT access](/docs/reference/api/v1-delete-invite-external-jit-access) | +| | | [Delete JIT access](/docs/reference/api/v1-delete-jit-access) | +| | | [Invite external JIT access](/docs/reference/api/v1-invite-external-jit-access) | +| | | [List JIT access](/docs/reference/api/v1-list-jit-access) | +| | | [Update JIT access](/docs/reference/api/v1-update-jit-access) | +| Network Bans | Read | [List all network bans](/docs/reference/api/v1-list-all-network-bans) | +| | | [List all network bans enriched](/docs/reference/api/v1-list-all-network-bans-enriched) | +| | Read-write | [Delete network bans](/docs/reference/api/v1-delete-network-bans) | +| Network Restrictions | Read | [Get network restrictions](/docs/reference/api/v1-get-network-restrictions) | +| | | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | Read-write | [Patch network restrictions](/docs/reference/api/v1-patch-network-restrictions) | +| | | [Update network restrictions](/docs/reference/api/v1-update-network-restrictions) | +| Migrations | Read | [Get a migration](/docs/reference/api/v1-get-a-migration) | +| | | [List migration history](/docs/reference/api/v1-list-migration-history) | +| | Read-write | [Apply a migration](/docs/reference/api/v1-apply-a-migration) | +| | | [Patch a migration](/docs/reference/api/v1-patch-a-migration) | +| | | [Rollback migrations](/docs/reference/api/v1-rollback-migrations) | +| | | [Upsert a migration](/docs/reference/api/v1-upsert-a-migration) | +| Connection Pooling | Read | [Get pooler config](/docs/reference/api/v1-get-pooler-config) | +| | Read-write | [Update pooler config](/docs/reference/api/v1-update-pooler-config) | +| Read-only Mode | Read | [Get read-only mode status](/docs/reference/api/v1-get-readonly-mode-status) | +| | Read-write | [Disable read-only mode temporarily](/docs/reference/api/v1-disable-readonly-mode-temporarily) | +| SSL Enforcement | Read | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | | [Get SSL enforcement config](/docs/reference/api/v1-get-ssl-enforcement-config) | +| | Read-write | [Update SSL enforcement config](/docs/reference/api/v1-update-ssl-enforcement-config) | +| Database Webhooks | Read-write | [Enable database webhook](/docs/reference/api/v1-enable-database-webhook) | +| **Application services** | | | +| API Keys | Read | [Get project API key](/docs/reference/api/v1-get-project-api-key) | +| | | [Get project API keys](/docs/reference/api/v1-get-project-api-keys) | +| | | [Get project legacy API keys](/docs/reference/api/v1-get-project-legacy-api-keys) | +| | Read-write | [Create project API key](/docs/reference/api/v1-create-project-api-key) | +| | | [Delete project API key](/docs/reference/api/v1-delete-project-api-key) | +| | | [Update project API key](/docs/reference/api/v1-update-project-api-key) | +| | | [Update project legacy API keys](/docs/reference/api/v1-update-project-legacy-api-keys) | +| Auth Config | Read | [Get a SSO provider](/docs/reference/api/v1-get-a-sso-provider) | +| | | [Get auth service config](/docs/reference/api/v1-get-auth-service-config) | +| | | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | | [Get project TPA integration](/docs/reference/api/v1-get-project-tpa-integration) | +| | | [List all SSO provider](/docs/reference/api/v1-list-all-sso-provider) | +| | | [List project TPA integrations](/docs/reference/api/v1-list-project-tpa-integrations) | +| | Read-write | [Create a SSO provider](/docs/reference/api/v1-create-a-sso-provider) | +| | | [Create project TPA integration](/docs/reference/api/v1-create-project-tpa-integration) | +| | | [Delete a SSO provider](/docs/reference/api/v1-delete-a-sso-provider) | +| | | [Delete project TPA integration](/docs/reference/api/v1-delete-project-tpa-integration) | +| | | [Update a SSO provider](/docs/reference/api/v1-update-a-sso-provider) | +| | | [Update auth service config](/docs/reference/api/v1-update-auth-service-config)[^3] | +| Auth Signing Keys | Read | [Get legacy signing key](/docs/reference/api/v1-get-legacy-signing-key) | +| | | [Get project signing key](/docs/reference/api/v1-get-project-signing-key) | +| | | [Get project signing keys](/docs/reference/api/v1-get-project-signing-keys) | +| | Read-write | [Create legacy signing key](/docs/reference/api/v1-create-legacy-signing-key) | +| | | [Create project signing key](/docs/reference/api/v1-create-project-signing-key) | +| | | [Remove project signing key](/docs/reference/api/v1-remove-project-signing-key) | +| | | [Update project signing key](/docs/reference/api/v1-update-project-signing-key) | +| Data API Config | Read | [Get PostgREST service config](/docs/reference/api/v1-get-postgrest-service-config) | +| | | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | Read-write | [Update PostgREST service config](/docs/reference/api/v1-update-postgrest-service-config) | +| Edge Functions | Read | [Get a function](/docs/reference/api/v1-get-a-function) | +| | | [Get a function body](/docs/reference/api/v1-get-a-function-body) | +| | | [List all functions](/docs/reference/api/v1-list-all-functions) | +| | Read-write | [Bulk update functions](/docs/reference/api/v1-bulk-update-functions) | +| | | [Create a function](/docs/reference/api/v1-create-a-function) | +| | | [Delete a function](/docs/reference/api/v1-delete-a-function) | +| | | [Deploy a function](/docs/reference/api/v1-deploy-a-function) | +| | | [Update a function](/docs/reference/api/v1-update-a-function) | +| Edge Function Secrets | Read | [List all secrets](/docs/reference/api/v1-list-all-secrets) | +| | Read-write | [Bulk create secrets](/docs/reference/api/v1-bulk-create-secrets) | +| | | [Bulk delete secrets](/docs/reference/api/v1-bulk-delete-secrets) | +| Realtime Config | Read | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | | [Get realtime config](/docs/reference/api/v1-get-realtime-config) | +| | Read-write | [Shutdown realtime](/docs/reference/api/v1-shutdown-realtime) | +| | | [Update realtime config](/docs/reference/api/v1-update-realtime-config) | +| Storage | Read | [List all buckets](/docs/reference/api/v1-list-all-buckets) | +| Storage Config | Read | [Get project config](/docs/reference/api/v2-get-project-config)[^5] | +| | | [Get storage config](/docs/reference/api/v1-get-storage-config) | +| | Read-write | [Update storage config](/docs/reference/api/v1-update-storage-config) | +| **Infrastructure and delivery** | | | +| Development Branches | Read | [Get a branch](/docs/reference/api/v1-get-a-branch) | +| | | [Get a branch config](/docs/reference/api/v1-get-a-branch-config) | +| | | [List all branches](/docs/reference/api/v1-list-all-branches) | +| | Read-write | [Create a branch](/docs/reference/api/v1-create-a-branch) | +| | | [Delete a branch](/docs/reference/api/v1-delete-a-branch) | +| | | [Diff a branch](/docs/reference/api/v1-diff-a-branch) | +| | | [Merge a branch](/docs/reference/api/v1-merge-a-branch) | +| | | [Push a branch](/docs/reference/api/v1-push-a-branch) | +| | | [Reset a branch](/docs/reference/api/v1-reset-a-branch) | +| | | [Restore a branch](/docs/reference/api/v1-restore-a-branch) | +| | | [Update a branch config](/docs/reference/api/v1-update-a-branch-config) | +| Production Branches | Read | [Get a branch](/docs/reference/api/v1-get-a-branch) | +| | | [Get a branch config](/docs/reference/api/v1-get-a-branch-config) | +| | | [List all branches](/docs/reference/api/v1-list-all-branches) | +| | Read-write | [Create a branch](/docs/reference/api/v1-create-a-branch) | +| | | [Delete a branch](/docs/reference/api/v1-delete-a-branch) | +| | | [Diff a branch](/docs/reference/api/v1-diff-a-branch) | +| | | [Disable preview branching](/docs/reference/api/v1-disable-preview-branching) | +| | | [Merge a branch](/docs/reference/api/v1-merge-a-branch) | +| | | [Push a branch](/docs/reference/api/v1-push-a-branch) | +| | | [Reset a branch](/docs/reference/api/v1-reset-a-branch) | +| | | [Restore a branch](/docs/reference/api/v1-restore-a-branch) | +| | | [Update a branch config](/docs/reference/api/v1-update-a-branch-config) | +| Custom Domains | Read | [Get hostname config](/docs/reference/api/v1-get-hostname-config) | +| | Read-write | [Activate custom hostname](/docs/reference/api/v1-activate-custom-hostname) | +| | | Delete hostname config | +| | | [Update hostname config](/docs/reference/api/v1-update-hostname-config) | +| | | [Verify DNS config](/docs/reference/api/v1-verify-dns-config) | +| Add-ons | Read | [List project add-ons](/docs/reference/api/v1-list-project-addons) | +| | Read-write | [Apply project add-on](/docs/reference/api/v1-apply-project-addon) | +| | | [Remove project add-on](/docs/reference/api/v1-remove-project-addon) | +| Disk Config | Read | [Get database disk](/docs/reference/api/v1-get-database-disk) | +| | | [Get disk utilization](/docs/reference/api/v1-get-disk-utilization) | +| | | [Get project disk auto-scaling config](/docs/reference/api/v1-get-project-disk-autoscale-config) | +| | Read-write | [Modify database disk](/docs/reference/api/v1-modify-database-disk) | +| Read Replicas | Read-write | [Remove a read replica](/docs/reference/api/v1-remove-a-read-replica) | +| | | [Setup a read replica](/docs/reference/api/v1-setup-a-read-replica) | +| Vanity Subdomain | Read | [Get vanity subdomain config](/docs/reference/api/v1-get-vanity-subdomain-config) | +| | Read-write | [Activate vanity subdomain config](/docs/reference/api/v1-activate-vanity-subdomain-config) | +| | | [Check vanity subdomain availability](/docs/reference/api/v1-check-vanity-subdomain-availability) | +| | | [Deactivate vanity subdomain config](/docs/reference/api/v1-deactivate-vanity-subdomain-config) | +| **Account and organization** | | | +| Organizations | Read | [List all organizations](/docs/reference/api/v1-list-all-organizations) | +| | Read-write | [Create an organization](/docs/reference/api/v1-create-an-organization) | +| Projects (account-wide) | Read | [List all projects](/docs/reference/api/v1-list-all-projects) | +| SQL Snippets (account-wide) | Read | [Get a snippet](/docs/reference/api/v1-get-a-snippet) | +| | | [List all snippets](/docs/reference/api/v1-list-all-snippets) | +| Organization Settings | Read | [Get an organization](/docs/reference/api/v1-get-an-organization) | +| | | [Get organization entitlements](/docs/reference/api/v1-get-organization-entitlements) | +| | Read-write | [Assign organization member role](/docs/reference/api/v2-assign-organization-member-role) | +| | | [OAuth authorize project claim](/docs/reference/api/v1-oauth-authorize-project-claim)[^2] | +| | | [Transfer a project](/docs/reference/api/v2-transfer-a-project) | +| Organization Members | Read | [List organization members](/docs/reference/api/v1-list-organization-members) | +| | | [List organization members](/docs/reference/api/v2-list-organization-members) | +| | | [List organization roles](/docs/reference/api/v2-list-organization-roles) | +| | Read-write | [Create organization invitations](/docs/reference/api/v2-create-organization-invitations) | +| | | [Delete organization invitations](/docs/reference/api/v2-delete-organization-invitations) | +| Organization Projects | Read | [Get all projects for organization](/docs/reference/api/v1-get-all-projects-for-organization) | +| | | [List organization GitHub connections](/docs/reference/api/v2-list-organization-github-connections) | +| | | [List organization projects](/docs/reference/api/v2-list-organization-projects) | +| | Read-write | [Create a project](/docs/reference/api/v1-create-a-project) | +| Platform Webhooks (organization) | Read | [Get delivery](/docs/reference/api/v2-organizations-slug-webhooks-deliveries-id-get) | +| | | [Get endpoint](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-id-get) | +| | | [List deliveries](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-id-deliveries-get) | +| | | [List endpoints](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-get) | +| | Read-write | [Create endpoint](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-post) | +| | | [Delete all endpoints](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-delete) | +| | | [Delete endpoint](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-id-delete) | +| | | [Retry delivery](/docs/reference/api/v2-organizations-slug-webhooks-deliveries-id-retry-post) | +| | | [Send test event](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-id-test-post) | +| | | [Update endpoint](/docs/reference/api/v2-organizations-slug-webhooks-endpoints-id-patch) | + +[^1]: Requires **Project Settings** (Read) and **Database** (Read). + +[^2]: Requires **Organization Settings** (Read-write) and **Project Settings** (Read-write). + +[^3]: Requires **Auth Config** (Read-write) and **Project Settings** (Read-write). + +[^4]: Requires **Project Settings** (Read-write) and **Database** (Read-write). + +[^5]: Requires **Database Config** (Read), **Database** (Read), **SSL Enforcement** (Read), **Network Restrictions** (Read), **Auth Config** (Read), **Data API Config** (Read), **Realtime Config** (Read), and **Storage Config** (Read). diff --git a/apps/docs/content/guides/platform/personal-access-tokens.mdx b/apps/docs/content/guides/platform/personal-access-tokens.mdx new file mode 100644 index 00000000000..9710b5d77f4 --- /dev/null +++ b/apps/docs/content/guides/platform/personal-access-tokens.mdx @@ -0,0 +1,61 @@ +--- +title: 'Personal Access Tokens' +description: 'Scope personal access tokens to specific organizations, projects, and permissions' +--- + + + +Scoped personal access tokens are in **public alpha** and rolling out gradually. If you don't see the option to choose permissions when creating a token, your account doesn't have access yet. File a [support ticket](https://supabase.help) to get early access. + + + +Personal access tokens (PATs) authenticate you to the [Management API](/docs/reference/api/introduction) and the tools built on it, like the Supabase CLI and the [MCP server](/docs/guides/ai-tools/mcp). They come in two flavors: + +- **Classic tokens** carry your account's full access. That means every permission, on every organization and every project you belong to today, and on every one you create or join in the future. A classic token created a year ago can touch a project you created today. +- **Scoped tokens** carry only the organizations, projects, and permissions you choose. For example: read one project's database and view its logs, with no access to billing or organization settings. + + + +We recommend scoped tokens for everything, especially AI agents, automation scripts, and CI environments. If a token leaks, the blast radius stays small. + + + +To use scoped personal access tokens you need a Supabase account with a role on the organization or project you want the token to reach. A scoped personal access token's permissions only ever narrow what your account can already do. They never grant more. If your role doesn't include a permission (see [Access Control](/docs/guides/platform/access-control)), granting that permission to a token has no effect: the token still can't do it. + +You create scoped tokens the same way as classic tokens, from your [access tokens](/dashboard/account/tokens) settings. Choose which permissions to grant during creation instead of leaving the token with full access. + +## Create and use a scoped personal access token + +This example creates a token that can only read one project's settings, then calls an endpoint outside that scope. Both calls are reads available to every organization role, including Read-Only, so anyone can run it. + +1. Go to your [access tokens](/dashboard/account/tokens) settings and generate a new token. +2. While creating it, scope the token to one project and grant only the **Project Settings** permission with **Read** access. +3. Copy the token (scoped personal access tokens start with `sbp_fc`) and use it against the Management API, replacing `your-project-ref` with the project's ref: + +```bash +export SUPABASE_ACCESS_TOKEN="sbp_fc..." + +# Allowed: Project Settings Read unlocks this endpoint +curl "https://api.supabase.com/v1/projects/your-project-ref" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" +# Returns the project's details + +# Denied: this endpoint needs the Database permission, which the token lacks +curl -i "https://api.supabase.com/v1/projects/your-project-ref/types/typescript" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" +# Returns HTTP 403 +``` + +The tables below list which permission unlocks which endpoints, and which permission each [MCP tool](#mcp-tools) requires, so you can grant exactly what a workflow needs. + +## Permission scopes + +Each permission controls read or read-write access to one resource, for example **Database**, **Edge Functions**, or **Production Branches**. The permissions and names below match those shown when creating a scoped personal access token. Permissions that currently unlock neither a public Management API endpoint nor an MCP tool are omitted. A few endpoints need more than one permission. The footnotes call these out. + +<$Partial path="access-control/scoped_pat_permissions.mdx" /> + +## MCP tools + +A scoped personal access token used to authenticate the [MCP server](/docs/guides/ai-tools/mcp) can only call the tools its granted permissions unlock. See [Available tools](/docs/guides/ai-tools/mcp#available-tools) for what each tool does. + +<$Partial path="access-control/scoped_pat_mcp_tools.mdx" /> diff --git a/apps/docs/spec/Makefile b/apps/docs/spec/Makefile index 75515103045..2110c1d0b4b 100644 --- a/apps/docs/spec/Makefile +++ b/apps/docs/spec/Makefile @@ -1,7 +1,7 @@ REPO_DIR=$(shell pwd) GENERATOR_DIR=../../../packages/generator -.PHONY: run download download.api.v1 download.storage.v1 download.tsdoc.v2 download.server.v1 transform dereference.api.v1 dereference.auth.v1 dereference.storage.v0 generate generate.sections.api.v1 format +.PHONY: run download download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 transform dereference.api.v1 dereference.auth.v1 dereference.storage.v0 generate generate.sections.api.v1 generate.partials.access-control format run: download transform generate format @@ -11,12 +11,17 @@ run: download transform generate format ############################################################################### # comment out download.auth.v1 temporarily, we're manually creating the file # download: download.api.v1 download.auth.v1 download.storage.v1 download.tsdoc.v2 -download: download.api.v1 download.storage.v1 download.tsdoc.v2 download.server.v1 +download: download.api.v1 download.mcp-tools-permissions download.storage.v1 download.tsdoc.v2 download.server.v1 download.api.v1: curl -sS https://api.supabase.com/api/v1-json > $(REPO_DIR)/api_v1_openapi.json curl -sS https://api.supabase.com/api/v2-json > $(REPO_DIR)/api_v2_openapi.json +# Download tool permissions from the public Management API projection of its enforcement rules. +download.mcp-tools-permissions: + curl -sSf https://api.supabase.com/platform/mcp-tools-permissions > $(REPO_DIR)/mcp_tools_permissions.json + npx prettier --write $(REPO_DIR)/mcp_tools_permissions.json + # This flow needs to be updated, so we'l comment out for the moment # Manual flow for now: # — get swagger.json (https://supabase.github.io/gotrue/swagger.json) @@ -81,7 +86,7 @@ dereference.analytics.v0: ############################################################################### # Generate sections from OpenAPI 3.0 ############################################################################### -generate: generate.sections.api.v1 +generate: generate.sections.api.v1 generate.partials.access-control generate.sections.api.v1: npx tsx $(REPO_DIR)/sections/generateMgmtApiSections.cts \ @@ -89,6 +94,16 @@ generate.sections.api.v1: $(REPO_DIR)/transforms/api_v2_openapi_deparsed.json \ $(REPO_DIR)/common-api-sections.json +# Generate PAT guide tables from Management API inputs and Studio's shared permission catalog. +generate.partials.access-control: + npx tsx $(REPO_DIR)/sections/generateAccessControlPartials.mts \ + $(REPO_DIR)/transforms/api_v1_openapi_deparsed.json \ + $(REPO_DIR)/transforms/api_v2_openapi_deparsed.json \ + $(REPO_DIR)/mcp_tools_permissions.json \ + $(REPO_DIR)/../content/_partials/access-control/scoped_pat_permissions.mdx \ + $(REPO_DIR)/../content/_partials/access-control/scoped_pat_mcp_tools.mdx + npx prettier --write $(REPO_DIR)/../content/_partials/access-control/*.mdx + ############################################################################### # Validate OpenAPI 3.0 ############################################################################### diff --git a/apps/docs/spec/mcp_tools_permissions.json b/apps/docs/spec/mcp_tools_permissions.json new file mode 100644 index 00000000000..82b8c4dfef9 --- /dev/null +++ b/apps/docs/spec/mcp_tools_permissions.json @@ -0,0 +1,35 @@ +{ + "list_organizations": [["organizations_read"]], + "get_organization": [["organization_admin_read"]], + "list_projects": [["projects_read"]], + "get_project": [["project_admin_read"]], + "create_project": [["organization_projects_create"]], + "pause_project": [["project_admin_write"]], + "restore_project": [["project_admin_write"]], + "list_branches": [["branching_development_read"], ["branching_production_read"]], + "create_branch": [["branching_development_create"], ["branching_production_create"]], + "delete_branch": [["branching_production_delete"], ["branching_development_delete"]], + "merge_branch": [["branching_production_write"], ["branching_development_write"]], + "reset_branch": [["branching_production_write"], ["branching_development_write"]], + "rebase_branch": [["branching_production_write"], ["branching_development_write"]], + "execute_sql": [["database_read"]], + "list_tables": [["database_read"]], + "list_extensions": [["database_read"]], + "list_migrations": [["database_migrations_read"]], + "apply_migration": [["database_migrations_write"]], + "get_logs": [["analytics_logs_read"]], + "query_logs": [["analytics_logs_read"]], + "get_advisors": [["advisors_read"]], + "generate_typescript_types": [["database_read"]], + "get_project_url": [["project_admin_read"]], + "get_publishable_keys": [["api_gateway_keys_read"]], + "list_edge_functions": [["edge_functions_read"]], + "get_edge_function": [["edge_functions_read"]], + "deploy_edge_function": [["edge_functions_write"]], + "get_storage_config": [["storage_config_read"]], + "update_storage_config": [["storage_config_write"]], + "list_storage_buckets": [["storage_read"]], + "get_cost": [["organization_admin_read", "projects_read"]], + "confirm_cost": [[]], + "search_docs": [[]] +} diff --git a/apps/docs/spec/sections/generateAccessControlPartials.mts b/apps/docs/spec/sections/generateAccessControlPartials.mts new file mode 100644 index 00000000000..9c706b7a945 --- /dev/null +++ b/apps/docs/spec/sections/generateAccessControlPartials.mts @@ -0,0 +1,334 @@ +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const { PERMISSION_CATALOG_BY_CATEGORY, PERMISSION_MODE_LABEL } = + require('shared-data/scoped-access-token-permissions') as typeof import('shared-data/scoped-access-token-permissions') + +type ScopeGroupAlternatives = string[][] +type McpMap = Record + +type Operation = { + operationId?: string + summary?: string + 'x-fga-permissions'?: ScopeGroupAlternatives + 'x-internal'?: boolean +} + +type Endpoint = { + operationId: string + label: string + groups: ScopeGroupAlternatives +} + +type PermissionRow = { + resource: string + access: string + category: string + scopes: string[] +} + +const GENERATED_NOTICE = + '{/* Generated by `make -C apps/docs/spec generate.partials.access-control`. Do not hand-edit; see supabase/platform#37175 and apps/docs/spec/Makefile. */}\n' + +const WORD_FIXES: Record = { + api: 'API', + sso: 'SSO', + tpa: 'TPA', + pitr: 'PITR', + dns: 'DNS', + jit: 'JIT', + ssl: 'SSL', + oauth: 'OAuth', + github: 'GitHub', + postgrest: 'PostgREST', + pgbouncer: 'PgBouncer', + readonly: 'read-only', + addon: 'add-on', + addons: 'add-ons', + autoscale: 'auto-scaling', +} + +const readJson = (filePath: string) => JSON.parse(fs.readFileSync(filePath, 'utf8')) + +function endpointLabel(operationId: string) { + const words = operationId + .replace(/^v\d+-?/, '') + .split('-') + .filter(Boolean) + .map((word) => WORD_FIXES[word] ?? word) + .join(' ') + return words.charAt(0).toUpperCase() + words.slice(1) +} + +const permissionRows: PermissionRow[] = PERMISSION_CATALOG_BY_CATEGORY.flatMap((category) => + category.entries.flatMap((entry) => [ + ...(entry.readScopes.length > 0 + ? [ + { + resource: entry.name, + access: PERMISSION_MODE_LABEL.read, + category: category.name, + scopes: entry.readScopes, + }, + ] + : []), + ...(entry.writeScopes.length > 0 + ? [ + { + resource: entry.name, + access: PERMISSION_MODE_LABEL.readwrite, + category: category.name, + scopes: entry.writeScopes, + }, + ] + : []), + ]) +) + +const rowByScope = new Map(permissionRows.flatMap((row) => row.scopes.map((scope) => [scope, row]))) + +// Workers permissions are present in the API spec but are not live for scoped PATs yet. +const EXCLUDED_SCOPES = new Set(['workers_read', 'workers_write']) + +// The public v2 webhook operations currently omit x-fga-permissions from the OpenAPI projection. +// Keep this fallback narrow so the generated table can still link those endpoints, and fail below +// if any other public operation has not been classified for the scoped-PAT table. +const WEBHOOK_PERMISSION_SCOPES = [ + { + routePrefix: '/v2/projects/{ref}/webhooks/', + read: 'platform_webhooks_projects_read', + write: 'platform_webhooks_projects_write', + }, + { + routePrefix: '/v2/organizations/{slug}/webhooks/', + read: 'platform_webhooks_organization_read', + write: 'platform_webhooks_organization_write', + }, +] + +// These public operations sit outside the scoped-PAT permission table. +const OPERATIONS_OUTSIDE_SCOPED_PAT_TABLE = new Set([ + 'v1-accept-invite-external-jit-access', + 'v1-authorize-user', + 'v1-exchange-oauth-token', + 'v1-get-available-regions', + 'v1-get-profile', + 'v1-revoke-token', +]) + +function webhookPermissionGroups( + route: string, + method: string +): ScopeGroupAlternatives | undefined { + const scopes = WEBHOOK_PERMISSION_SCOPES.find(({ routePrefix }) => route.startsWith(routePrefix)) + if (!scopes) return undefined + + const access = ['get', 'head'].includes(method) + ? 'read' + : ['post', 'put', 'patch', 'delete'].includes(method) + ? 'write' + : undefined + if (!access) return undefined + return [[scopes[access]]] +} + +function knownGroups(groups: ScopeGroupAlternatives, missing: Set) { + return groups.filter((group) => { + if (group.some((scope) => EXCLUDED_SCOPES.has(scope))) return false + const unknown = group.filter((scope) => !rowByScope.has(scope)) + unknown.forEach((scope) => missing.add(scope)) + return unknown.length === 0 + }) +} + +function joinList(items: string[]) { + if (items.length < 2) return items[0] ?? '' + if (items.length === 2) return `${items[0]} and ${items[1]}` + return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}` +} + +function formatRequirement(groups: ScopeGroupAlternatives) { + const alternatives = Array.from( + new Set( + groups.map((group) => + joinList( + Array.from( + new Set( + group.map((scope) => { + const row = rowByScope.get(scope)! + return `**${row.resource}** (${row.access})` + }) + ) + ) + ) + ) + ) + ) + return alternatives.join(alternatives.some((item) => item.includes(' and ')) ? ', or ' : ' or ') +} + +function missingScopesNotice(missing: Set) { + if (missing.size === 0) return [] + return [ + '', + `{/* Not documented, missing from the shared permission catalog ` + + `(packages/shared-data/scoped-access-token-permissions.ts): ${[...missing].sort().join(', ')} */}`, + ] +} + +function collectEndpoints(specPaths: string[]) { + const endpoints = new Map() + const unclassifiedOperations: string[] = [] + + for (const specPath of specPaths) { + const spec = readJson(specPath) + for (const [route, methods] of Object.entries>(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods)) { + if (!operation?.operationId || operation['x-internal']) continue + + const key = `${method.toUpperCase()} ${route}` + const fallbackGroups = webhookPermissionGroups(route, method) + const groups = operation['x-fga-permissions'] ?? fallbackGroups ?? [] + if (groups.length === 0) { + if (!OPERATIONS_OUTSIDE_SCOPED_PAT_TABLE.has(operation.operationId)) { + unclassifiedOperations.push(`${key} (${operation.operationId})`) + } + continue + } + + endpoints.set(key, { + operationId: operation.operationId, + label: fallbackGroups + ? (operation.summary ?? endpointLabel(operation.operationId)) + : endpointLabel(operation.operationId), + groups, + }) + } + } + } + + if (unclassifiedOperations.length > 0) { + throw new Error( + `Public Management API operations are not classified for the scoped-PAT table:\n${unclassifiedOperations.join('\n')}` + ) + } + + return [...endpoints.values()] +} + +function generatePermissionsPartial(specPaths: string[], tools: McpMap, outputPath: string) { + const missing = new Set() + const endpoints = collectEndpoints(specPaths).map((endpoint) => ({ + ...endpoint, + groups: knownGroups(endpoint.groups, missing), + })) + const mcpToolScopes = new Set( + Object.values(tools).flatMap((groups) => knownGroups(groups, missing).flat()) + ) + const footnotes = new Map() + const lines = [ + GENERATED_NOTICE, + '| Permission | Access required | Management API endpoint |', + '| ---------- | --------------- | ----------------------- |', + ] + let previousCategory = '' + let previousResource = '' + + for (const row of permissionRows) { + const rowScopes = new Set(row.scopes) + const rowEndpoints = endpoints + .filter((endpoint) => + endpoint.groups.some((group) => group.some((scope) => rowScopes.has(scope))) + ) + .sort((a, b) => a.label.localeCompare(b.label) || a.operationId.localeCompare(b.operationId)) + + if (rowEndpoints.length === 0 && !row.scopes.some((scope) => mcpToolScopes.has(scope))) continue + + if (row.category !== previousCategory) { + lines.push(`| **${row.category}** | | |`) + previousCategory = row.category + previousResource = '' + } + + const permissionCell = row.resource === previousResource ? '' : row.resource + previousResource = row.resource + + if (rowEndpoints.length === 0) { + lines.push(`| ${permissionCell} | ${row.access} | No public Management API endpoints |`) + continue + } + + rowEndpoints.forEach((endpoint, index) => { + const label = endpoint.label.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\s+/g, ' ') + const link = /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(endpoint.operationId) + ? `[${label}](/docs/reference/api/${endpoint.operationId})` + : label + const unlocksAlone = endpoint.groups.some((group) => + group.every((scope) => rowScopes.has(scope)) + ) + let requirement = '' + if (!unlocksAlone) { + const text = `Requires ${formatRequirement(endpoint.groups)}.` + const id = footnotes.get(text) ?? String(footnotes.size + 1) + footnotes.set(text, id) + requirement = `[^${id}]` + } + lines.push( + `| ${index === 0 ? permissionCell : ''} | ${index === 0 ? row.access : ''} | ${link}${requirement} |` + ) + }) + } + + const definitions = Array.from(footnotes, ([text, id]) => `[^${id}]: ${text}`) + writeOutput(outputPath, [ + ...lines, + ...(definitions.length > 0 ? ['', ...definitions] : []), + ...missingScopesNotice(missing), + '', + ]) +} + +function generateMcpToolsPartial(tools: McpMap, outputPath: string) { + const missing = new Set() + const rows = Object.entries(tools) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([tool, groups]) => { + const publishable = knownGroups(groups, missing) + const requirement = publishable.some((group) => group.length === 0) + ? 'None (always available)' + : publishable.length === 0 + ? 'Not available to scoped personal access tokens' + : formatRequirement(publishable) + return `| \`${tool}\` | ${requirement} |` + }) + + writeOutput(outputPath, [ + GENERATED_NOTICE, + '| MCP tool | Required permission |', + '| -------- | ------------------- |', + ...rows, + ...missingScopesNotice(missing), + '', + ]) +} + +function writeOutput(outputPath: string, lines: string[]) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.writeFileSync(outputPath, lines.join('\n'), 'utf8') + console.log(`Wrote ${outputPath}`) +} + +const args = process.argv.slice(2).map((arg) => path.resolve(arg)) +if (args.length !== 5) { + console.error( + 'Usage: generateAccessControlPartials.mts ' + + ' ' + ) + process.exit(1) +} + +const tools: McpMap = readJson(args[2]) +generatePermissionsPartial(args.slice(0, 2), tools, args[3]) +generateMcpToolsPartial(tools, args[4]) diff --git a/apps/studio/CLAUDE.md b/apps/studio/CLAUDE.md index f1f084c3192..3e058c3b557 100644 --- a/apps/studio/CLAUDE.md +++ b/apps/studio/CLAUDE.md @@ -37,6 +37,7 @@ Studio is migrating from the Next.js pages router (`pages/**`) to TanStack Start - **Telemetry** — `useTrack()` from `lib/telemetry/track`; event types live in `packages/common/telemetry-constants.ts`. - **Tests** — default to including relevant tests with any change: a couple of unit tests for extracted logic, component tests for UI behavior, E2E only when the scope demands it (`studio-testing` has the decision tree). Not every PR needs them, but "no tests" should be a considered choice, not the default. Tooling: vitest + MSW; component tests use `customRender` + `addAPIMock` from `tests/lib/`; unhandled network requests fail tests. Don't `vi.mock('@/data/...')`. - **Shortcuts** — use the registry in `state/shortcuts/` and `components/ui/Shortcut*.tsx`; keep `G then …` chords for navigation; no one-off keyboard listeners. +- **Scoped PAT catalog** — `packages/shared-data/scoped-access-token-permissions.ts` feeds Studio and the generated Personal Access Tokens guide. After changing it, run `make -C apps/docs/spec generate.partials.access-control`; Docs Tests rejects stale tables. - **Reuse first** — before writing a new hook or helper, search for an existing one (`hooks/`, `lib/`, `packages/common`, `packages/ui-patterns`). If you do need a new one, make it as reusable as possible: general naming, no page-specific coupling, placed where other callers can find it. - Co-locate sub-components with their parent; avoid barrel re-export files. diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts index c3f765e8bb3..cc5bdee8a80 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts @@ -1,5 +1,11 @@ import { permissions } from '@supabase/shared-types' import { components } from 'api-types' +import { + getAction, + getResource, + PERMISSION_CATALOG, + type FgaAction, +} from 'shared-data/scoped-access-token-permissions' export type ScopedAccessTokenPermission = components['schemas']['CreateScopedAccessTokenBody']['permissions'][number] @@ -26,25 +32,12 @@ export const EXPIRES_AT_OPTIONS = { const FGA = permissions.FgaPermissions -const getAction = (key: string): string => { - if (key.endsWith('_READ')) return 'read' - if (key.endsWith('_WRITE')) return 'write' - if (key.endsWith('_CREATE')) return 'create' - if (key.endsWith('_DELETE')) return 'delete' - return 'read' -} - -const getResource = (key: string): string => { - return key.replace(/_(READ|WRITE|CREATE|DELETE)$/, '').toLowerCase() -} - const buildPermissionList = () => { const list: Array<{ scope: string resource: string - action: string + action: FgaAction id: string - title: string }> = [] for (const [scope, scopePerms] of Object.entries(FGA)) { @@ -54,7 +47,6 @@ const buildPermissionList = () => { resource: getResource(key), action: getAction(key), id: perm.id, - title: perm.title, }) } } @@ -64,20 +56,12 @@ const buildPermissionList = () => { export const PERMISSION_LIST = buildPermissionList() -export const ACCESS_TOKEN_RESOURCES = (() => { - const resourceMap = new Map() - - for (const p of PERMISSION_LIST) { - const key = `${p.scope}:${p.resource}` - if (!resourceMap.has(key)) { - const cleanTitle = p.title.replace(/^(Read|Manage|Create|Delete)\s+/i, '') - resourceMap.set(key, { resource: key, title: cleanTitle, actions: [] }) - } - const entry = resourceMap.get(key)! - if (!entry.actions.includes(p.action)) { - entry.actions.push(p.action) - } - } - - return Array.from(resourceMap.values()) -})() +/** + * Resources shown in token permission summaries (e.g. the post-creation banner). + * Titles come from the shared permission catalog so they match the creation form + * and the generated docs tables. + */ +export const ACCESS_TOKEN_RESOURCES = PERMISSION_CATALOG.map((entry) => ({ + resource: entry.key, + title: entry.name, +})) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts index 405b96e3d61..33caa07b954 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts @@ -1,563 +1,32 @@ -import { permissions } from '@supabase/shared-types' +import { + getCatalogEntry, + PERMISSION_CATALOG, + PERMISSION_CATALOG_BY_CATEGORY, + PERMISSION_MODE_LABEL, + type PermissionCatalogEntry, + type PermissionCategoryKey, + type PermissionMode, + type RiskLevel, +} from 'shared-data/scoped-access-token-permissions' import type { ScopedAccessTokenPermission } from './AccessToken.constants' +export { + getCatalogEntry, + PERMISSION_CATALOG, + PERMISSION_CATALOG_BY_CATEGORY, + PERMISSION_MODE_LABEL, +} +export type { PermissionCatalogEntry, PermissionMode, RiskLevel } + /** - * Data model for the scoped access-token creation flow. + * Selection model for the scoped personal access token creation flow: none/read/readwrite modes per + * catalog entry, and the conversions between a selection and concrete FGA scope ids. * - * The real permission scopes come from `@supabase/shared-types` (`FgaPermissions`). Those scopes - * carry no category or risk metadata, so this file layers editable presentation data on top: - * - PERMISSION_CATEGORIES groups every scope into one of five UI categories. - * - RESOURCE_METADATA assigns each resource a display name, description, category and risk. - * - * TODO(product): the risk levels, reasons and "Allows" copy below are proposed defaults — review - * and adjust. Where a resource has no explicit metadata entry we fall back to a heuristic. + * The catalog itself (resources, categories, risk metadata, scopes) lives in + * shared-data/scoped-access-token-permissions.ts so the docs generator can consume it too. */ -const FGA = permissions.FgaPermissions - -export type PermissionMode = 'none' | 'read' | 'readwrite' -export type RiskLevel = 'low' | 'medium' | 'high' -export type PermissionCategoryKey = 'account' | 'project' | 'database' | 'appsvc' | 'infra' - -export interface PermissionCategory { - key: PermissionCategoryKey - name: string - description: string -} - -/** Display order matches the accordion, where every category starts collapsed. */ -export const PERMISSION_CATEGORIES: PermissionCategory[] = [ - { - key: 'project', - name: 'Project', - description: 'Core project visibility, settings, and diagnostics.', - }, - { - key: 'database', - name: 'Database', - description: 'SQL access, migrations, backups, and data operations.', - }, - { - key: 'appsvc', - name: 'Application Services', - description: 'Auth, storage, realtime, edge functions, and service configuration.', - }, - { - key: 'infra', - name: 'Infrastructure & Delivery', - description: 'Branch automation, domains, add-ons, and network.', - }, - { - key: 'account', - name: 'Account & Organization', - description: 'Account-wide and organization-level access that spans projects.', - }, -] - -interface ResourceMeta { - category: PermissionCategoryKey - name: string - description: string - risk: RiskLevel - riskReason: string - allowsRead?: string[] - allowsWrite?: string[] -} - -/** - * Per-resource presentation metadata, keyed by the derived `scope:resource` key (see - * AccessToken.constants → ACCESS_TOKEN_RESOURCES). Every resource returned from FgaPermissions - * should have an entry; RESOURCE_METADATA_FALLBACK covers anything that slips through. - */ -const RESOURCE_METADATA: Record = { - // --- Account & Organization --- - 'user:organizations': { - category: 'account', - name: 'Organizations', - description: 'Organizations you belong to.', - risk: 'medium', - riskReason: 'Read-write can create new organizations under your account.', - allowsRead: ['List your organizations'], - allowsWrite: ['Create organizations'], - }, - 'user:projects': { - category: 'account', - name: 'Projects (account-wide)', - description: 'Projects across all your organizations.', - risk: 'low', - riskReason: 'Read-only listing of the projects you can access.', - allowsRead: ['List your projects'], - }, - 'user:snippets': { - category: 'account', - name: 'SQL Snippets (account-wide)', - description: 'Saved SQL snippets across your account.', - risk: 'low', - riskReason: 'Read-only access to your saved snippets.', - allowsRead: ['Read your SQL snippets'], - }, - 'organization:admin': { - category: 'account', - name: 'Organization Settings', - description: 'Organization settings and project transfers.', - risk: 'high', - riskReason: 'Read-write grants elevated access to organization settings and project transfers.', - allowsRead: ['Read organization settings'], - allowsWrite: ['Manage organization settings', 'Transfer projects'], - }, - 'organization:members': { - category: 'account', - name: 'Organization Members', - description: 'Members and roles within the organization.', - risk: 'high', - riskReason: 'Read-write can add or remove members and change roles across your organization.', - allowsRead: ['Read organization members'], - allowsWrite: ['Add or remove members', 'Change member roles'], - }, - 'organization:projects': { - category: 'account', - name: 'Organization Projects', - description: 'Projects within the organization.', - risk: 'medium', - riskReason: 'Read-write can create new projects in the organization.', - allowsRead: ['List organization projects'], - allowsWrite: ['Create organization projects'], - }, - - // --- Project --- - 'project:admin': { - category: 'project', - name: 'Project Settings', - description: 'Project metadata and settings.', - risk: 'high', - riskReason: 'Read-write grants elevated access to change project settings and configuration.', - allowsRead: ['Read project metadata'], - allowsWrite: ['Update project settings'], - }, - 'project:action_runs': { - category: 'project', - name: 'Action Runs', - description: 'Project action run status and logs.', - risk: 'medium', - riskReason: 'Read-write can trigger action runs that execute project workflows.', - allowsRead: ['Read action run status', 'Read run logs'], - allowsWrite: ['Trigger action runs'], - }, - 'project:advisors': { - category: 'project', - name: 'Advisors', - description: 'Security and performance advisor results.', - risk: 'low', - riskReason: 'Read-only access to advisor findings — no changes possible.', - allowsRead: ['Read security advisors', 'Read performance advisors'], - }, - 'project:analytics_logs': { - category: 'project', - name: 'Logs', - description: 'Operational logs and log analytics.', - risk: 'low', - riskReason: 'Read-only access to project logs.', - allowsRead: ['Read project logs'], - }, - 'project:analytics_usage': { - category: 'project', - name: 'Usage Analytics', - description: 'Project usage and analytics data.', - risk: 'low', - riskReason: 'Read-only access to usage analytics.', - allowsRead: ['Read usage analytics'], - }, - 'project:snippets': { - category: 'project', - name: 'SQL Snippets', - description: 'Saved SQL snippets for the project.', - risk: 'low', - riskReason: 'Read-write can create and edit saved SQL snippets.', - allowsRead: ['Read project SQL snippets'], - allowsWrite: ['Manage project SQL snippets'], - }, - - // --- Database --- - 'project:database': { - category: 'database', - name: 'Database', - description: 'Database access and data operations.', - risk: 'high', - riskReason: - 'Read-write lets this token run arbitrary SQL, so it can modify or delete any data in your database.', - allowsRead: ['Read tables and schema', 'Run read-only queries'], - allowsWrite: ['Run arbitrary SQL'], - }, - 'project:database_migrations': { - category: 'database', - name: 'Migrations', - description: 'Database migration history and application.', - risk: 'high', - riskReason: - 'Read-write can apply schema changes that alter or drop tables across your database.', - allowsRead: ['Read migration history'], - allowsWrite: ['Apply migrations'], - }, - 'project:backups': { - category: 'database', - name: 'Backups', - description: 'Database backups, restore points, and restore.', - risk: 'high', - riskReason: - 'Read-write can trigger restores that overwrite current data with an earlier snapshot.', - allowsRead: ['Read backups and restore points'], - allowsWrite: ['Trigger restores'], - }, - 'project:database_config': { - category: 'database', - name: 'Database Config', - description: 'Database configuration.', - risk: 'medium', - riskReason: 'Read-write can change database configuration.', - allowsRead: ['Read database configuration'], - allowsWrite: ['Update database configuration'], - }, - 'project:database_jit': { - category: 'database', - name: 'Database JIT', - description: 'Just-in-time database access settings.', - risk: 'medium', - riskReason: 'Read-write can change just-in-time database access settings.', - allowsRead: ['Read JIT settings'], - allowsWrite: ['Manage JIT settings'], - }, - 'project:database_pooling_config': { - category: 'database', - name: 'Connection Pooling', - description: 'Database connection pooling.', - risk: 'medium', - riskReason: 'Read-write can change connection pooling behavior.', - allowsRead: ['Read pooling configuration'], - allowsWrite: ['Update pooling configuration'], - }, - 'project:database_readonly_config': { - category: 'database', - name: 'Read-only Mode', - description: 'Database read-only mode.', - risk: 'medium', - riskReason: 'Read-write can toggle the database into or out of read-only mode.', - allowsRead: ['Read read-only mode status'], - allowsWrite: ['Toggle read-only mode'], - }, - 'project:database_ssl_config': { - category: 'database', - name: 'SSL Enforcement', - description: 'Database SSL configuration.', - risk: 'medium', - riskReason: 'Read-write can change SSL enforcement for database connections.', - allowsRead: ['Read SSL configuration'], - allowsWrite: ['Manage SSL enforcement'], - }, - 'project:database_webhooks_config': { - category: 'database', - name: 'Database Webhooks', - description: 'Webhooks triggered from the database.', - risk: 'medium', - riskReason: 'Read-write can change database webhook configuration.', - allowsRead: ['Read webhook configuration'], - allowsWrite: ['Manage database webhooks'], - }, - 'project:database_network_bans': { - category: 'database', - name: 'Network Bans', - description: 'Banned IPs for the database.', - risk: 'medium', - riskReason: 'Read-write can ban or unban IP addresses from reaching the database.', - allowsRead: ['Read banned IPs'], - allowsWrite: ['Manage banned IPs'], - }, - 'project:database_network_restrictions': { - category: 'database', - name: 'Network Restrictions', - description: 'Network restrictions for the database.', - risk: 'high', - riskReason: 'Read-write can change which networks are allowed to reach the database.', - allowsRead: ['Read network restrictions'], - allowsWrite: ['Manage network restrictions'], - }, - - // --- Application Services --- - 'project:auth_config': { - category: 'appsvc', - name: 'Auth Config', - description: 'Authentication provider and settings.', - risk: 'high', - riskReason: - 'Read-write can change authentication providers and settings, affecting how users sign in.', - allowsRead: ['Read auth configuration'], - allowsWrite: ['Update auth providers and settings'], - }, - 'project:auth_signing_keys': { - category: 'appsvc', - name: 'Auth Signing Keys', - description: 'Authentication signing keys.', - risk: 'high', - riskReason: 'Read-write can rotate signing keys, invalidating existing sessions and tokens.', - allowsRead: ['Read signing keys'], - allowsWrite: ['Manage signing keys'], - }, - 'project:api_gateway_keys': { - category: 'appsvc', - name: 'API Keys', - description: 'Project API keys.', - risk: 'high', - riskReason: 'Read exposes API keys; read-write grants elevated access to create new keys.', - allowsRead: ['Read project API keys'], - allowsWrite: ['Create and revoke API keys'], - }, - 'project:edge_functions': { - category: 'appsvc', - name: 'Edge Functions', - description: 'Edge functions.', - risk: 'medium', - riskReason: 'Read-write can deploy or delete edge functions.', - allowsRead: ['Read edge functions'], - allowsWrite: ['Deploy and delete edge functions'], - }, - 'project:edge_functions_secrets': { - category: 'appsvc', - name: 'Edge Function Secrets', - description: 'Secrets available to edge functions.', - risk: 'high', - riskReason: 'Read exposes function secrets; read-write can set new secret values.', - allowsRead: ['Read edge function secrets'], - allowsWrite: ['Set edge function secrets'], - }, - 'project:realtime_config': { - category: 'appsvc', - name: 'Realtime Config', - description: 'Realtime configuration.', - risk: 'medium', - riskReason: 'Read-write can change realtime settings and shut down active connections.', - allowsRead: ['Read realtime configuration'], - allowsWrite: ['Update realtime settings'], - }, - 'project:storage': { - category: 'appsvc', - name: 'Storage', - description: 'File storage buckets and objects.', - risk: 'medium', - riskReason: 'Read-write can modify or delete stored files.', - allowsRead: ['Read storage buckets and objects'], - allowsWrite: ['Manage storage buckets and objects'], - }, - 'project:storage_config': { - category: 'appsvc', - name: 'Storage Config', - description: 'Storage bucket configuration.', - risk: 'medium', - riskReason: 'Read-write can change storage configuration.', - allowsRead: ['Read storage configuration'], - allowsWrite: ['Update storage configuration'], - }, - 'project:data_api_config': { - category: 'appsvc', - name: 'Data API Config', - description: 'PostgREST behavior and settings.', - risk: 'medium', - riskReason: 'Read-write can change how the auto-generated Data API behaves.', - allowsRead: ['Read Data API configuration'], - allowsWrite: ['Update Data API configuration'], - }, - - // --- Infrastructure & Delivery --- - 'project:branching_development': { - category: 'infra', - name: 'Development Branches', - description: 'Development branch automation.', - risk: 'low', - riskReason: 'Branch automation for development workflows — limited blast radius.', - allowsRead: ['Read development branches'], - allowsWrite: ['Create, update, and delete development branches'], - }, - 'project:branching_production': { - category: 'infra', - name: 'Production Branches', - description: 'Production branch automation.', - risk: 'high', - riskReason: - 'Read-write grants elevated access to create, merge, or delete production branches.', - allowsRead: ['Read production branches'], - allowsWrite: ['Create, merge, and delete production branches'], - }, - 'project:custom_domain': { - category: 'infra', - name: 'Custom Domains', - description: 'Custom hostnames.', - risk: 'medium', - riskReason: 'Read-write can change custom hostnames, affecting how your project is reached.', - allowsRead: ['Read custom domain configuration'], - allowsWrite: ['Set custom hostnames'], - }, - 'project:vanity_subdomain': { - category: 'infra', - name: 'Vanity Subdomain', - description: 'Project vanity subdomain.', - risk: 'medium', - riskReason: 'Read-write can change the project vanity subdomain.', - allowsRead: ['Read vanity subdomain'], - allowsWrite: ['Manage vanity subdomain'], - }, - 'project:infra_addons': { - category: 'infra', - name: 'Add-ons', - description: 'Infrastructure add-ons.', - risk: 'medium', - riskReason: 'Read-write can enable or change paid infrastructure add-ons.', - allowsRead: ['Read infrastructure add-ons'], - allowsWrite: ['Manage infrastructure add-ons'], - }, - 'project:infra_disk_config': { - category: 'infra', - name: 'Disk Config', - description: 'Disk configuration.', - risk: 'medium', - riskReason: 'Read-write can change disk size and configuration, which may incur cost.', - allowsRead: ['Read disk configuration'], - allowsWrite: ['Manage disk configuration'], - }, - 'project:read_replicas': { - category: 'infra', - name: 'Read Replicas', - description: 'Read replica configuration.', - risk: 'medium', - riskReason: 'Read-write can provision or remove read replicas, which may incur cost.', - allowsRead: ['Read read-replica configuration'], - allowsWrite: ['Manage read replicas'], - }, -} - -const RESOURCE_METADATA_FALLBACK = ( - resourceKey: string, - title: string, - hasWrite: boolean -): ResourceMeta => ({ - category: resourceKey.startsWith('project:') ? 'project' : 'account', - name: title.replace(/^(Read|Manage|Create|Delete)\s+/i, ''), - description: title, - risk: hasWrite ? 'medium' : 'low', - riskReason: hasWrite - ? 'Read-write can modify this resource.' - : 'Read-only access to this resource.', -}) - -const PERMISSION_LEVELS = ['user', 'organization', 'project'] as const - -export type PermissionLevel = (typeof PERMISSION_LEVELS)[number] - -/** - * Runtime guard for the FGA namespaces: role evaluation branches on the level, so an unrecognized - * namespace must fail loudly (at module load, caught by any test importing the catalog) rather - * than silently evaluate as project-level. - */ -const toPermissionLevel = (scope: string): PermissionLevel => { - const level = scope.toLowerCase() - const match = PERMISSION_LEVELS.find((candidate) => candidate === level) - if (match === undefined) throw new Error(`Unknown FGA namespace: ${scope}`) - return match -} - -export interface PermissionCatalogEntry { - /** Derived resource key, e.g. "project:database" */ - key: string - /** Which FGA namespace the resource lives in — decides which role (org vs project) governs it. */ - level: PermissionLevel - category: PermissionCategoryKey - name: string - description: string - risk: RiskLevel - riskReason: string - allowsRead: string[] - allowsWrite: string[] - /** Whether a Read-write mode is offered (false => read-only resource). */ - writable: boolean - /** FGA scope ids granted at Read (and above). */ - readScopes: ScopedAccessTokenPermission[] - /** Additional FGA scope ids granted at Read-write (write / create / delete). */ - writeScopes: ScopedAccessTokenPermission[] -} - -const getAction = (key: string): 'read' | 'write' | 'create' | 'delete' => { - if (key.endsWith('_WRITE')) return 'write' - if (key.endsWith('_CREATE')) return 'create' - if (key.endsWith('_DELETE')) return 'delete' - return 'read' -} - -const getResource = (key: string): string => - key.replace(/_(READ|WRITE|CREATE|DELETE)$/, '').toLowerCase() - -/** - * Builds the permission catalog from the real FgaPermissions. Each unique `scope:resource` becomes - * one row; its read scope maps to Read mode and its write/create/delete scopes to Read-write mode. - */ -const buildCatalog = (): PermissionCatalogEntry[] => { - const byResource = new Map< - string, - { level: PermissionLevel; title: string; readScopes: string[]; writeScopes: string[] } - >() - - for (const [scope, scopePerms] of Object.entries(FGA)) { - const level = toPermissionLevel(scope) - for (const [permKey, perm] of Object.entries(scopePerms)) { - const resourceKey = `${level}:${getResource(permKey)}` - const action = getAction(permKey) - if (!byResource.has(resourceKey)) { - byResource.set(resourceKey, { level, title: perm.title, readScopes: [], writeScopes: [] }) - } - const entry = byResource.get(resourceKey)! - if (action === 'read') entry.readScopes.push(perm.id) - else entry.writeScopes.push(perm.id) - } - } - - const catalog: PermissionCatalogEntry[] = [] - for (const [key, { level, title, readScopes, writeScopes }] of byResource.entries()) { - const meta = - RESOURCE_METADATA[key] ?? RESOURCE_METADATA_FALLBACK(key, title, writeScopes.length > 0) - catalog.push({ - key, - level, - category: meta.category, - name: meta.name, - description: meta.description, - risk: meta.risk, - riskReason: meta.riskReason, - allowsRead: meta.allowsRead ?? [`Read ${meta.name.toLowerCase()}`], - allowsWrite: - meta.allowsWrite ?? (writeScopes.length > 0 ? [`Modify ${meta.name.toLowerCase()}`] : []), - writable: writeScopes.length > 0, - readScopes: readScopes as ScopedAccessTokenPermission[], - writeScopes: writeScopes as ScopedAccessTokenPermission[], - }) - } - return catalog -} - -export const PERMISSION_CATALOG = buildCatalog() - -const CATALOG_BY_KEY = new Map(PERMISSION_CATALOG.map((entry) => [entry.key, entry])) - -export const getCatalogEntry = (key: string) => CATALOG_BY_KEY.get(key) - -export interface CategoryWithEntries extends PermissionCategory { - entries: PermissionCatalogEntry[] -} - -/** Catalog grouped by category, in category display order, dropping empty categories. */ -export const PERMISSION_CATALOG_BY_CATEGORY: CategoryWithEntries[] = PERMISSION_CATEGORIES.map( - (category) => ({ - ...category, - entries: PERMISSION_CATALOG.filter((entry) => entry.category === category.key), - }) -).filter((category) => category.entries.length > 0) - /** Map of resource key -> selected mode. Absent keys are treated as 'none'. */ export type PermissionSelection = Record @@ -577,7 +46,7 @@ export const selectionToScopes = ( ): ScopedAccessTokenPermission[] => { const scopes: ScopedAccessTokenPermission[] = [] for (const [key, mode] of Object.entries(selection)) { - const entry = CATALOG_BY_KEY.get(key) + const entry = getCatalogEntry(key) if (!entry) continue scopes.push(...getEntryScopes(entry, mode)) } @@ -590,7 +59,7 @@ export const selectionToScopes = ( * Tokens created through the Management API can hold arbitrary scope subsets that the * none/read/readwrite modes cannot represent exactly (e.g. a lone branching_development_create). * Any granted scope of an entry marks it at the corresponding mode, so a partial grant is never - * dropped — the mode is an upper bound and may name specific operations the token lacks, but it + * dropped. The mode is an upper bound and may name specific operations the token lacks, but it * never understates the token's authority or risk. The endpoint and MCP-tool lists, computed * from the actual granted scopes, remain the precise view. */ @@ -623,12 +92,6 @@ export const RISK_LEVEL_LABEL: Record = { high: 'High risk', } -export const PERMISSION_MODE_LABEL: Record = { - none: 'None', - read: 'Read', - readwrite: 'Read-write', -} - export type ResourceAccessMode = 'project' | 'organization' | 'account' export const RISK_TONE_VARIANT: Record = { diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx index b96ba33e95e..2fd628ebc00 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx @@ -13,8 +13,6 @@ import { getActivePreset, type PermissionPreset } from '../../AccessToken.preset import type { TokenAccessEvaluation } from '../../AccessToken.roles' import { PermissionPresetSelect } from './PermissionPresetSelect' import { PermissionRow } from './PermissionRow' -import { InlineLink } from '@/components/ui/InlineLink' -import { DOCS_URL } from '@/lib/constants' interface PermissionsAccordionProps { selection: PermissionSelection @@ -42,11 +40,7 @@ export const PermissionsAccordion = ({ description={

Grant the minimum access this token needs. Everything defaults to None. Permissions - follow your role in the organizations and projects you're a member of — see{' '} - - access control - {' '} - for how roles work. + follow your role in the organizations and projects you're a member of.

} > diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index 302388d8060..b7875967542 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -15,6 +15,7 @@ import { ExperimentalTokenDropdown } from '../Classic/ExperimentalTokenDropdown' import { NewScopedTokenForm } from './Form/NewScopedTokenForm' import { getExpiryDate, type TokenFormValues } from './Form/NewScopedTokenForm.utils' import { NewScopedTokenSuccess } from './Form/NewScopedTokenSuccess' +import { TokenDocsButtons } from './TokenDocsButtons' import { useAccessTokenCreateMutation, type NewAccessToken, @@ -124,11 +125,12 @@ export const NewScopedTokenSheet = ({ onCreateExperimentalToken }: NewScopedToke size="default" className="flex h-full flex-col gap-0 sm:w-[656px] lg:w-[800px]" > - + {step === 'success' ? 'Token created' : 'Generate token'} Configure and create a new access token. + {step !== 'success' && } {step === 'success' && createdToken ? ( { + return ( +
+ + +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx index 533c7744034..7ac97feadf3 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -21,7 +21,7 @@ import { getCapabilityDensityTier, type CapabilityLevelFilter, } from './TokenCapabilities/TokenCapabilities.utils' -import { DocsButton } from '@/components/ui/DocsButton' +import { TokenDocsButtons } from './TokenDocsButtons' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query' import { @@ -29,7 +29,6 @@ import { useGetEnabledEndpointsForCapability, } from '@/data/scoped-access-tokens/permission-scope-map-query' import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query' -import { DOCS_URL } from '@/lib/constants' interface ViewTokenSheetProps { visible: boolean @@ -148,18 +147,7 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp

View access for {token?.name}

-
- - -
+
{/* Radix wraps viewport children in an inline-styled display:table div that grows to fit the widest child, which would let one long endpoint path expand the sheet instead of diff --git a/packages/shared-data/package.json b/packages/shared-data/package.json index bc39f076600..68e503b0df5 100644 --- a/packages/shared-data/package.json +++ b/packages/shared-data/package.json @@ -15,6 +15,7 @@ "author": "", "license": "MIT", "dependencies": { + "@supabase/shared-types": "0.1.91", "zod": "catalog:" } } diff --git a/packages/shared-data/scoped-access-token-permissions.ts b/packages/shared-data/scoped-access-token-permissions.ts new file mode 100644 index 00000000000..2c8e2934abb --- /dev/null +++ b/packages/shared-data/scoped-access-token-permissions.ts @@ -0,0 +1,625 @@ +import { permissions } from '@supabase/shared-types' + +/** + * The permission catalog for scoped personal access tokens: every FGA scope grouped into resources with + * display names, categories, and risk metadata. + * + * The real permission scopes come from `@supabase/shared-types` (`FgaPermissions`). Those scopes + * carry no category or risk metadata, so this file layers editable presentation data on top: + * - PERMISSION_CATEGORIES groups every scope into a display category. + * - RESOURCE_METADATA assigns each resource a display name, description, category and risk. + * + * Lives in shared-data (not apps/studio) because two apps consume it: Studio's scoped personal + * access token creation form, and the docs generator that renders the "Personal Access Tokens" + * guide's permission tables (apps/docs/spec/sections/generateAccessControlPartials.mts). + * The docs must show the same names, categories, and order as the form. + * + * The `name` and `category` fields are published copy: they are the permission and section labels + * in both the form and the docs guide, so renaming one renames both on the next regeneration. + * + * TODO(product): the risk levels, risk reasons, and "Allows" copy below are proposed defaults and + * still need review. They render in the form only, not in the docs. Where a resource has no + * explicit metadata entry we fall back to a heuristic. + */ + +const FGA = permissions.FgaPermissions + +type PermissionsOf = T extends Record ? P : never +/** Union of every FGA scope id literal published by @supabase/shared-types. */ +export type FgaScopeId = + PermissionsOf<(typeof FGA)[keyof typeof FGA]> extends { id: infer Id } ? Id : never + +export type RiskLevel = 'low' | 'medium' | 'high' + +/** + * Display labels for permission modes. Part of the form<->docs contract like resource names: + * Studio's form and the docs guide's tables must label modes identically. + */ +export const PERMISSION_MODE_LABEL = { + none: 'None', + read: 'Read', + readwrite: 'Read-write', +} as const + +/** Selection modes for a catalog entry. The label map's keys are the single source of truth. */ +export type PermissionMode = keyof typeof PERMISSION_MODE_LABEL + +export type PermissionCategoryKey = 'account' | 'project' | 'database' | 'appsvc' | 'infra' + +export interface PermissionCategory { + key: PermissionCategoryKey + name: string + description: string +} + +/** Display order. The form's accordion sections and the docs guide's sections both follow it. */ +const PERMISSION_CATEGORIES: PermissionCategory[] = [ + { + key: 'project', + name: 'Project', + description: 'Core project visibility, settings, and diagnostics.', + }, + { + key: 'database', + name: 'Database', + description: 'SQL access, migrations, backups, and data operations.', + }, + { + key: 'appsvc', + name: 'Application services', + description: 'Auth, storage, realtime, edge functions, and service configuration.', + }, + { + key: 'infra', + name: 'Infrastructure and delivery', + description: 'Branch automation, domains, add-ons, and network.', + }, + { + key: 'account', + name: 'Account and organization', + description: 'Account-wide and organization-level access that spans projects.', + }, +] + +interface ResourceMeta { + category: PermissionCategoryKey + name: string + description: string + risk: RiskLevel + riskReason: string + allowsRead?: string[] + allowsWrite?: string[] +} + +/** + * Per-resource presentation metadata, keyed by the derived `scope:resource` key (derived in + * `buildCatalog` below). Every resource returned from FgaPermissions should have an entry; + * RESOURCE_METADATA_FALLBACK covers anything that slips through. + */ +const RESOURCE_METADATA: Record = { + // --- Account and organization --- + 'user:organizations': { + category: 'account', + name: 'Organizations', + description: 'Organizations you belong to.', + risk: 'medium', + riskReason: 'Read-write can create new organizations under your account.', + allowsRead: ['List your organizations'], + allowsWrite: ['Create organizations'], + }, + 'user:projects': { + category: 'account', + name: 'Projects (account-wide)', + description: 'Projects across all your organizations.', + risk: 'low', + riskReason: 'Read-only listing of the projects you can access.', + allowsRead: ['List your projects'], + }, + 'user:snippets': { + category: 'account', + name: 'SQL Snippets (account-wide)', + description: 'Saved SQL snippets across your account.', + risk: 'low', + riskReason: 'Read-only access to your saved snippets.', + allowsRead: ['Read your SQL snippets'], + }, + 'organization:admin': { + category: 'account', + name: 'Organization Settings', + description: 'Organization settings and project transfers.', + risk: 'high', + riskReason: 'Read-write grants elevated access to organization settings and project transfers.', + allowsRead: ['Read organization settings'], + allowsWrite: ['Manage organization settings', 'Transfer projects'], + }, + 'organization:members': { + category: 'account', + name: 'Organization Members', + description: 'Members and roles within the organization.', + risk: 'high', + riskReason: 'Read-write can add or remove members and change roles across your organization.', + allowsRead: ['Read organization members'], + allowsWrite: ['Add or remove members', 'Change member roles'], + }, + 'organization:projects': { + category: 'account', + name: 'Organization Projects', + description: 'Projects within the organization.', + risk: 'medium', + riskReason: 'Read-write can create new projects in the organization.', + allowsRead: ['List organization projects'], + allowsWrite: ['Create organization projects'], + }, + 'organization:platform_webhooks': { + category: 'account', + name: 'Platform Webhooks (organization)', + description: 'Platform webhook endpoints and deliveries for the organization.', + risk: 'medium', + riskReason: 'Read-write can create webhook endpoints that receive organization events.', + allowsRead: ['Read webhook endpoints and deliveries'], + allowsWrite: ['Manage webhook endpoints'], + }, + + // --- Project --- + 'project:admin': { + category: 'project', + name: 'Project Settings', + description: 'Project metadata and settings.', + risk: 'high', + riskReason: 'Read-write grants elevated access to change project settings and configuration.', + allowsRead: ['Read project metadata'], + allowsWrite: ['Update project settings'], + }, + 'project:action_runs': { + category: 'project', + name: 'Action Runs', + description: 'Project action run status and logs.', + risk: 'medium', + riskReason: 'Read-write can trigger action runs that execute project workflows.', + allowsRead: ['Read action run status', 'Read run logs'], + allowsWrite: ['Trigger action runs'], + }, + 'project:advisors': { + category: 'project', + name: 'Advisors', + description: 'Security and performance advisor results.', + risk: 'low', + riskReason: 'Read-only access to advisor findings. No changes possible.', + allowsRead: ['Read security advisors', 'Read performance advisors'], + }, + 'project:analytics_logs': { + category: 'project', + name: 'Logs', + description: 'Operational logs and log analytics.', + risk: 'low', + riskReason: 'Read-only access to project logs.', + allowsRead: ['Read project logs'], + }, + 'project:analytics_usage': { + category: 'project', + name: 'Usage Analytics', + description: 'Project usage and analytics data.', + risk: 'low', + riskReason: 'Read-only access to usage analytics.', + allowsRead: ['Read usage analytics'], + }, + 'project:analytics_config': { + category: 'project', + name: 'Analytics Config', + description: 'Log drains and analytics configuration.', + risk: 'medium', + riskReason: 'Read-write can create log drains that export project logs.', + allowsRead: ['Read analytics configuration'], + allowsWrite: ['Manage log drains'], + }, + 'project:platform_webhooks': { + category: 'project', + name: 'Platform Webhooks', + description: 'Platform webhook endpoints and deliveries for the project.', + risk: 'medium', + riskReason: 'Read-write can create webhook endpoints that receive project events.', + allowsRead: ['Read webhook endpoints and deliveries'], + allowsWrite: ['Manage webhook endpoints'], + }, + 'project:snippets': { + category: 'project', + name: 'SQL Snippets', + description: 'Saved SQL snippets for the project.', + risk: 'low', + riskReason: 'Read-write can create and edit saved SQL snippets.', + allowsRead: ['Read project SQL snippets'], + allowsWrite: ['Manage project SQL snippets'], + }, + + // --- Database --- + 'project:database': { + category: 'database', + name: 'Database', + description: 'Database access and data operations.', + risk: 'high', + riskReason: + 'Read-write lets this token run arbitrary SQL, so it can modify or delete any data in your database.', + allowsRead: ['Read tables and schema', 'Run read-only queries'], + allowsWrite: ['Run arbitrary SQL'], + }, + 'project:database_migrations': { + category: 'database', + name: 'Migrations', + description: 'Database migration history and application.', + risk: 'high', + riskReason: + 'Read-write can apply schema changes that alter or drop tables across your database.', + allowsRead: ['Read migration history'], + allowsWrite: ['Apply migrations'], + }, + 'project:backups': { + category: 'database', + name: 'Backups', + description: 'Database backups, restore points, and restore.', + risk: 'high', + riskReason: + 'Read-write can trigger restores that overwrite current data with an earlier snapshot.', + allowsRead: ['Read backups and restore points'], + allowsWrite: ['Trigger restores'], + }, + 'project:database_config': { + category: 'database', + name: 'Database Config', + description: 'Database configuration.', + risk: 'medium', + riskReason: 'Read-write can change database configuration.', + allowsRead: ['Read database configuration'], + allowsWrite: ['Update database configuration'], + }, + 'project:database_jit': { + category: 'database', + name: 'Database JIT', + description: 'Just-in-time database access settings.', + risk: 'medium', + riskReason: 'Read-write can change just-in-time database access settings.', + allowsRead: ['Read JIT settings'], + allowsWrite: ['Manage JIT settings'], + }, + 'project:database_pooling_config': { + category: 'database', + name: 'Connection Pooling', + description: 'Database connection pooling.', + risk: 'medium', + riskReason: 'Read-write can change connection pooling behavior.', + allowsRead: ['Read pooling configuration'], + allowsWrite: ['Update pooling configuration'], + }, + 'project:database_readonly_config': { + category: 'database', + name: 'Read-only Mode', + description: 'Database read-only mode.', + risk: 'medium', + riskReason: 'Read-write can toggle the database into or out of read-only mode.', + allowsRead: ['Read read-only mode status'], + allowsWrite: ['Toggle read-only mode'], + }, + 'project:database_ssl_config': { + category: 'database', + name: 'SSL Enforcement', + description: 'Database SSL configuration.', + risk: 'medium', + riskReason: 'Read-write can change SSL enforcement for database connections.', + allowsRead: ['Read SSL configuration'], + allowsWrite: ['Manage SSL enforcement'], + }, + 'project:database_webhooks_config': { + category: 'database', + name: 'Database Webhooks', + description: 'Webhooks triggered from the database.', + risk: 'medium', + riskReason: 'Read-write can change database webhook configuration.', + allowsRead: ['Read webhook configuration'], + allowsWrite: ['Manage database webhooks'], + }, + 'project:database_network_bans': { + category: 'database', + name: 'Network Bans', + description: 'Banned IPs for the database.', + risk: 'medium', + riskReason: 'Read-write can ban or unban IP addresses from reaching the database.', + allowsRead: ['Read banned IPs'], + allowsWrite: ['Manage banned IPs'], + }, + 'project:database_network_restrictions': { + category: 'database', + name: 'Network Restrictions', + description: 'Network restrictions for the database.', + risk: 'high', + riskReason: 'Read-write can change which networks are allowed to reach the database.', + allowsRead: ['Read network restrictions'], + allowsWrite: ['Manage network restrictions'], + }, + + // --- Application services --- + 'project:auth_config': { + category: 'appsvc', + name: 'Auth Config', + description: 'Authentication provider and settings.', + risk: 'high', + riskReason: + 'Read-write can change authentication providers and settings, affecting how users sign in.', + allowsRead: ['Read auth configuration'], + allowsWrite: ['Update auth providers and settings'], + }, + 'project:auth_signing_keys': { + category: 'appsvc', + name: 'Auth Signing Keys', + description: 'Authentication signing keys.', + risk: 'high', + riskReason: 'Read-write can rotate signing keys, invalidating existing sessions and tokens.', + allowsRead: ['Read signing keys'], + allowsWrite: ['Manage signing keys'], + }, + 'project:api_gateway_keys': { + category: 'appsvc', + name: 'API Keys', + description: 'Project API keys.', + risk: 'high', + riskReason: 'Read exposes API keys; read-write grants elevated access to create new keys.', + allowsRead: ['Read project API keys'], + allowsWrite: ['Create and revoke API keys'], + }, + 'project:edge_functions': { + category: 'appsvc', + name: 'Edge Functions', + description: 'Edge functions.', + risk: 'medium', + riskReason: 'Read-write can deploy or delete edge functions.', + allowsRead: ['Read edge functions'], + allowsWrite: ['Deploy and delete edge functions'], + }, + 'project:edge_functions_secrets': { + category: 'appsvc', + name: 'Edge Function Secrets', + description: 'Secrets available to edge functions.', + risk: 'high', + riskReason: 'Read exposes function secrets; read-write can set new secret values.', + allowsRead: ['Read edge function secrets'], + allowsWrite: ['Set edge function secrets'], + }, + 'project:realtime_config': { + category: 'appsvc', + name: 'Realtime Config', + description: 'Realtime configuration.', + risk: 'medium', + riskReason: 'Read-write can change realtime settings and shut down active connections.', + allowsRead: ['Read realtime configuration'], + allowsWrite: ['Update realtime settings'], + }, + 'project:storage': { + category: 'appsvc', + name: 'Storage', + description: 'File storage buckets and objects.', + risk: 'medium', + riskReason: 'Read-write can modify or delete stored files.', + allowsRead: ['Read storage buckets and objects'], + allowsWrite: ['Manage storage buckets and objects'], + }, + 'project:storage_config': { + category: 'appsvc', + name: 'Storage Config', + description: 'Storage bucket configuration.', + risk: 'medium', + riskReason: 'Read-write can change storage configuration.', + allowsRead: ['Read storage configuration'], + allowsWrite: ['Update storage configuration'], + }, + 'project:data_api_config': { + category: 'appsvc', + name: 'Data API Config', + description: 'PostgREST behavior and settings.', + risk: 'medium', + riskReason: 'Read-write can change how the auto-generated Data API behaves.', + allowsRead: ['Read Data API configuration'], + allowsWrite: ['Update Data API configuration'], + }, + + // --- Infrastructure and delivery --- + 'project:branching_development': { + category: 'infra', + name: 'Development Branches', + description: 'Development branch automation.', + risk: 'low', + riskReason: 'Branch automation for development workflows with limited blast radius.', + allowsRead: ['Read development branches'], + allowsWrite: ['Create, update, and delete development branches'], + }, + 'project:branching_production': { + category: 'infra', + name: 'Production Branches', + description: 'Production branch automation.', + risk: 'high', + riskReason: + 'Read-write grants elevated access to create, merge, or delete production branches.', + allowsRead: ['Read production branches'], + allowsWrite: ['Create, merge, and delete production branches'], + }, + 'project:custom_domain': { + category: 'infra', + name: 'Custom Domains', + description: 'Custom hostnames.', + risk: 'medium', + riskReason: 'Read-write can change custom hostnames, affecting how your project is reached.', + allowsRead: ['Read custom domain configuration'], + allowsWrite: ['Set custom hostnames'], + }, + 'project:vanity_subdomain': { + category: 'infra', + name: 'Vanity Subdomain', + description: 'Project vanity subdomain.', + risk: 'medium', + riskReason: 'Read-write can change the project vanity subdomain.', + allowsRead: ['Read vanity subdomain'], + allowsWrite: ['Manage vanity subdomain'], + }, + 'project:infra_addons': { + category: 'infra', + name: 'Add-ons', + description: 'Infrastructure add-ons.', + risk: 'medium', + riskReason: 'Read-write can enable or change paid infrastructure add-ons.', + allowsRead: ['Read infrastructure add-ons'], + allowsWrite: ['Manage infrastructure add-ons'], + }, + 'project:infra_disk_config': { + category: 'infra', + name: 'Disk Config', + description: 'Disk configuration.', + risk: 'medium', + riskReason: 'Read-write can change disk size and configuration, which may incur cost.', + allowsRead: ['Read disk configuration'], + allowsWrite: ['Manage disk configuration'], + }, + 'project:read_replicas': { + category: 'infra', + name: 'Read Replicas', + description: 'Read replica configuration.', + risk: 'medium', + riskReason: 'Read-write can provision or remove read replicas, which may incur cost.', + allowsRead: ['Read read-replica configuration'], + allowsWrite: ['Manage read replicas'], + }, +} + +const RESOURCE_METADATA_FALLBACK = ( + resourceKey: string, + title: string, + hasWrite: boolean +): ResourceMeta => ({ + category: resourceKey.startsWith('project:') ? 'project' : 'account', + // Title-cased so an uncurated resource doesn't ship a lowercase FGA-derived name + // (e.g. "project analytics configurations") next to the curated Title Case entries. + name: title + .replace(/^(Read|Manage|Create|Delete)\s+/i, '') + .replace(/\b[a-z]/g, (char) => char.toUpperCase()), + description: title, + risk: hasWrite ? 'medium' : 'low', + riskReason: hasWrite + ? 'Read-write can modify this resource.' + : 'Read-only access to this resource.', +}) + +const PERMISSION_LEVELS = ['user', 'organization', 'project'] as const + +export type PermissionLevel = (typeof PERMISSION_LEVELS)[number] + +/** + * Runtime guard for the FGA namespaces: role evaluation branches on the level, so an unrecognized + * namespace must fail loudly (at module load, caught by any test importing the catalog) rather + * than silently evaluate as project-level. + */ +const toPermissionLevel = (scope: string): PermissionLevel => { + const level = scope.toLowerCase() + const match = PERMISSION_LEVELS.find((candidate) => candidate === level) + if (match === undefined) throw new Error(`Unknown FGA namespace: ${scope}`) + return match +} + +export interface PermissionCatalogEntry { + /** Derived resource key, e.g. "project:database" */ + key: string + /** Which FGA namespace the resource lives in. Decides which role (org vs project) governs it. */ + level: PermissionLevel + category: PermissionCategoryKey + name: string + description: string + risk: RiskLevel + riskReason: string + allowsRead: string[] + allowsWrite: string[] + /** Whether a Read-write mode is offered (false => read-only resource). */ + writable: boolean + /** FGA scope ids granted at Read (and above). */ + readScopes: FgaScopeId[] + /** Additional FGA scope ids granted at Read-write (write / create / delete). */ + writeScopes: FgaScopeId[] +} + +/** Action classes an FGA permission key's suffix can map to. */ +export type FgaAction = 'read' | 'write' | 'create' | 'delete' + +/** + * Classifies an FGA permission key by its action suffix: "PROJECTS_READ" -> "read". + * Keys without a write, create, or delete suffix are treated as read. + */ +export const getAction = (key: string): FgaAction => { + if (key.endsWith('_WRITE')) return 'write' + if (key.endsWith('_CREATE')) return 'create' + if (key.endsWith('_DELETE')) return 'delete' + return 'read' +} + +const isReadScope = (key: string): boolean => getAction(key) === 'read' + +/** Strips the FGA action suffix off a permission key: "PROJECTS_READ" -> "projects". */ +export const getResource = (key: string): string => + key.replace(/_(READ|WRITE|CREATE|DELETE)$/, '').toLowerCase() + +/** + * Builds the permission catalog from the real FgaPermissions. Each unique `scope:resource` becomes + * one row; its read scope maps to Read mode and its write/create/delete scopes to Read-write mode. + */ +const buildCatalog = (): PermissionCatalogEntry[] => { + const byResource = new Map< + string, + { level: PermissionLevel; title: string; readScopes: string[]; writeScopes: string[] } + >() + + for (const [scope, scopePerms] of Object.entries(FGA)) { + const level = toPermissionLevel(scope) + for (const [permKey, perm] of Object.entries(scopePerms)) { + const resourceKey = `${level}:${getResource(permKey)}` + if (!byResource.has(resourceKey)) { + byResource.set(resourceKey, { level, title: perm.title, readScopes: [], writeScopes: [] }) + } + const entry = byResource.get(resourceKey)! + if (isReadScope(permKey)) entry.readScopes.push(perm.id) + else entry.writeScopes.push(perm.id) + } + } + + const catalog: PermissionCatalogEntry[] = [] + for (const [key, { level, title, readScopes, writeScopes }] of byResource.entries()) { + const meta = + RESOURCE_METADATA[key] ?? RESOURCE_METADATA_FALLBACK(key, title, writeScopes.length > 0) + catalog.push({ + key, + level, + category: meta.category, + name: meta.name, + description: meta.description, + risk: meta.risk, + riskReason: meta.riskReason, + allowsRead: meta.allowsRead ?? [`Read ${meta.name.toLowerCase()}`], + allowsWrite: + meta.allowsWrite ?? (writeScopes.length > 0 ? [`Modify ${meta.name.toLowerCase()}`] : []), + writable: writeScopes.length > 0, + readScopes: readScopes as FgaScopeId[], + writeScopes: writeScopes as FgaScopeId[], + }) + } + return catalog +} + +export const PERMISSION_CATALOG = buildCatalog() + +const CATALOG_BY_KEY = new Map(PERMISSION_CATALOG.map((entry) => [entry.key, entry])) + +export const getCatalogEntry = (key: string) => CATALOG_BY_KEY.get(key) + +export interface CategoryWithEntries extends PermissionCategory { + entries: PermissionCatalogEntry[] +} + +/** Catalog grouped by category, in category display order, dropping empty categories. */ +export const PERMISSION_CATALOG_BY_CATEGORY: CategoryWithEntries[] = PERMISSION_CATEGORIES.map( + (category) => ({ + ...category, + entries: PERMISSION_CATALOG.filter((entry) => entry.category === category.key), + }) +).filter((category) => category.entries.length > 0) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb78095c2ce..ca4cb7e8981 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2592,6 +2592,9 @@ importers: packages/shared-data: dependencies: + '@supabase/shared-types': + specifier: 0.1.91 + version: 0.1.91 zod: specifier: 'catalog:' version: 3.25.76