mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
Add a guide that compares classic and scoped personal access tokens, explains how account roles constrain token permissions, and walks through creating and testing a project-scoped token. Include generated tables mapping permissions to Management API endpoints and MCP tools, and link the guide from docs navigation and Studio token sheets. Move the scoped-token permission catalog from Studio into shared-data. Studio and docs generation now share permission names, categories, descriptions, risk metadata, modes, scopes, and display order. Generate the tables from the shared catalog, OpenAPI x-fga-permissions, and the downloaded MCP permission map. Exclude Workers permissions until the feature is live. Run regeneration through the docs Makefile, verify checked-in output in CI, and refresh it in the weekly Management API workflow. Add Dashboard and Docs ownership plus contributor guidance so permission changes stay synchronized.
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
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 }
|
|
|
|
/**
|
|
* 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 catalog itself (resources, categories, risk metadata, scopes) lives in
|
|
* shared-data/scoped-access-token-permissions.ts so the docs generator can consume it too.
|
|
*/
|
|
|
|
/** 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 = getCatalogEntry(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 type ResourceAccessMode = 'project' | 'organization' | 'account'
|
|
|
|
export const RISK_TONE_VARIANT: Record<RiskLevel, 'success' | 'warning' | 'destructive'> = {
|
|
low: 'success',
|
|
medium: 'warning',
|
|
high: 'destructive',
|
|
}
|