Files
supabase__supabase/apps/docs/spec/sections/generateAccessControlPartials.mts
Gildas Garcia 9b1dddde11 Scoped PAT: add api_gateway_keys_secret_read and data_api_config_secret_read permissions (#50134)
## 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>
2026-09-11 13:31:21 +08:00

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