mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +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.
335 lines
10 KiB
TypeScript
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])
|