Files
supabase/apps/docs/spec/sections/generateAccessControlPartials.mts
Wen Bo Xie 2681a21f5c docs: add Personal Access Tokens guide with generated permission tables (#49732)
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.
2026-09-01 12:30:56 +00:00

335 lines
10 KiB
TypeScript

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<string, ScopeGroupAlternatives>
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<string, string> = {
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<string>) {
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<string>) {
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<string, Endpoint>()
const unclassifiedOperations: string[] = []
for (const specPath of specPaths) {
const spec = readJson(specPath)
for (const [route, methods] of Object.entries<Record<string, Operation>>(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<string>()
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<string, string>()
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<string>()
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 <api-v1.json> <api-v2.json> ' +
'<mcp-tools.json> <permissions-output.mdx> <mcp-tools-output.mdx>'
)
process.exit(1)
}
const tools: McpMap = readJson(args[2])
generatePermissionsPartial(args.slice(0, 2), tools, args[3])
generateMcpToolsPartial(tools, args[4])