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])