fix(studio): model scoped pat permissions as OR-of-AND alternatives - smaller version (#48809)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Breaking down #48635


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Scoped access tokens now support alternative permission requirements,
enabling more precise access for APIs and tools.
- Added clearer role and resource access evaluation, including
project-specific permissions and partial read access.
- Access reviews now identify unavailable or excessive permissions and
group inaccessible resources for easier resolution.
- **Bug Fixes**
- Improved handling of legacy, incomplete, or invalid permission data
with safer fallback behavior.
  - Corrected access filtering for MCP tools and API capabilities.
- **Documentation**
- Updated access-review wording to clarify the relationship between
scopes and related MCP tools.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Wen Bo Xie <wenbox323@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-07 09:51:36 +01:00
1 parent 6c0439ace8
commit f8206a5f81
11 files changed
+999 -202

No files matched your search

@@ -4,36 +4,53 @@ import { McpMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
const { OAuthScope } = constants
// Manually extracted from platform mcp controller code
const MCPToolOAuthScopeMapping = {
apply_migration: [OAuthScope.DATABASE_WRITE],
create_branch: [OAuthScope.ENVIRONMENT_WRITE],
create_project: [OAuthScope.PROJECTS_WRITE],
delete_branch: [OAuthScope.ENVIRONMENT_WRITE],
deploy_edge_function: [OAuthScope.EDGE_FUNCTIONS_WRITE],
execute_sql: [OAuthScope.DATABASE_READ, OAuthScope.DATABASE_WRITE],
generate_typescript_types: [OAuthScope.DATABASE_READ],
get_security_advisors: [OAuthScope.DATABASE_READ],
get_performance_advisors: [OAuthScope.DATABASE_READ],
get_edge_function: [OAuthScope.EDGE_FUNCTIONS_READ],
get_logs: [OAuthScope.ANALYTICS_READ],
get_organization: [OAuthScope.ORGANIZATIONS_READ],
get_project: [OAuthScope.PROJECTS_READ],
get_project_url: [OAuthScope.PROJECTS_READ],
get_publishable_keys: [OAuthScope.SECRETS_READ],
get_storage_config: [OAuthScope.STORAGE_READ],
list_branches: [OAuthScope.ENVIRONMENT_READ],
list_edge_functions: [OAuthScope.EDGE_FUNCTIONS_READ],
list_migrations: [OAuthScope.DATABASE_READ],
list_organizations: [OAuthScope.ORGANIZATIONS_READ],
list_projects: [OAuthScope.PROJECTS_READ],
list_storage_buckets: [OAuthScope.STORAGE_READ],
merge_branch: [OAuthScope.ENVIRONMENT_WRITE],
pause_project: [OAuthScope.PROJECTS_WRITE],
rebase_branch: [OAuthScope.ENVIRONMENT_WRITE],
reset_branch: [OAuthScope.ENVIRONMENT_WRITE],
restore_project: [OAuthScope.PROJECTS_WRITE],
update_storage_config: [OAuthScope.STORAGE_WRITE],
type OAuthScopeValue = (typeof OAuthScope)[keyof typeof OAuthScope]
// Manually extracted from platform mcp controller code. Each tool maps to alternative OAuth-scope
// groups with the same semantics as ScopeGroupAlternatives: a token can call the tool when it
// holds ALL scopes of at least ONE group (OR between groups, AND within a group). Most tools
// assert a single scope; execute_sql asserts database:read OR database:write depending on the
// session's read_only mode (mcp.controller.ts), so it carries two alternatives.
const MCPToolOAuthScopeMapping: Record<string, OAuthScopeValue[][]> = {
apply_migration: [[OAuthScope.DATABASE_WRITE]],
// Computes a local confirmation hash without calling the platform — no scope gates it.
confirm_cost: [[]],
create_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
create_project: [[OAuthScope.PROJECTS_WRITE]],
delete_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
deploy_edge_function: [[OAuthScope.EDGE_FUNCTIONS_WRITE]],
execute_sql: [[OAuthScope.DATABASE_READ], [OAuthScope.DATABASE_WRITE]],
generate_typescript_types: [[OAuthScope.DATABASE_READ]],
get_advisors: [[OAuthScope.DATABASE_READ]],
// Calls getOrganization + listProjects to price a project, so it needs both read scopes.
// (The type=branch path returns a constant with no platform call; gating on the project
// path's scopes fails closed for branch-only pricing, which is fine for advisory display.)
get_cost: [[OAuthScope.ORGANIZATIONS_READ, OAuthScope.PROJECTS_READ]],
get_edge_function: [[OAuthScope.EDGE_FUNCTIONS_READ]],
get_logs: [[OAuthScope.ANALYTICS_READ]],
get_organization: [[OAuthScope.ORGANIZATIONS_READ]],
get_project: [[OAuthScope.PROJECTS_READ]],
get_project_url: [[OAuthScope.PROJECTS_READ]],
get_publishable_keys: [[OAuthScope.SECRETS_READ]],
get_storage_config: [[OAuthScope.STORAGE_READ]],
list_branches: [[OAuthScope.ENVIRONMENT_READ]],
list_edge_functions: [[OAuthScope.EDGE_FUNCTIONS_READ]],
// Runs through executeSql with read_only forced true.
list_extensions: [[OAuthScope.DATABASE_READ]],
list_migrations: [[OAuthScope.DATABASE_READ]],
list_organizations: [[OAuthScope.ORGANIZATIONS_READ]],
list_projects: [[OAuthScope.PROJECTS_READ]],
list_storage_buckets: [[OAuthScope.STORAGE_READ]],
// Runs through executeSql with read_only forced true.
list_tables: [[OAuthScope.DATABASE_READ]],
merge_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
pause_project: [[OAuthScope.PROJECTS_WRITE]],
rebase_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
reset_branch: [[OAuthScope.ENVIRONMENT_WRITE]],
restore_project: [[OAuthScope.PROJECTS_WRITE]],
// Queries the public content API — no scope gates it.
search_docs: [[]],
update_storage_config: [[OAuthScope.STORAGE_WRITE]],
}
type ExtractIds<T> = {
@@ -157,17 +174,30 @@ export const legacyOauthScopeToFgaPermissionMap: Record<string, string[]> = {
}
/*
* Build a map of MCP tools/FGA permissions by mapping their OAuth Scopes to the FGA permissions:
* Build a map of MCP tools/FGA permissions by expanding each OAuth-scope group to the FGA
* permissions it implies:
* {
* apply_migration: ["project_admin_write", ...]
* execute_sql: [["snippets_read", "database_read", ...], ["project_admin_write", ...]]
* }
* Groups are expanded independently, preserving the OR-of-AND structure. A group is an AND, so it
* is kept only when every one of its scopes maps to at least one FGA permission — a partial
* expansion would weaken the requirement (e.g. [ORGANIZATIONS_READ, PROJECTS_READ] shrinking to
* projects_read alone). A group with any unmapped scope is dropped whole, so the tool stays gated
* rather than becoming ungated; an explicitly empty group ([]) is the deliberate ungated marker
* and is vacuously kept.
* The code is duplicated from platform until we find a better way to share those mappings
*/
export const expandOAuthScopeGroups = (
oAuthScopeGroups: string[][],
fgaPermissionMap: Record<string, string[]>
): string[][] =>
oAuthScopeGroups
.filter((group) => group.every((oAuthScope) => (fgaPermissionMap[oAuthScope] ?? []).length > 0))
.map((group) => group.flatMap((oAuthScope) => fgaPermissionMap[oAuthScope] ?? []))
export const MCPToolScopeMappings = Object.entries(MCPToolOAuthScopeMapping).reduce(
(acc, [mcpTool, oAuthScopes]) => {
acc[mcpTool] = oAuthScopes.flatMap(
(oAuthScope) => legacyOauthScopeToFgaPermissionMap[oAuthScope]
)
(acc, [mcpTool, oAuthScopeGroups]) => {
acc[mcpTool] = expandOAuthScopeGroups(oAuthScopeGroups, legacyOauthScopeToFgaPermissionMap)
return acc
},
{} as McpMap
@@ -1,8 +1,16 @@
import { http, HttpResponse } from 'msw'
import { describe, expect, test } from 'vitest'
import { getEndpointsAndMCPToolsForAPI } from './buildAPIPermissionScopeMap'
import {
addMCPToolsToScopes,
buildAPIPermissionScopeMap,
getScopesAndEndpointsForAPI,
} from './buildAPIPermissionScopeMap'
import { expandOAuthScopeGroups, MCPToolScopeMappings } from './MCPToolScopeMappings'
import { type ScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { mswServer } from '@/tests/lib/msw'
describe('getEndpointsAndMCPToolsForAPI', () => {
describe('getScopesAndEndpointsForAPI', () => {
const openAPISpecs = {
paths: {
'/v1/projects/{ref}/database/migrations': {
@@ -18,36 +26,285 @@ describe('getEndpointsAndMCPToolsForAPI', () => {
'x-fga-permissions': [['database_migrations_write']],
},
},
// Alternative groups: development OR production (real shape of the branching endpoints)
'/v1/projects/{ref}/branches': {
get: {
'x-fga-permissions': [['branching_development_read'], ['branching_production_read']],
},
},
// No annotation -> not part of the map
'/v1/projects/available-regions': {
get: {},
},
},
}
const mcp_tools = {
list_migrations: ['database_migrations_read'],
apply_migration: ['database_migrations_write', 'database_write'],
}
test('returns an object with permissions as keys and endpoints and mcp_tools as values', () => {
const permissionScopeMap = getEndpointsAndMCPToolsForAPI(openAPISpecs, mcp_tools)
test('indexes scopes and endpoints, preserving alternative permission groups', () => {
const permissionScopeMap = getScopesAndEndpointsForAPI(openAPISpecs)
expect(permissionScopeMap).toEqual({
scopes: {
database_migrations_read: {
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
mcp_tools: ['list_migrations'],
mcp_tools: [],
},
database_migrations_write: {
endpoints: [
'POST /v1/projects/{ref}/database/migrations',
'PATCH /v1/projects/{ref}/database/migrations/{version}',
],
mcp_tools: ['apply_migration'],
mcp_tools: [],
},
branching_development_read: {
endpoints: ['GET /v1/projects/{ref}/branches'],
mcp_tools: [],
},
branching_production_read: {
endpoints: ['GET /v1/projects/{ref}/branches'],
mcp_tools: [],
},
},
endpoints: {
'GET /v1/projects/{ref}/database/migrations': ['database_migrations_read'],
'POST /v1/projects/{ref}/database/migrations': ['database_migrations_write'],
'PATCH /v1/projects/{ref}/database/migrations/{version}': ['database_migrations_write'],
'GET /v1/projects/{ref}/database/migrations': [['database_migrations_read']],
'POST /v1/projects/{ref}/database/migrations': [['database_migrations_write']],
'PATCH /v1/projects/{ref}/database/migrations/{version}': [['database_migrations_write']],
'GET /v1/projects/{ref}/branches': [
['branching_development_read'],
['branching_production_read'],
],
},
})
})
// An endpoint recorded with zero groups would read as ungated and be reported callable by every
// token, so unusable annotations must drop the endpoint instead.
test('drops endpoints with empty or unusable permission groups rather than marking them ungated', () => {
const { endpoints, scopes } = getScopesAndEndpointsForAPI({
paths: {
'/v1/empty-groups': { get: { 'x-fga-permissions': [[], undefined] } },
'/v1/unannotated': { get: {} },
},
})
expect(endpoints).toEqual({})
expect(scopes).toEqual({})
})
})
describe('addMCPToolsToScopes', () => {
test('assigns tools to every scope in any of their groups, without duplicates', () => {
const scopes: ScopeMap = {
database_migrations_read: {
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
mcp_tools: [],
},
database_migrations_write: { endpoints: [], mcp_tools: ['apply_migration'] },
}
addMCPToolsToScopes(scopes, {
list_migrations: [['database_migrations_read']],
apply_migration: [['database_migrations_write'], ['database_migrations_read']],
})
expect(scopes).toEqual({
database_migrations_read: {
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
mcp_tools: ['list_migrations', 'apply_migration'],
},
database_migrations_write: { endpoints: [], mcp_tools: ['apply_migration'] },
})
})
test('initializes scopes that only MCP tools reference', () => {
const scopes: ScopeMap = {}
addMCPToolsToScopes(scopes, { update_storage_config: [['storage_config_write']] })
expect(scopes.storage_config_write).toEqual({
endpoints: [],
mcp_tools: ['update_storage_config'],
})
})
test('leaves ungated tools out of the scope index', () => {
const scopes: ScopeMap = {}
addMCPToolsToScopes(scopes, { search_docs: [[]] })
expect(scopes).toEqual({})
})
})
describe('MCPToolScopeMappings', () => {
// Platform gates execute_sql on database:read OR database:write depending on the MCP session's
// read_only mode (mcp.controller.ts), so the derived requirement must be two alternatives — a
// single conjunctive group would hide the tool from read-only tokens the platform accepts.
test('execute_sql derives the database:read bundle OR the database:write bundle', () => {
expect(MCPToolScopeMappings.execute_sql).toHaveLength(2)
const [readGroup, writeGroup] = MCPToolScopeMappings.execute_sql
expect(readGroup).toContain('database_read')
expect(readGroup).not.toContain('database_write')
expect(writeGroup).toContain('database_write')
})
test('single-scope tools derive a single conjunctive group', () => {
expect(MCPToolScopeMappings.apply_migration).toHaveLength(1)
expect(MCPToolScopeMappings.apply_migration[0]).toContain('database_write')
})
test('tools without a platform scope gate stay ungated ([[]]), not disabled ([])', () => {
expect(MCPToolScopeMappings.confirm_cost).toEqual([[]])
expect(MCPToolScopeMappings.search_docs).toEqual([[]])
})
// A group is an AND: expanding only its mapped scopes would weaken the requirement (e.g.
// [organizations:read, projects:read] shrinking to projects_read alone) and report the tool
// enabled for an incomplete grant.
test('a group with any unmapped scope is dropped whole, not partially expanded', () => {
const map = { 'projects:read': ['projects_read'] }
expect(expandOAuthScopeGroups([['organizations:read', 'projects:read']], map)).toEqual([])
// Other alternatives and the ungated marker survive the drop untouched.
expect(expandOAuthScopeGroups([['organizations:read'], ['projects:read'], []], map)).toEqual([
['projects_read'],
[],
])
})
// Guards the OAuth-scope -> legacy-map join: a scope key drifting out of the legacy map must
// not inject undefined into the payload (flatMap doesn't flatten it) or silently disable a
// gated tool by dropping all its groups.
test('every derived group is non-empty strings, and only the ungated tools lack scopes', () => {
const ungated = ['confirm_cost', 'search_docs']
for (const [tool, groups] of Object.entries(MCPToolScopeMappings)) {
expect(groups.length, `${tool} lost all its alternatives`).toBeGreaterThan(0)
for (const group of groups) {
if (!ungated.includes(tool))
expect(group.length, `${tool} has an empty group`).toBeGreaterThan(0)
for (const scope of group)
expect(typeof scope, `${tool} leaked a non-string scope`).toBe('string')
}
}
})
test('get_cost requires both organization and project read bundles together', () => {
expect(MCPToolScopeMappings.get_cost).toHaveLength(1)
expect(MCPToolScopeMappings.get_cost[0]).toEqual(
expect.arrayContaining(['organizations_read', 'projects_read'])
)
})
// Drift guard: the exact tool registry of @supabase/mcp-server-supabase@0.8.1, the version the
// platform pins. When the platform bumps the MCP server, this list (and the mapping) must be
// re-derived from the controller's assertMcpOAuthScope calls.
test('covers exactly the tool registry of the deployed MCP server', () => {
expect(Object.keys(MCPToolScopeMappings).sort()).toEqual([
'apply_migration',
'confirm_cost',
'create_branch',
'create_project',
'delete_branch',
'deploy_edge_function',
'execute_sql',
'generate_typescript_types',
'get_advisors',
'get_cost',
'get_edge_function',
'get_logs',
'get_organization',
'get_project',
'get_project_url',
'get_publishable_keys',
'get_storage_config',
'list_branches',
'list_edge_functions',
'list_extensions',
'list_migrations',
'list_organizations',
'list_projects',
'list_storage_buckets',
'list_tables',
'merge_branch',
'pause_project',
'rebase_branch',
'reset_branch',
'restore_project',
'search_docs',
'update_storage_config',
])
})
})
describe('buildAPIPermissionScopeMap', () => {
// vitestSetup starts mswServer with `onUnhandledRequest: 'error'` and resets handlers between
// tests, so mocking here keeps that guard instead of replacing global fetch.
const stubSpecs = (v1: Record<string, unknown>, v2: Record<string, unknown>) => {
mswServer.use(
http.get('*/api/v1-json', () => HttpResponse.json(v1)),
http.get('*/api/v2-json', () => HttpResponse.json(v2))
)
}
test('merges both specs, attaching each MCP tool to a shared scope exactly once', async () => {
stubSpecs(
{
paths: {
'/v1/projects/{ref}/database/query': {
post: { 'x-fga-permissions': [['database_read']] },
},
},
},
{
paths: {
'/v2/projects/{ref}/inspect': { get: { 'x-fga-permissions': [['database_read']] } },
},
}
)
const map = await buildAPIPermissionScopeMap()
// Both specs contribute an endpoint to the same scope, and neither is duplicated
expect(map.scopes.database_read.endpoints).toEqual([
'POST /v1/projects/{ref}/database/query',
'GET /v2/projects/{ref}/inspect',
])
// The tools were attached after the merge, so a scope shared by both specs lists each once
const tools = map.scopes.database_read.mcp_tools
expect(new Set(tools).size).toBe(tools.length)
expect(tools).toContain('execute_sql')
})
// Path items may legally carry non-operation members; the specs are fetched live, so a benign
// upstream swagger change must not start 500ing this route.
test('tolerates path items with non-method OpenAPI members', async () => {
stubSpecs(
{
paths: {
'/v1/projects/{ref}': {
parameters: [{ name: 'ref', in: 'path', required: true }],
summary: 'Project detail',
get: { 'x-fga-permissions': [['project_admin_read']] },
},
},
},
{ paths: {} }
)
const map = await buildAPIPermissionScopeMap()
expect(map.endpoints['GET /v1/projects/{ref}']).toEqual([['project_admin_read']])
expect(Object.keys(map.endpoints)).toHaveLength(1)
})
test('returns a copy of the tool mapping so callers cannot corrupt the module singleton', async () => {
stubSpecs({ paths: {} }, { paths: {} })
const map = await buildAPIPermissionScopeMap()
expect(map.mcp_tools).toEqual(MCPToolScopeMappings)
expect(map.mcp_tools).not.toBe(MCPToolScopeMappings)
const before = structuredClone(MCPToolScopeMappings.execute_sql)
map.mcp_tools.execute_sql.push(['tampered'])
expect(MCPToolScopeMappings.execute_sql).toEqual(before)
})
})
@@ -1,4 +1,4 @@
import lodash from 'lodash'
import { cloneDeep } from 'lodash'
import z from 'zod'
// We don't have an OpenAPI that describes mcp tools security requirements so
@@ -12,96 +12,107 @@ import {
} from '@/data/scoped-access-tokens/permission-scope-map-query'
import { InternalServerError } from '@/lib/api/apiHelpers'
// Default lodash merge does not correctly merge arrays so this custom merger fixes it
function mergeArrays(objValue: unknown, srcValue: unknown) {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return objValue.concat(srcValue)
}
}
/*
* Builds the permissions/endpoint mapping by fetching the OpenAPI specs for our v1 and v2 APIs.
* The two specs are indexed together rather than merged afterwards: every v1 path starts with
* `/v1/` and every v2 path with `/v2/`, so they can't collide, and one pass de-duplicates a
* scope's endpoint list by construction.
* @throws InternalServerError when it can't fetch the OpenAPI specs
*/
export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap> => {
// Get the permissions map for the API v1
const apiV1SpecsJSON = await fetchAPIPermissionScope('v1')
const [apiV1SpecsJSON, apiV2SpecsJSON] = await Promise.all([
fetchAPIPermissionScope('v1'),
fetchAPIPermissionScope('v2'),
])
const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON)
const permissionScopeMapV1 = getEndpointsAndMCPToolsForAPI(apiV1Specs, MCPToolScopeMappings)
// Get the permissions map for the API v2
const apiV2SpecsJSON = await fetchAPIPermissionScope('v2')
const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON)
const permissionScopeMapV2 = getEndpointsAndMCPToolsForAPI(apiV2Specs, MCPToolScopeMappings)
const permissionScope = lodash.mergeWith(
{ mcp_tools: MCPToolScopeMappings },
permissionScopeMapV1,
permissionScopeMapV2,
mergeArrays
)
const { scopes, endpoints } = getScopesAndEndpointsForAPI({
paths: { ...apiV1Specs.paths, ...apiV2Specs.paths },
})
addMCPToolsToScopes(scopes, MCPToolScopeMappings)
return permissionScope
return {
scopes,
endpoints,
// Deep copy so a caller mutating the response can't corrupt the module-level mapping, which
// outlives every request in a long-running server.
mcp_tools: cloneDeep(MCPToolScopeMappings),
}
}
// OPEN API specs look like this (only kept the parts we're interested in):
// {
// "paths": {
// "/v2/projects/{ref}/analytics/log-drains": {
// "/v1/projects/{ref}/branches": {
// "get": {
// "x-fga-permissions": [
// [
// "analytics_config_read"
// ]
// ["branching_development_read"],
// ["branching_production_read"]
// ]
// }
// }
// }
// }
export const getEndpointsAndMCPToolsForAPI = (
apiSpecs: z.output<typeof API_SPECS_SCHEMA>,
mcp_tools: McpMap
// The extension value is a list of alternative permission groups: a token needs ALL permissions
// of at least ONE group (OR between groups, AND within a group). Groups must be preserved verbatim,
// not flattened, or OR-alternatives (e.g. development vs production branching) turn into impossible
// conjunctions. An endpoint with no usable group is dropped entirely rather than recorded with an
// empty requirement, which would read as "ungated" and mark it callable by every token.
//
// KNOWN DIVERGENCE: annotations are trusted verbatim, and the one on
// POST /v1/projects/{ref}/database/query overstates access — the spec publishes
// `[[database_read], [database_write]]`, but the route's guard requires database_read outright and
// the write group is doc-only (see the execute_sql entry in MCPToolScopeMappings.ts). A token
// granted only database_write is therefore shown this endpoint as callable when the guard would
// reject it. Studio-created tokens can't hit this (write mode always grants the read scopes too),
// so this stays a display inaccuracy for API-created tokens; the fix is correcting the annotation
// upstream in the mgmt-api, not special-casing it here.
export const getScopesAndEndpointsForAPI = (
apiSpecs: z.output<typeof API_SPECS_SCHEMA>
): Omit<PermissionScopeMap, 'mcp_tools'> => {
const scopes: ScopeMap = {}
const endpoints: EndpointMap = {}
// Loop over each API path to assign endpoints to their scopes and
// scopes to their endpoints
// Loop over each API path to record endpoint permission groups and index scopes -> endpoints
Object.entries(apiSpecs.paths).forEach(([path, methods]) => {
// Loop over each API path method (get, post, etc.)
Object.entries(methods).forEach(([method, methodSpecs]) => {
const endpoint = `${method.toUpperCase()} ${path}`
if (methodSpecs['x-fga-permissions'] == null) return
const groups = (methodSpecs['x-fga-permissions'] ?? []).filter(
(group): group is string[] => Array.isArray(group) && group.length > 0
)
if (groups.length === 0) return
methodSpecs['x-fga-permissions'].forEach((permissions) => {
permissions?.forEach((permission) => {
// Initialize scope object if needed
scopes[permission] = scopes[permission] || { endpoints: [], mcp_tools: [] }
endpoints[endpoint] = groups
// Initialize endpoints array if needed
endpoints[endpoint] = endpoints[endpoint] || []
groups.flat().forEach((permission) => {
// Initialize scope object if needed
scopes[permission] = scopes[permission] || { endpoints: [], mcp_tools: [] }
if (!scopes[permission].endpoints.includes(endpoint)) {
scopes[permission].endpoints.push(endpoint)
}
if (!endpoints[endpoint].includes(permission)) {
endpoints[endpoint].push(permission)
}
})
if (!scopes[permission].endpoints.includes(endpoint)) {
scopes[permission].endpoints.push(endpoint)
}
})
})
})
// Assign the mcp tools to their scopes
Object.entries(mcp_tools).forEach(([mcpTool, toolScopes]) => {
toolScopes.forEach((toolScope) => {
if (scopes[toolScope] && !scopes[toolScope].mcp_tools.includes(mcpTool)) {
return { scopes, endpoints }
}
// Assign the mcp tools to their scopes. Scopes referenced only by tools (no endpoint lists them,
// e.g. project_snippets_read) are initialized here so they don't vanish from the map. Ungated tools
// reference no scope, so they're absent from this index by construction — they belong to no
// capability and `getEnabledMcpTools` reports them for every token instead.
export const addMCPToolsToScopes = (scopes: ScopeMap, mcp_tools: McpMap) => {
Object.entries(mcp_tools).forEach(([mcpTool, toolScopeGroups]) => {
toolScopeGroups.flat().forEach((toolScope) => {
scopes[toolScope] = scopes[toolScope] || { endpoints: [], mcp_tools: [] }
if (!scopes[toolScope].mcp_tools.includes(mcpTool)) {
scopes[toolScope].mcp_tools.push(mcpTool)
}
})
})
return { scopes, endpoints }
}
const NEXT_PUBLIC_API_DOMAIN = process.env.NEXT_PUBLIC_API_DOMAIN || 'https://api.supabase.com'
@@ -142,12 +153,22 @@ const OPEN_API_PATH_METHOD_SCHEMA = z.object({
'x-fga-permissions': z.array(z.string().array().optional()).optional(),
})
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'] as const
// OpenAPI path items may legally carry non-operation members (path-level `parameters`, `summary`,
// `description`, `servers`, `$ref`). z.record with an enum key schema rejects unknown keys
// outright, which would turn a benign upstream spec change into a 500 for this whole route —
// strip them before validating the operations.
const OPEN_API_PATH_ITEM_SCHEMA = z.preprocess(
(item) =>
item !== null && typeof item === 'object'
? Object.fromEntries(
Object.entries(item).filter(([key]) => (HTTP_METHODS as readonly string[]).includes(key))
)
: item,
z.record(z.enum(HTTP_METHODS), OPEN_API_PATH_METHOD_SCHEMA)
)
const API_SPECS_SCHEMA = z.object({
paths: z.record(
z.string(),
z.record(
z.enum(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']),
OPEN_API_PATH_METHOD_SCHEMA
)
),
paths: z.record(z.string(), OPEN_API_PATH_ITEM_SCHEMA),
})
@@ -18,7 +18,9 @@ export const permissionRow = (
organization_slug: string,
actions: string[],
resources: string[],
project_refs: string[] = []
// Org-wide rows serialize as [] or null on the wire (nullable in the API contract; the
// view-synthesized admin rows for auth.subject_roles/user_invites are null).
project_refs: string[] | null = []
): PermissionRowFixture => ({
actions: actions as Permission['actions'],
condition: null as unknown as Permission['condition'],
@@ -4,14 +4,121 @@ import {
computeOverallRisk,
countConfigured,
getCatalogEntry,
scopesToSelection,
selectionToScopes,
type PermissionSelection,
} from './AccessToken.permissions'
import {
getEnabledEndpoints,
getEnabledEndpointsForCapability,
getEnabledMcpTools,
normalizePermissionScopeMap,
type PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
const scopeMap = (partial: Partial<PermissionScopeMap>): PermissionScopeMap => ({
scopes: {},
endpoints: {},
mcp_tools: {},
...partial,
})
describe('scopesToSelection', () => {
it('round-trips studio-created grants (full read/write sets)', () => {
expect(scopesToSelection(['database_read', 'database_write'])).toEqual({
'project:database': 'readwrite',
})
expect(scopesToSelection(['database_read'])).toEqual({ 'project:database': 'read' })
expect(scopesToSelection([])).toEqual({})
})
// API-created tokens can hold partial scope sets; the derived mode is an upper bound so the
// review UI never claims 'Minimal — no capabilities' for a token with real authority.
it('never drops partial grants from API-created tokens', () => {
// A lone create scope (one of the entry's three write scopes) still surfaces as readwrite.
expect(scopesToSelection(['branching_development_create'])).toEqual({
'project:branching_development': 'readwrite',
})
// A write grant without the read scopes still surfaces (upper-bound readwrite).
expect(scopesToSelection(['project_admin_write'])).toEqual({ 'project:admin': 'readwrite' })
})
})
describe('normalizePermissionScopeMap', () => {
// A stale CDN entry can serve the pre-groups payload (flat conjunctive string[] per endpoint)
// to a client whose evaluators expect string[][]; without normalization group.every crashes.
it('interprets a stale flat payload as single conjunctive groups', () => {
const normalized = normalizePermissionScopeMap({
scopes: {},
endpoints: {
'GET /v1/projects': ['projects_read', 'organization_projects_read'],
},
mcp_tools: { execute_sql: ['database_read', 'database_write'] },
})
expect(normalized.endpoints['GET /v1/projects']).toEqual([
['projects_read', 'organization_projects_read'],
])
expect(normalized.mcp_tools.execute_sql).toEqual([['database_read', 'database_write']])
// The normalized shape evaluates without throwing
expect(
getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap: normalized })
).toEqual([])
})
it('passes the current grouped payload through unchanged', () => {
const grouped: PermissionScopeMap = {
scopes: {},
endpoints: { 'GET /v1/branches': [['branching_development_read']] },
mcp_tools: { search_docs: [[]], broken_tool: [] },
}
expect(normalizePermissionScopeMap(grouped)).toEqual(grouped)
})
it('tolerates payloads missing any of the three maps entirely', () => {
const normalized = normalizePermissionScopeMap({})
expect(normalized.scopes).toEqual({})
expect(normalized.endpoints).toEqual({})
expect(normalized.mcp_tools).toEqual({})
})
it('fails closed (nobody) on values that are not arrays at all', () => {
const normalized = normalizePermissionScopeMap({
scopes: {},
endpoints: { 'GET /v1/projects': null },
mcp_tools: { execute_sql: 'database_read' },
})
expect(normalized.endpoints['GET /v1/projects']).toEqual([])
expect(normalized.mcp_tools.execute_sql).toEqual([])
})
// response.json() can legally produce any of these (a null body, an error string); the
// normalizer is the boundary and must return the fail-closed empty map, not throw.
it('fails closed on a top-level payload that is not an object', () => {
const empty = { scopes: {}, endpoints: {}, mcp_tools: {} }
expect(normalizePermissionScopeMap(null)).toEqual(empty)
expect(normalizePermissionScopeMap(undefined)).toEqual(empty)
expect(normalizePermissionScopeMap('internal server error')).toEqual(empty)
expect(normalizePermissionScopeMap([])).toEqual(empty)
})
it('empties a field whose record shape is wrong without discarding the rest', () => {
const normalized = normalizePermissionScopeMap({
scopes: { database_read: { endpoints: 'not-an-array', mcp_tools: [] } },
endpoints: { 'GET /v1/projects': [['projects_read']] },
mcp_tools: null,
})
expect(normalized.scopes).toEqual({})
expect(normalized.endpoints['GET /v1/projects']).toEqual([['projects_read']])
expect(normalized.mcp_tools).toEqual({})
})
})
describe('selectionToScopes', () => {
it('ignores none and returns read scope for read mode', () => {
const selection: PermissionSelection = { 'project:database': 'read', 'project:backups': 'none' }
@@ -75,56 +182,162 @@ describe('countConfigured', () => {
})
})
describe('permission scope map (dual-scope enforcement)', () => {
it('enables a dual-scope MCP tool only when all required scopes are granted', () => {
// execute_sql requires both database_read and database_write
describe('permission scope map (group enforcement)', () => {
it('enables a multi-scope MCP tool group only when all scopes of the group are granted', () => {
// create_project requires org read + org project create together (the handler's FGA checks)
const permissionScopeMap = scopeMap({
mcp_tools: { create_project: [['organization_admin_read', 'organization_projects_create']] },
})
expect(
getEnabledMcpTools({
// Only scope one is granted
grantedScopes: ['database_read'],
permissionScopeMap: {
scopes: {},
endpoints: {},
mcp_tools: {
execute_sql: ['database_read', 'database_write'],
},
},
grantedScopes: ['organization_projects_create'],
permissionScopeMap,
})
).not.toContain('execute_sql')
).not.toContain('create_project')
expect(
getEnabledMcpTools({
// Both scopes are granted
grantedScopes: ['database_read', 'database_write'],
permissionScopeMap: {
scopes: {},
endpoints: {},
mcp_tools: {
execute_sql: ['database_read', 'database_write'],
},
},
grantedScopes: ['organization_admin_read', 'organization_projects_create'],
permissionScopeMap,
})
).toContain('execute_sql')
).toContain('create_project')
})
it('only lists endpoints whose every required scope is granted', () => {
it('enables a tool with alternative groups when any single group is fully granted', () => {
// execute_sql requires database_read OR database_write, depending on read-only mode
const permissionScopeMap = scopeMap({
mcp_tools: { execute_sql: [['database_read'], ['database_write']] },
})
expect(getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap })).toContain(
'execute_sql'
)
expect(getEnabledMcpTools({ grantedScopes: ['database_write'], permissionScopeMap })).toContain(
'execute_sql'
)
expect(
getEnabledMcpTools({ grantedScopes: ['storage_read'], permissionScopeMap })
).not.toContain('execute_sql')
})
it('reports ungated tools (one empty group) as enabled for any token, including one with no scopes', () => {
// search_docs hits the public content API and confirm_cost computes a local hash, so no
// permission gates either — the review step should say so rather than hide them.
const permissionScopeMap = scopeMap({
mcp_tools: { search_docs: [[]], confirm_cost: [[]] },
})
expect(getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap })).toEqual([
'search_docs',
'confirm_cost',
])
expect(getEnabledMcpTools({ grantedScopes: [], permissionScopeMap })).toEqual([
'search_docs',
'confirm_cost',
])
})
it('never enables a tool whose alternatives were all dropped ([])', () => {
const permissionScopeMap = scopeMap({ mcp_tools: { broken_tool: [] } })
expect(
getEnabledMcpTools({ grantedScopes: ['database_read'], permissionScopeMap })
).not.toContain('broken_tool')
})
it('lists endpoints when at least one alternative group is fully granted', () => {
const endpoints = getEnabledEndpoints({
grantedScopes: ['database_read', 'database_write'],
permissionScopeMap: {
scopes: {},
grantedScopes: ['database_read', 'database_write', 'branching_development_read'],
permissionScopeMap: scopeMap({
endpoints: {
'GET /api/valid_read': ['database_read'],
'POST /api/valid_write': ['database_write'],
'PUT /api/valid_both': ['database_read', 'database_write'],
'PUT /api/invalid': ['project_write'],
'PUT /api/incomplete': ['database_read', 'project_write'],
'GET /api/valid_read': [['database_read']],
'POST /api/valid_write': [['database_write']],
'PUT /api/valid_both': [['database_read', 'database_write']],
// development OR production: development alone is enough
'GET /api/valid_alternative': [
['branching_development_read'],
['branching_production_read'],
],
'PUT /api/invalid': [['project_write']],
'PUT /api/incomplete': [['database_read', 'project_write']],
},
mcp_tools: {},
},
}),
})
expect(endpoints).toEqual([
{ raw: 'GET /api/valid_read', method: 'GET', path: '/api/valid_read' },
{ raw: 'POST /api/valid_write', method: 'POST', path: '/api/valid_write' },
{ raw: 'PUT /api/valid_both', method: 'PUT', path: '/api/valid_both' },
{ raw: 'GET /api/valid_alternative', method: 'GET', path: '/api/valid_alternative' },
])
})
})
describe('getEnabledEndpointsForCapability', () => {
const rawPaths = (endpoints: ReturnType<typeof getEnabledEndpointsForCapability>) =>
endpoints.map(({ raw }) => raw)
it('attributes an endpoint to each capability whose scope is in a fully-granted group', () => {
const permissionScopeMap = scopeMap({
endpoints: {
'GET /api/branches': [['branching_development_read'], ['branching_production_read']],
},
})
const allGrantedScopes = ['branching_development_read', 'branching_production_read']
expect(
rawPaths(
getEnabledEndpointsForCapability({
capabilityScopes: ['branching_development_read'],
allGrantedScopes,
permissionScopeMap,
})
)
).toEqual(['GET /api/branches'])
expect(
rawPaths(
getEnabledEndpointsForCapability({
capabilityScopes: ['branching_production_read'],
allGrantedScopes,
permissionScopeMap,
})
)
).toEqual(['GET /api/branches'])
})
// The endpoint is callable, but thanks to the development alternative — production granted alone
// would not have enabled it, so it must not be listed under the production capability.
it('does not attribute an endpoint to a capability whose own group is unsatisfied', () => {
const enabled = getEnabledEndpointsForCapability({
capabilityScopes: ['branching_production_read'],
allGrantedScopes: ['branching_development_read'],
permissionScopeMap: scopeMap({
endpoints: {
'GET /api/branches': [['branching_development_read'], ['branching_production_read']],
},
}),
})
expect(enabled).toEqual([])
})
it('requires every scope of the capability group to be granted', () => {
const permissionScopeMap = scopeMap({
endpoints: { 'PUT /api/upgrade': [['project_admin_read', 'database_read']] },
})
expect(
getEnabledEndpointsForCapability({
capabilityScopes: ['database_read'],
allGrantedScopes: ['database_read'],
permissionScopeMap,
})
).toEqual([])
expect(
rawPaths(
getEnabledEndpointsForCapability({
capabilityScopes: ['database_read'],
allGrantedScopes: ['database_read', 'project_admin_read'],
permissionScopeMap,
})
)
).toEqual(['PUT /api/upgrade'])
})
})
@@ -447,7 +447,21 @@ const RESOURCE_METADATA_FALLBACK = (
: 'Read-only access to this resource.',
})
export type PermissionLevel = 'user' | 'organization' | 'project'
const PERMISSION_LEVELS = ['user', 'organization', 'project'] as const
export type PermissionLevel = (typeof PERMISSION_LEVELS)[number]
/**
* Runtime guard for the FGA namespaces: role evaluation branches on the level, so an unrecognized
* namespace must fail loudly (at module load, caught by any test importing the catalog) rather
* than silently evaluate as project-level.
*/
const toPermissionLevel = (scope: string): PermissionLevel => {
const level = scope.toLowerCase()
const match = PERMISSION_LEVELS.find((candidate) => candidate === level)
if (match === undefined) throw new Error(`Unknown FGA namespace: ${scope}`)
return match
}
export interface PermissionCatalogEntry {
/** Derived resource key, e.g. "project:database" */
@@ -490,7 +504,7 @@ const buildCatalog = (): PermissionCatalogEntry[] => {
>()
for (const [scope, scopePerms] of Object.entries(FGA)) {
const level = scope.toLowerCase() as PermissionLevel
const level = toPermissionLevel(scope)
for (const [permKey, perm] of Object.entries(scopePerms)) {
const resourceKey = `${level}:${getResource(permKey)}`
const action = getAction(permKey)
@@ -570,15 +584,22 @@ export const selectionToScopes = (
return Array.from(new Set(scopes))
}
/** Reverses `selectionToScopes`: derives a selection from a token's granted FGA scope ids. */
/**
* Reverses `selectionToScopes`: derives a selection from a token's granted FGA scope ids.
*
* Tokens created through the Management API can hold arbitrary scope subsets that the
* none/read/readwrite modes cannot represent exactly (e.g. a lone branching_development_create).
* Any granted scope of an entry marks it at the corresponding mode, so a partial grant is never
* dropped — the mode is an upper bound and may name specific operations the token lacks, but it
* never understates the token's authority or risk. The endpoint and MCP-tool lists, computed
* from the actual granted scopes, remain the precise view.
*/
export const scopesToSelection = (grantedScopes: string[]): PermissionSelection => {
const granted = new Set(grantedScopes)
const selection: PermissionSelection = {}
for (const entry of PERMISSION_CATALOG) {
const hasWrite =
entry.writeScopes.length > 0 && entry.writeScopes.every((scope) => granted.has(scope))
const hasRead =
entry.readScopes.length > 0 && entry.readScopes.every((scope) => granted.has(scope))
const hasWrite = entry.writeScopes.some((scope) => granted.has(scope))
const hasRead = entry.readScopes.some((scope) => granted.has(scope))
if (hasWrite) selection[entry.key] = 'readwrite'
else if (hasRead) selection[entry.key] = 'read'
}
@@ -22,7 +22,10 @@ import {
type EvaluateTokenAccessArgs = TokenRoleContextArgs & { selection: PermissionSelection }
/** Composes the two production entry points the way `useTokenAccessEvaluation` does. */
/**
* Composes the two production entry points the way a consumer should: resolve the (expensive)
* role context once from its inputs, then apply the (cheap) selection to it on every change.
*/
const evaluateTokenAccess = ({ selection, ...contextArgs }: EvaluateTokenAccessArgs) =>
applySelectionToRoleContext(computeTokenRoleContext(contextArgs), selection)
@@ -83,6 +86,19 @@ describe('project-scoped membership helpers', () => {
expect(getIsProjectScopedOnly(developerRows(ORG.slug), ORG.slug)).toBe(false)
expect(getIsProjectScopedOnly([], ORG.slug)).toBe(false)
})
it('treats project_refs: null rows as org-wide, in any row order', () => {
// The real /platform/profile/permissions response serializes the view-synthesized
// Administrator/Owner rows (auth.subject_roles, user_invites) with project_refs: null, and
// the response carries no ordering guarantee.
const nullRow = row(ORG.slug, ['write:Create', 'write:Delete'], ['auth.subject_roles'])
nullRow.project_refs = null
expect(getIsProjectScopedOnly([nullRow, ...administratorRows(ORG.slug)], ORG.slug)).toBe(false)
expect(getIsProjectScopedOnly([...administratorRows(ORG.slug), nullRow], ORG.slug)).toBe(false)
// A lone null row is org-wide too, not project-scoped.
expect(getIsProjectScopedOnly([nullRow], ORG.slug)).toBe(false)
})
})
describe('requiredRoleForEntry', () => {
@@ -118,6 +134,35 @@ describe('evaluateTokenAccess', () => {
expect(result.entries['project:database'].status).toBe('unknown')
})
it('normalizes effectiveSelection on every path, not just the evaluated one', () => {
const selection: PermissionSelection = {
'project:database': 'readwrite',
'project:backups': 'none',
'not:a-real-key': 'read',
}
const expected = { 'project:database': 'readwrite' }
// Unknown path (permissions still loading)
expect(
evaluateTokenAccess({ ...baseArgs, selection, permissions: undefined }).effectiveSelection
).toEqual(expected)
// Account path (selection tracks the owner by definition)
expect(
evaluateTokenAccess({
...baseArgs,
selection,
resourceAccess: 'account',
organizationSlugs: [],
permissions: ownerRows(ORG.slug),
}).effectiveSelection
).toEqual(expected)
// Evaluated path
expect(
evaluateTokenAccess({ ...baseArgs, selection, permissions: ownerRows(ORG.slug) })
.effectiveSelection
).toEqual(expected)
})
it('passes everything the user’s role covers', () => {
const result = evaluateTokenAccess({
...baseArgs,
@@ -196,26 +241,88 @@ describe('evaluateTokenAccess', () => {
})
// Developer on the bound project: database readwrite is fine.
expect(result.entries['project:database'].status).toBe('ok')
// But org-level scopes check the org-wide role, which is only member — and the failure
// carries their real per-project role so the UI can explain the distinction.
expect(result.entries['organization:members'].status).toBe('exceeds-role')
expect(result.entries['organization:members'].failingResources).toEqual([
// Org-level scopes can never be exercised through a project-scoped token — platform rejects
// them outright regardless of the owner's role, so no role evaluation applies.
expect(result.entries['organization:members'].status).toBe('unavailable-for-scope')
expect(result.entries['organization:members'].effectiveMode).toBe('none')
expect(result.entries['organization:members'].failingResources).toEqual([])
expect(result.unavailableEntryKeys).toEqual(['organization:members'])
expect(result.exceedingEntryKeys).toEqual([])
expect(result.effectiveSelection).toEqual({ 'project:database': 'readwrite' })
})
it('honors project-scoped roles for project entries on organization-scoped tokens', () => {
// An org member invited as Developer to one project: platform checks the owner's permission
// against the project object, so an org-bound token really can database_write there. Only the
// projects where the role is insufficient may be reported as failing.
const result = evaluateTokenAccess({
...baseArgs,
selection: { 'project:database': 'readwrite' },
permissions: developerRows(ORG.slug, [PROJECT.ref]),
organizations: [ORG],
projects: [PROJECT, OTHER_PROJECT],
})
expect(result.entries['project:database'].status).toBe('exceeds-role')
expect(result.entries['project:database'].failingResources).toEqual([
{ type: 'project', id: OTHER_PROJECT.ref, label: OTHER_PROJECT.ref, role: 'member' },
])
// Developer on every project of the org: nothing fails, despite the org role being member.
const allProjects = evaluateTokenAccess({
...baseArgs,
selection: { 'project:database': 'readwrite' },
permissions: developerRows(ORG.slug, [PROJECT.ref, OTHER_PROJECT.ref]),
organizations: [ORG],
projects: [PROJECT, OTHER_PROJECT],
})
expect(allProjects.entries['project:database'].status).toBe('ok')
expect(allProjects.effectiveSelection).toEqual({ 'project:database': 'readwrite' })
})
it('falls back to the org level for bound orgs with no accessible projects', () => {
const result = evaluateTokenAccess({
...baseArgs,
selection: { 'project:database': 'readwrite' },
permissions: readonlyRows(ORG.slug),
organizations: [ORG],
projects: [],
})
expect(result.entries['project:database'].status).toBe('exceeds-role')
expect(result.entries['project:database'].failingResources).toEqual([
{
type: 'organization',
id: ORG.slug,
label: ORG.slug,
role: 'member',
projectScopedRoles: [{ label: PROJECT.ref, role: 'developer' }],
role: 'readonly',
projectScopedRoles: undefined,
},
])
})
it('explains org-level failures for members invited only to a project', () => {
// Read-only on one project, selecting Organization Settings read-write (requires Owner).
it('marks org-level entries unavailable on project-scoped tokens even for org owners', () => {
// Platform's getChecks throws for project-scoped tokens on organization endpoints before
// any FGA evaluation, so even an org owner's project token can never call them.
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref],
selection: { 'organization:members': 'readwrite', 'user:organizations': 'read' },
permissions: ownerRows(ORG.slug),
})
expect(result.entries['organization:members'].status).toBe('unavailable-for-scope')
// User-level scopes are granted by the token grant alone (token -> scope tuple checks), so
// they stay exercisable for any resource binding.
expect(result.entries['user:organizations'].status).toBe('ok')
expect(result.effectiveSelection).toEqual({ 'user:organizations': 'read' })
})
it('explains org-level failures for members invited only to a project', () => {
// Read-only on one project, selecting Organization Settings read-write (requires Owner) on
// an organization-scoped token — the failure carries their real per-project role so the UI
// can explain the distinction.
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'organization',
selection: { 'organization:admin': 'readwrite' },
permissions: readonlyRows(ORG.slug, [PROJECT.ref]),
organizations: [{ ...ORG, name: 'Acme Corp' }],
@@ -242,8 +349,7 @@ describe('evaluateTokenAccess', () => {
const strayOrgRow = row(ORG.slug, ['read:Read'], ['notifications'])
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref],
resourceAccess: 'organization',
selection: { 'organization:admin': 'readwrite' },
permissions: [...readonlyRows(ORG.slug, [PROJECT.ref]), strayOrgRow],
projects: [{ ...PROJECT, name: 'Acme production' }],
@@ -201,7 +201,10 @@ export const estimateRoleLevel = (
return isMember ? 'member' : 'none'
}
/** True when every permission row the user holds in the org is limited to specific projects. */
/**
* True when every permission row the user holds in the org is limited to specific projects.
* Org-wide rows arrive as [] or null (the API contract is nullable) — both mean not scoped.
*/
export const getIsProjectScopedOnly = (
permissions: Permission[],
organizationSlug: string
@@ -211,7 +214,7 @@ export const getIsProjectScopedOnly = (
)
if (orgRows.length === 0) return false
return orgRows.every(
(permission) => permission.project_refs !== undefined && permission.project_refs.length > 0
(permission) => Array.isArray(permission.project_refs) && permission.project_refs.length > 0
)
}
@@ -231,7 +234,12 @@ export const requiredRoleForEntry = (
): TokenRoleLevel =>
mode === 'none' ? 'member' : requiredRoleForScopes(getEntryScopes(entry, mode))
export type EntryAccessStatus = 'ok' | 'exceeds-role' | 'unknown'
/**
* 'unavailable-for-scope': the entry's permission level can never be exercised through this
* token's resource binding, regardless of the owner's role — platform rejects project-scoped
* tokens outright on organization endpoints.
*/
export type EntryAccessStatus = 'ok' | 'exceeds-role' | 'unavailable-for-scope' | 'unknown'
/** A token-bound resource where the user's current role can't exercise the selected mode. */
export interface FailingResource {
@@ -275,7 +283,13 @@ export interface TokenAccessEvaluation {
entries: Record<string, EntryAccess>
/** Entry keys whose selected mode exceeds the user's current role. */
exceedingEntryKeys: string[]
/** Selection reduced to what the user's current role can exercise. */
/** Entry keys the token's resource binding can never exercise (org entries on project tokens). */
unavailableEntryKeys: string[]
/**
* Selection reduced to what the user's current role can exercise. Always normalized to
* catalog-known, non-'none' entries — including on the 'unknown' and account paths, where no
* reduction applies.
*/
effectiveSelection: PermissionSelection
}
@@ -307,7 +321,11 @@ export interface TokenRoleContext {
hasNoAccessibleResource: boolean
/** Per bound organization (or parent org in project mode). */
orgLevels: FailingResource[]
/** Per bound project in project mode; mirrors orgLevels otherwise (org roles cascade). */
/**
* Per bound project in project mode; per accessible project of the bound orgs in organization
* mode (platform checks project permissions against the project object, so project-scoped
* roles count). Orgs with no accessible projects contribute their org level instead.
*/
projectLevels: FailingResource[]
/** Weakest role across orgLevels / projectLevels. */
orgLevel: TokenRoleLevel
@@ -435,15 +453,27 @@ export const computeTokenRoleContext = ({
}
})
const toProjectLevel = (project: { ref: string; organization_slug: string; name?: string }) => ({
type: 'project' as const,
id: project.ref,
label: project.name ?? project.ref,
role: roleFor(project.organization_slug, project.ref),
})
// In organization mode the token's scope cascades to every project of the bound orgs, and
// platform checks the owner's permission against the project object — so a project-scoped
// Developer really can exercise e.g. database_write on their project through an org-bound
// token. Evaluate project-level entries per accessible project rather than by the org-level
// role, falling back to the org level for orgs with no accessible projects. Future projects
// only ever inherit the org-level role; the per-project view can't warn about those.
const projectLevels: FailingResource[] =
resourceAccess === 'project'
? accessibleProjects.map((project) => ({
type: 'project' as const,
id: project.ref,
label: project.name ?? project.ref,
role: roleFor(project.organization_slug, project.ref),
}))
: orgLevels
? accessibleProjects.map(toProjectLevel)
: orgSlugsForLevels.flatMap((slug) => {
const orgProjects = projects.filter((project) => project.organization_slug === slug)
if (orgProjects.length === 0) return orgLevels.filter((level) => level.id === slug)
return orgProjects.map(toProjectLevel)
})
return {
status: 'evaluated',
@@ -468,6 +498,15 @@ export const applySelectionToRoleContext = (
context: TokenRoleContext,
selection: PermissionSelection
): TokenAccessEvaluation => {
// Every path reports entries and effectiveSelection over the same normalized key set, so
// consumers can iterate either without special-casing 'none' modes or unknown catalog keys.
const selectedKeys = Object.keys(selection).filter(
(key) => selection[key] !== 'none' && getCatalogEntry(key) !== undefined
)
const normalizedSelection: PermissionSelection = Object.fromEntries(
selectedKeys.map((key) => [key, selection[key]])
)
const base = {
status: context.status,
inaccessibleOrgSlugs: context.inaccessibleOrgSlugs,
@@ -475,11 +514,10 @@ export const applySelectionToRoleContext = (
hasNoBoundResources: context.hasNoBoundResources,
hasNoAccessibleResource: context.hasNoAccessibleResource,
exceedingEntryKeys: [] as string[],
effectiveSelection: selection,
unavailableEntryKeys: [] as string[],
effectiveSelection: normalizedSelection,
}
const selectedKeys = Object.keys(selection).filter((key) => selection[key] !== 'none')
// Account-scoped (legacy/user) tokens track the owner's access by definition — every entry is
// exercisable, so requiredRole/failingResources (only read for 'exceeds-role' entries) stay inert.
if (context.status === 'evaluated' && context.resourceAccess === 'account') {
@@ -509,6 +547,7 @@ export const applySelectionToRoleContext = (
const { orgLevel, projectLevel, orgLevels, projectLevels } = context
const entries: Record<string, EntryAccess> = {}
const exceedingEntryKeys: string[] = []
const unavailableEntryKeys: string[] = []
const effectiveSelection: PermissionSelection = {}
for (const key of selectedKeys) {
@@ -516,6 +555,23 @@ export const applySelectionToRoleContext = (
const entry = getCatalogEntry(key)
if (!entry) continue
// Platform rejects project-scoped tokens outright on organization endpoints (getChecks
// throws before any FGA evaluation), so the owner's org role is irrelevant there and
// role-based evaluation would wrongly report these entries as exercisable. The one
// exception — organization_admin_write is additionally enforced on a few project-ref
// routes via the model's `from parent_organization` indirection — is deliberately
// ignored: this advisory UI fails closed.
if (context.resourceAccess === 'project' && entry.level === 'organization') {
entries[key] = {
status: 'unavailable-for-scope',
effectiveMode: 'none',
requiredRole: requiredRoleForEntry(entry, mode),
failingResources: [],
}
unavailableEntryKeys.push(key)
continue
}
const availableLevel =
entry.level === 'user' ? 'owner' : entry.level === 'organization' ? orgLevel : projectLevel
@@ -544,7 +600,7 @@ export const applySelectionToRoleContext = (
if (effectiveMode !== 'none') effectiveSelection[key] = effectiveMode
}
return { ...base, entries, exceedingEntryKeys, effectiveSelection }
return { ...base, entries, exceedingEntryKeys, unavailableEntryKeys, effectiveSelection }
}
export interface FailingResourceGroup {
@@ -76,8 +76,12 @@ export const RiskMarker = ({
)}
{mcpTools.length > 0 && (
<div className="space-y-1">
{/* getMcpToolsForScopes is associative, not conjunctive: these scopes contribute to
the listed tools, but a tool may need scopes from other capabilities too — the
review step's enabled-tools list is the authoritative view. Keep this heading
distinct from the review step's "MCP tools". */}
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
MCP tools
Related MCP tools
</p>
<p className="font-mono text-xs text-foreground-light">{mcpTools.join(', ')}</p>
</div>
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { scopedAccessTokenKeys } from './keys'
import { BASE_PATH } from '@/lib/constants'
@@ -28,17 +29,35 @@ export interface ScopeMapEntry {
}
*/
export type ScopeMap = Record<string, ScopeMapEntry>
// e.g { 'GET /v2/projects/{ref}/analytics/log-drains': ['analytics_config_read'] }
export type EndpointMap = Record<string, string[]>
// e.g { 'deploy_edge_function': ['edge_functions_write'] }
export type McpMap = Record<string, string[]>
/** A set of scopes that must ALL be granted (conjunctive). */
export type ScopeGroup = string[]
/**
* Alternative scope groups, mirroring the mgmt-api `x-fga-permissions` semantics: a token satisfies
* the requirement when it holds ALL scopes of at least ONE group — OR between groups, AND within a
* group. `GET /v1/projects/{ref}/branches` is annotated
* `[['branching_development_read'], ['branching_production_read']]`, and either alternative alone
* authorizes the call.
*
* Standard disjunctive-normal-form semantics apply at the edges, so no special cases are needed:
* `[[]]` — one empty group — is satisfied by every token (an empty AND is true), which is how MCP
* tools that make no Management API call are recorded. `[]` — no alternatives at all — is satisfied
* by nobody (an empty OR is false), so an item that somehow loses its groups is hidden rather than
* advertised to everyone.
*/
export type ScopeGroupAlternatives = ScopeGroup[]
// e.g { 'GET /v1/projects/{ref}/branches': [['branching_development_read'], ['branching_production_read']] }
export type EndpointMap = Record<string, ScopeGroupAlternatives>
// e.g { 'deploy_edge_function': [['edge_functions_write']] }
export type McpMap = Record<string, ScopeGroupAlternatives>
export interface PermissionScopeMap {
/** scope id -> the endpoints / MCP tools it (partially) authorizes */
scopes: ScopeMap
/** endpoint -> ALL scopes it requires (conjunctive) */
/** endpoint -> alternative scope groups (OR between groups, AND within a group) */
endpoints: EndpointMap
/** MCP tool -> ALL scopes it requires (conjunctive) */
/** MCP tool -> alternative scope groups (OR between groups, AND within a group) */
mcp_tools: McpMap
}
@@ -57,10 +76,15 @@ const splitEndpoint = (raw: string): EnabledEndpoint => {
return { method: raw.slice(0, spaceIndex), path: raw.slice(spaceIndex + 1), raw }
}
// Plain disjunctive-normal-form evaluation: some group where every scope is granted. An empty
// group is vacuously satisfied, which is exactly what "nothing gates this" should mean.
const isSatisfied = (groups: ScopeGroupAlternatives, granted: Set<string>) =>
groups.some((group) => group.every((scope) => granted.has(scope)))
/**
* Given the set of granted scope ids, returns the Management API endpoints the token can call.
* An endpoint is only enabled when ALL of its required scopes are granted (conjunctive), which is
* how the mgmt-api `FgaPermissionsGuard` evaluates the `@AuthWithFgaPermissions` decorator.
* An endpoint is enabled when ALL scopes of at least ONE of its alternative groups are granted,
* matching the `x-fga-permissions` contract described on `ScopeGroupAlternatives`.
*/
export const getEnabledEndpoints = ({
grantedScopes,
@@ -73,13 +97,15 @@ export const getEnabledEndpoints = ({
const granted = new Set(grantedScopes)
return Object.entries(permissionScopeMap.endpoints)
.filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope)))
.filter(([, groups]) => isSatisfied(groups, granted))
.map(([raw]) => splitEndpoint(raw))
}
/**
* Given the set of granted scope ids, returns the MCP tools the token can call. As with endpoints,
* a tool is only enabled when ALL of its required scopes are granted.
* Given the set of granted scope ids, returns the MCP tools the token can call: those with at least
* one fully-granted scope group. Ungated tools — recorded as one empty group (`[[]]`), see
* ScopeGroupAlternatives — are vacuously satisfied and so reported for every token. A tool with no
* alternatives at all (`[]`) is reported for nobody.
*/
export const getEnabledMcpTools = ({
grantedScopes,
@@ -92,15 +118,18 @@ export const getEnabledMcpTools = ({
const granted = new Set(grantedScopes)
return Object.entries(permissionScopeMap.mcp_tools)
.filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope)))
.filter(([, groups]) => isSatisfied(groups, granted))
.map(([tool]) => tool)
}
/**
* Endpoints that (a) are fully satisfied by the complete granted-scope set AND (b) require at least
* one of `capabilityScopes`. Used by the review step to group enabled endpoints under the capability
* that contributes them, while still honouring dual-scope requirements (a dual-scope endpoint only
* Endpoints that are enabled by the complete granted-scope set AND owe that to `capabilityScopes`:
* some fully-granted group must contain at least one capability scope. Used by the review step to
* group enabled endpoints under the capability that contributes them (a multi-scope group only
* appears once all its scopes are granted, and shows under each contributing capability).
*
* An endpoint enabled purely through a group that holds none of `capabilityScopes` is not
* attributed to this capability, and an ungated item is never attributed to any capability.
*/
export const getEnabledEndpointsForCapability = ({
capabilityScopes,
@@ -116,11 +145,11 @@ export const getEnabledEndpointsForCapability = ({
const granted = new Set(allGrantedScopes)
const capability = new Set(capabilityScopes)
return Object.entries(permissionScopeMap.endpoints)
.filter(
([, required]) =>
required.length > 0 &&
required.every((scope) => granted.has(scope)) &&
required.some((scope) => capability.has(scope))
.filter(([, groups]) =>
groups.some(
(group) =>
group.some((scope) => capability.has(scope)) && group.every((scope) => granted.has(scope))
)
)
.map(([raw]) => splitEndpoint(raw))
}
@@ -146,6 +175,59 @@ export const getMcpToolsForScopes = ({
return Array.from(tools)
}
/**
* The endpoint's payload changed endpoint/tool requirements from a flat conjunctive scope list
* (string[]) to alternative groups (string[][], ScopeGroupAlternatives) at the same URL, and the
* response is CDN-cached (s-maxage + stale-while-revalidate). Right after a deploy a new client
* can still receive a stale flat payload; evaluating it as groups would call group.every on a
* string and crash the render. Interpret a flat list as what it was — a single conjunctive group.
*/
const isScopeGroup = (value: unknown): value is ScopeGroup =>
Array.isArray(value) && value.every((scope) => typeof scope === 'string')
const normalizeGroups = (groups: unknown): ScopeGroupAlternatives => {
// A non-array value is not a shape any server ever emitted — fail closed (nobody) rather
// than crash the defense itself.
if (!Array.isArray(groups)) return []
if (groups.every(isScopeGroup)) return groups
if (groups.every((scope): scope is string => typeof scope === 'string')) return [groups]
return []
}
const scopeMapEntrySchema = z.object({
endpoints: z.array(z.string()),
mcp_tools: z.array(z.string()),
})
// Group values stay unknown here — normalizeGroups upgrades legacy flat payloads and fails closed
// per entry, which a strict schema would turn into all-or-nothing. `.catch({})` empties a field
// whose record shape is wrong without discarding the salvageable rest of the payload.
const rawPermissionScopeMapSchema = z.object({
scopes: z.record(scopeMapEntrySchema).catch({}),
endpoints: z.record(z.unknown()).catch({}),
mcp_tools: z.record(z.unknown()).catch({}),
})
const EMPTY_PERMISSION_SCOPE_MAP: PermissionScopeMap = { scopes: {}, endpoints: {}, mcp_tools: {} }
export const normalizePermissionScopeMap = (raw: unknown): PermissionScopeMap => {
const parsed = rawPermissionScopeMapSchema.safeParse(raw)
// A non-object body (null, an error string) has nothing salvageable — fail closed (empty map)
// instead of throwing out of the query fn.
if (!parsed.success) return EMPTY_PERMISSION_SCOPE_MAP
const { scopes, endpoints, mcp_tools } = parsed.data
return {
scopes,
endpoints: Object.fromEntries(
Object.entries(endpoints).map(([endpoint, groups]) => [endpoint, normalizeGroups(groups)])
),
mcp_tools: Object.fromEntries(
Object.entries(mcp_tools).map(([tool, groups]) => [tool, normalizeGroups(groups)])
),
}
}
export async function getGetScopedTokenPermissionsForScope(signal?: AbortSignal) {
const response = await fetch(`${BASE_PATH}/api/scoped-access-token-permissions`, {
signal,
@@ -175,13 +257,14 @@ export async function getGetScopedTokenPermissionsForScope(signal?: AbortSignal)
)
}
return await response.json()
const payload: unknown = await response.json()
return normalizePermissionScopeMap(payload)
}
export type ScopedAccessTokenPermissionsForScopeError = ResponseError
export const useGetEnabledEndpointsForCapability = <TData = PermissionScopeMap>() => {
return useQuery<TData, ScopedAccessTokenPermissionsForScopeError, TData>({
export const useGetEnabledEndpointsForCapability = () => {
return useQuery<PermissionScopeMap, ScopedAccessTokenPermissionsForScopeError>({
queryKey: scopedAccessTokenKeys.permissions(),
queryFn: ({ signal }) => getGetScopedTokenPermissionsForScope(signal),
})
+5 -1
View File
@@ -77,7 +77,11 @@ export interface Permission {
organization_slug: string
resources: string[]
restrictive?: boolean
project_refs: string[]
/**
* Projects the row is limited to. Organization-wide rows arrive as [] or null — the API
* contract is nullable and some view-synthesized rows serialize as null.
*/
project_refs: string[] | null
}
export interface ResponseFailure {