Files
dbb153042e feat(studio): cleaned up view permissions sheet (#49144)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Breaking down #49007 into smaller PR's. Part 1 merged in.

More to follow...


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Redesigned token capability details with expandable cards and dense
views for larger permission sets.
  * Added filtering by all, read, and read-write capabilities.
* Improved endpoint and MCP tool attribution, display, and endpoint
copying.
  * Added risk banners with permission and access warnings.
* Enhanced resource badges, responsive layouts, relative timestamps, and
dismissible creation guidance.

* **Bug Fixes**
  * Corrected MCP tool attribution across alternative permission scopes.
  * Improved handling and display of inaccessible resources.

* **Tests**
* Expanded coverage for capability views, filtering, risk messaging, and
permission evaluation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
2026-08-18 10:58:56 +01:00

639 lines
23 KiB
TypeScript

import { permissions } from '@supabase/shared-types'
import type { ScopedAccessTokenPermission } from './AccessToken.constants'
/**
* Data model for the scoped access-token creation flow.
*
* 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.
*/
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<string, ResourceMeta> = {
// --- 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<string, PermissionMode>
/** FGA scope ids a catalog entry grants at the given mode. */
export const getEntryScopes = (
entry: PermissionCatalogEntry,
mode: PermissionMode
): ScopedAccessTokenPermission[] => {
if (mode === 'none') return []
if (mode === 'readwrite') return [...entry.readScopes, ...entry.writeScopes]
return entry.readScopes
}
/** Flattens a selection into the concrete FGA scope ids to send to the API. */
export const selectionToScopes = (
selection: PermissionSelection
): ScopedAccessTokenPermission[] => {
const scopes: ScopedAccessTokenPermission[] = []
for (const [key, mode] of Object.entries(selection)) {
const entry = CATALOG_BY_KEY.get(key)
if (!entry) continue
scopes.push(...getEntryScopes(entry, mode))
}
return Array.from(new Set(scopes))
}
/**
* Reverses `selectionToScopes`: derives a selection from a token's granted FGA scope ids.
*
* 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
* never understates the token's authority or risk. The endpoint and MCP-tool lists, computed
* from the actual granted scopes, remain the precise view.
*/
export const scopesToSelection = (grantedScopes: string[]): PermissionSelection => {
const granted = new Set(grantedScopes)
const selection: PermissionSelection = {}
for (const entry of PERMISSION_CATALOG) {
const hasWrite = entry.writeScopes.some((scope) => granted.has(scope))
const hasRead = entry.readScopes.some((scope) => granted.has(scope))
if (hasWrite) selection[entry.key] = 'readwrite'
else if (hasRead) selection[entry.key] = 'read'
}
return selection
}
export const countConfiguredInCategory = (
selection: PermissionSelection,
categoryKey: PermissionCategoryKey
): number =>
PERMISSION_CATALOG.filter(
(entry) => entry.category === categoryKey && (selection[entry.key] ?? 'none') !== 'none'
).length
export const countConfigured = (selection: PermissionSelection): number =>
Object.values(selection).filter((mode) => mode !== 'none').length
export const RISK_LEVEL_LABEL: Record<RiskLevel, string> = {
low: 'Low risk',
medium: 'Medium risk',
high: 'High risk',
}
export const PERMISSION_MODE_LABEL: Record<PermissionMode, string> = {
none: 'None',
read: 'Read',
readwrite: 'Read-write',
}
export type ResourceAccessMode = 'project' | 'organization' | 'account'
export const RISK_TONE_VARIANT: Record<RiskLevel, 'success' | 'warning' | 'destructive'> = {
low: 'success',
medium: 'warning',
high: 'destructive',
}