mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Problem
The Management API now has `/v2/projects/{ref}/notebooks`, gated by the
new `project_notebooks_read` / `project_notebooks_write` FGA
permissions. Studio pins `@supabase/shared-types` 0.1.95, which predates
them, so the scoped access token form can't grant them. Tokens created
with every permission selected still get `403 forbidden` on the notebook
endpoints.
## Solution
- Bump `@supabase/shared-types` to 0.1.96 (Studio and shared-data),
which publishes the notebook permissions.
- Add a **Notebooks** entry to the permission catalog (Project category,
next to SQL Snippets).
- Add minimum roles to `FGA_SCOPE_MINIMUM_ROLE`: read is `readonly`,
write is `developer`, matching the OpenFGA model.
The docs permission tables don't change yet. They're built from the
docs' checked-in v2 spec, which doesn't include the notebook endpoints,
so the row appears on the next spec sync.
## Review instructions
1. In the preview, go to **Account → Access Tokens** and create a scoped
token for a project. Check that **Notebooks** is listed under Project,
and set it to Read-write.
2. List notebooks with the new token:
```bash
curl -s -H "Authorization: Bearer $TOKEN"
"https://api.supabase.com/v2/projects/$REF/notebooks"
```
It should return `200` with `{ "links": ..., "data": [...] }`, not
`403`.
3. Optional: create a token with Notebooks set to None, repeat step 2,
and check it returns `403`.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- Added project-level notebook permissions to access tokens.
- Access tokens can now grant read-only or developer-level access for
managing shared project notebooks.
- Project notebook permissions are displayed in the token creation
interface and supporting documentation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
678 lines
25 KiB
TypeScript
678 lines
25 KiB
TypeScript
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> = T extends Record<string, infer P> ? 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[]
|
|
dependencies?: 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<string, ResourceMeta> = {
|
|
// --- 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'],
|
|
},
|
|
'project:notebooks': {
|
|
category: 'project',
|
|
name: 'Notebooks',
|
|
description: 'Notebooks shared with everyone on the project.',
|
|
risk: 'low',
|
|
riskReason: 'Read-write can create, edit, and delete notebooks shared across the project.',
|
|
allowsRead: ['Read project notebooks'],
|
|
allowsWrite: ['Manage project notebooks'],
|
|
},
|
|
|
|
// --- 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:api_gateway_keys_secret': {
|
|
category: 'appsvc',
|
|
name: 'API Key Secrets',
|
|
description: 'Secret values of project API keys.',
|
|
risk: 'high',
|
|
riskReason: 'Read reveals the secret values of project API keys.',
|
|
allowsRead: ['Reveal project API key secrets'],
|
|
dependencies: ['project:api_gateway_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:workers': {
|
|
category: 'appsvc',
|
|
name: 'Compute',
|
|
description: 'Compute workers deployed to the project.',
|
|
risk: 'medium',
|
|
riskReason: 'Read-write can deploy or delete compute workers.',
|
|
allowsRead: ['List compute workers'],
|
|
allowsWrite: ['Deploy and delete compute workers'],
|
|
},
|
|
'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: 'Data API 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'],
|
|
},
|
|
'project:data_api_config_secret': {
|
|
category: 'appsvc',
|
|
name: 'Data API JWT Secret',
|
|
description: 'JWT secret used by the Data API.',
|
|
risk: 'high',
|
|
riskReason: 'Read exposes the JWT secret, which can be used to mint tokens for any role.',
|
|
allowsRead: ['Read Data API JWT secret'],
|
|
dependencies: ['project:data_api_config'],
|
|
},
|
|
|
|
// --- 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
|
|
}
|
|
|
|
type PermissionDependency = { key: string; label: string; permissions: 'read' | 'read-write' }
|
|
|
|
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[]
|
|
dependencies: Array<PermissionDependency>
|
|
}
|
|
|
|
/** 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)
|
|
const dependencies = (meta.dependencies ?? []).map((dependency) => {
|
|
return {
|
|
key: dependency,
|
|
label: RESOURCE_METADATA[dependency].name,
|
|
permissions:
|
|
RESOURCE_METADATA[dependency].allowsRead?.length &&
|
|
RESOURCE_METADATA[dependency].allowsWrite?.length
|
|
? ('read-write' as const)
|
|
: ('read' as const),
|
|
}
|
|
})
|
|
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[],
|
|
dependencies,
|
|
})
|
|
}
|
|
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)
|