mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
9b1dddde11
## What kind of change does this PR introduce? Surface the new scoped personal access token permissions published in `@supabase/shared-types` 0.1.95 (added by https://github.com/supabase/platform/pull/38060, now deployed). **Stacked on #50234**, which regenerates the Management API types so Studio's scope type includes the new ids. This PR targets that branch and will retarget to `master` when it merges. ## What's in here - Bump `@supabase/shared-types` to 0.1.95 (Studio and shared-data). - Catalog entries in `packages/shared-data/scoped-access-token-permissions.ts`: - **API Key Secrets** (`api_gateway_keys_secret_read`): gates `?reveal=true` on the API keys endpoints. Renamed from "JWT secret", which described the wrong thing. - **Data API JWT Secret** (`data_api_config_secret_read`): gates the `jwt_secret` field on the PostgREST config endpoint. - **Compute** (`workers_read` / `workers_write`): shared-types 0.1.95 also publishes the workers scopes, so they surface in the catalog now. Named to match Studio's product naming (#50208). - Minimum roles for the four new ids in `FGA_SCOPE_MINIMUM_ROLE`, transcribed from the OpenFGA model (secret reads: developer; workers read: readonly; workers write: developer). - Docs generator (`generateAccessControlPartials.mts`): - Drop the workers exclusion now that the scopes are live. - When an endpoint lists alternative permission sets (for example API keys read alone, or read plus secret read for reveal), a row's footnote now only considers the alternatives that include that row's own scope. Previously the API Key Secrets row would have said "Requires API Keys (Read), or API Keys (Read) and API Key Secrets (Read)". - Regenerated PAT guide tables. The committed Management API specs predate the secret scopes, so this also includes the same spec refresh the weekly docs bot performs (`chore(docs): refresh the Management API specs`, kept as its own commit). Besides the new rows it picks up two new upstream endpoints under Advisors and the branch rows. ## Verified - `pnpm --filter studio typecheck` clean on top of #50234. - Access token test suite passes, including the guard that the role table covers exactly the ids shared-types publishes. - Partial regeneration is idempotent, so the Docs Tests stale-table gate passes. ## Follow-ups (not in this PR) - `apps/docs/content/guides/getting-started/api-keys.mdx` says a fine-grained token needs `api_gateway_keys_read` for the `?reveal=true` example. It now also needs `api_gateway_keys_secret_read`. - `project:api_gateway_keys` still says "Read exposes API keys" in its risk reason, which overstates it now that secret values sit behind a separate scope. Rewording may mean revisiting its risk level. - The comment in `ComputeLayout.tsx` about shared-types not exposing `workers_read` is stale. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added permission support for API key secrets, Data API JWT secrets, and compute workers. * Added API endpoints to run project advisors and create branches. * Added support for additional log-drain destinations, including S3, Last9, and OTLP. * Added storage object versioning information to project configuration responses. * **Documentation** * Updated access-control documentation for new permissions, worker operations, advisor runs, and branch creation. * Clarified Data API configuration and secret descriptions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
338 lines
11 KiB
TypeScript
338 lines
11 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])))
|
|
|
|
// 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) => {
|
|
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
|
|
// An endpoint can list alternative permission sets (e.g. reading API keys needs
|
|
// `api_gateway_keys_read`, and revealing their secret values needs that plus
|
|
// `api_gateway_keys_secret_read`). Only the alternatives that include this row's own scope
|
|
// say anything about this row, so the footnote ignores the rest.
|
|
const relevantGroups = endpoint.groups.filter((group) =>
|
|
group.some((scope) => rowScopes.has(scope))
|
|
)
|
|
const unlocksAlone = relevantGroups.some((group) =>
|
|
group.every((scope) => rowScopes.has(scope))
|
|
)
|
|
let requirement = ''
|
|
if (!unlocksAlone) {
|
|
const text = `Requires ${formatRequirement(relevantGroups)}.`
|
|
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])
|