mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
|
||||
+271
-14
@@ -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'],
|
||||
|
||||
+247
-34
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user