mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
refactor(studio): fetch MCP tool→FGA map from mgmt-api endpoint (#49225)
Paired with [supabase/platform#37175](https://github.com/supabase/platform/pull/37175). Deletes the hand-maintained `MCPToolScopeMappings.ts` (the map is currently 'manually extracted from the mcp controller' and drifts) and fetches the `tool → FGA permission` map from the new mgmt-api `GET /mcp-tools-permissions` endpoint, the same way it already fetches the v1/v2 OpenAPI specs. Closes AI-1016 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * MCP tool permissions are now fetched dynamically from the management API and included in API permission mappings. * Permission data is validated before being applied. * **Bug Fixes** * Improved handling of invalid responses and request failures from the MCP permissions service. * Removed outdated bundled permission mappings, keeping access controls aligned with current configuration. * Updated permission mapping coverage to include both current API specifications and MCP tools. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
This commit is contained in:
3 files changed
+83
-333
No files matched your search
@@ -1,204 +0,0 @@
|
||||
import { constants, permissions } from '@supabase/shared-types'
|
||||
|
||||
import { McpMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const { OAuthScope } = constants
|
||||
|
||||
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> = {
|
||||
[K in keyof T]: {
|
||||
[P in keyof T[K]]: T[K][P] extends { id: infer I } ? I : never
|
||||
}
|
||||
}
|
||||
const FGA_PERMISSIONS = Object.fromEntries(
|
||||
Object.entries(permissions.FgaPermissions).map(([group, permissions]) => [
|
||||
group,
|
||||
Object.fromEntries(Object.entries(permissions).map(([key, { id }]) => [key, id])),
|
||||
])
|
||||
) as ExtractIds<typeof permissions.FgaPermissions>
|
||||
|
||||
// Duplicated from platform (packages/api-core/src/lib/permissions/fga-permissions.ts)
|
||||
// Ideally, this could be exported from @supabase/shared-types
|
||||
export const legacyOauthScopeToFgaPermissionMap: Record<string, string[]> = {
|
||||
'analytics:read': [
|
||||
FGA_PERMISSIONS.PROJECT.ANALYTICS_LOGS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ANALYTICS_USAGE_READ,
|
||||
],
|
||||
'analytics:write': [],
|
||||
'analytics_config:read': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_READ],
|
||||
'analytics_config:write': [FGA_PERMISSIONS.PROJECT.ANALYTICS_CONFIG_WRITE],
|
||||
'auth:read': [FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_READ],
|
||||
// Note(Hieu) Auth:write scope grants access to all auth config endpoints.
|
||||
// However, one endpoint requires minimum administrator role, so this oauth scope must also include the FGA PROJECT.ADMIN_WRITE permission
|
||||
'auth:write': [FGA_PERMISSIONS.PROJECT.ADMIN_WRITE, FGA_PERMISSIONS.PROJECT.AUTH_CONFIG_WRITE],
|
||||
'database:read': [
|
||||
FGA_PERMISSIONS.USER.SNIPPETS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ADVISORS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BACKUPS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_JIT_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_READ,
|
||||
FGA_PERMISSIONS.PROJECT.SNIPPETS_READ,
|
||||
],
|
||||
'database:write': [
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BACKUPS_WRITE,
|
||||
// Note(Hieu): Include database read permission here to align with the project query endpoint.
|
||||
// RLS and FGA guard this endpoint with database read first, then perform an additional check for write queries.
|
||||
// The OAuth guard requires database write directly, which causes a discrepancy error if we don't include read here.
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_MIGRATIONS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_POOLING_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_READONLY_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_SSL_CONFIG_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_WEBHOOKS_CONFIG_WRITE,
|
||||
],
|
||||
'domains:read': [
|
||||
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_READ,
|
||||
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_READ,
|
||||
],
|
||||
'domains:write': [
|
||||
FGA_PERMISSIONS.PROJECT.CUSTOM_DOMAIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.VANITY_SUBDOMAIN_WRITE,
|
||||
],
|
||||
'edge_functions:read': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_READ],
|
||||
'edge_functions:write': [FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_WRITE],
|
||||
'environment:read': [
|
||||
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_READ,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_READ,
|
||||
],
|
||||
'environment:write': [
|
||||
FGA_PERMISSIONS.PROJECT.ACTION_RUNS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_DELETE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_DEVELOPMENT_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_DELETE,
|
||||
FGA_PERMISSIONS.PROJECT.BRANCHING_PRODUCTION_WRITE,
|
||||
],
|
||||
'organizations:read': [
|
||||
FGA_PERMISSIONS.USER.ORGANIZATIONS_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.ADMIN_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.MEMBERS_READ,
|
||||
],
|
||||
'organizations:write': [],
|
||||
'projects:read': [
|
||||
FGA_PERMISSIONS.USER.PROJECTS_READ,
|
||||
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_READ,
|
||||
],
|
||||
'projects:write': [
|
||||
FGA_PERMISSIONS.ORGANIZATION.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.ORGANIZATION.PROJECTS_CREATE,
|
||||
FGA_PERMISSIONS.PROJECT.ADMIN_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_BANS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.DATABASE_NETWORK_RESTRICTIONS_WRITE,
|
||||
],
|
||||
'rest:read': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_READ],
|
||||
'rest:write': [FGA_PERMISSIONS.PROJECT.DATA_API_CONFIG_WRITE],
|
||||
'secrets:read': [
|
||||
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_READ,
|
||||
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_READ,
|
||||
],
|
||||
'secrets:write': [
|
||||
FGA_PERMISSIONS.PROJECT.API_GATEWAY_KEYS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.AUTH_SIGNING_KEYS_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.EDGE_FUNCTIONS_SECRETS_WRITE,
|
||||
],
|
||||
'storage:read': [
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_READ,
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_READ,
|
||||
],
|
||||
'storage:write': [
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_WRITE,
|
||||
FGA_PERMISSIONS.PROJECT.STORAGE_CONFIG_WRITE,
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* Build a map of MCP tools/FGA permissions by expanding each OAuth-scope group to the FGA
|
||||
* permissions it implies:
|
||||
* {
|
||||
* 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, oAuthScopeGroups]) => {
|
||||
acc[mcpTool] = expandOAuthScopeGroups(oAuthScopeGroups, legacyOauthScopeToFgaPermissionMap)
|
||||
return acc
|
||||
},
|
||||
{} as McpMap
|
||||
)
|
||||
+36
-110
@@ -6,7 +6,6 @@ import {
|
||||
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'
|
||||
|
||||
@@ -135,115 +134,24 @@ describe('addMCPToolsToScopes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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'])
|
||||
)
|
||||
})
|
||||
|
||||
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>) => {
|
||||
// tests, so mocking here keeps that guard instead of replacing global fetch. All three live
|
||||
// sources (v1 spec, v2 spec, the MCP tool-permissions endpoint) are stubbed.
|
||||
const stubSources = (
|
||||
v1: Record<string, unknown>,
|
||||
v2: Record<string, unknown>,
|
||||
mcpTools: Record<string, string[][]> = { execute_sql: [['database_read']] }
|
||||
) => {
|
||||
mswServer.use(
|
||||
http.get('*/api/v1-json', () => HttpResponse.json(v1)),
|
||||
http.get('*/api/v2-json', () => HttpResponse.json(v2))
|
||||
http.get('*/api/v2-json', () => HttpResponse.json(v2)),
|
||||
http.get('*/platform/mcp-tools-permissions', () => HttpResponse.json(mcpTools))
|
||||
)
|
||||
}
|
||||
|
||||
test('merges both specs, attaching each MCP tool to a shared scope exactly once', async () => {
|
||||
stubSpecs(
|
||||
stubSources(
|
||||
{
|
||||
paths: {
|
||||
'/v1/projects/{ref}/database/query': {
|
||||
@@ -274,7 +182,7 @@ describe('buildAPIPermissionScopeMap', () => {
|
||||
// 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(
|
||||
stubSources(
|
||||
{
|
||||
paths: {
|
||||
'/v1/projects/{ref}': {
|
||||
@@ -293,15 +201,33 @@ describe('buildAPIPermissionScopeMap', () => {
|
||||
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: {} })
|
||||
test('returns the MCP tool map fetched from the endpoint', async () => {
|
||||
stubSources(
|
||||
{ paths: {} },
|
||||
{ paths: {} },
|
||||
{
|
||||
apply_migration: [['database_migrations_write']],
|
||||
search_docs: [[]],
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
expect(map.mcp_tools).toEqual({
|
||||
apply_migration: [['database_migrations_write']],
|
||||
search_docs: [[]],
|
||||
})
|
||||
// The gated tool is indexed under its permission; the ungated one is not.
|
||||
expect(map.scopes.database_migrations_write.mcp_tools).toEqual(['apply_migration'])
|
||||
})
|
||||
|
||||
test('throws when the MCP tool-permissions endpoint is unavailable', async () => {
|
||||
mswServer.use(
|
||||
http.get('*/api/v1-json', () => HttpResponse.json({ paths: {} })),
|
||||
http.get('*/api/v2-json', () => HttpResponse.json({ paths: {} })),
|
||||
http.get('*/platform/mcp-tools-permissions', () => new HttpResponse(null, { status: 503 }))
|
||||
)
|
||||
|
||||
await expect(buildAPIPermissionScopeMap()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,5 @@
|
||||
import { cloneDeep } from 'lodash'
|
||||
import z from 'zod'
|
||||
|
||||
// We don't have an OpenAPI that describes mcp tools security requirements so
|
||||
// we have this hard coded file that must be updated when they change
|
||||
import { MCPToolScopeMappings } from './MCPToolScopeMappings'
|
||||
import {
|
||||
EndpointMap,
|
||||
McpMap,
|
||||
@@ -13,32 +9,29 @@ import {
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
|
||||
/*
|
||||
* 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
|
||||
* Builds the permissions/endpoint mapping from three live sources: the v1 and v2 OpenAPI specs
|
||||
* (endpoint -> FGA via `x-fga-permissions`) and the mgmt-api MCP-tool-permissions endpoint
|
||||
* (tool -> FGA). The MCP map is owned by Control Plane — it's projected from the same MCP_TOOL_AUTH
|
||||
* descriptor that drives enforcement — so Studio fetches it exactly like the OpenAPI spec instead of
|
||||
* hand-maintaining or importing a copy.
|
||||
* @throws InternalServerError when it can't fetch the specs or the MCP map
|
||||
*/
|
||||
export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap> => {
|
||||
const [apiV1SpecsJSON, apiV2SpecsJSON] = await Promise.all([
|
||||
const [apiV1SpecsJSON, apiV2SpecsJSON, mcpToolsJSON] = await Promise.all([
|
||||
fetchAPIPermissionScope('v1'),
|
||||
fetchAPIPermissionScope('v2'),
|
||||
fetchMcpToolPermissions(),
|
||||
])
|
||||
const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON)
|
||||
const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON)
|
||||
const mcpTools = MCP_TOOLS_SCHEMA.parse(mcpToolsJSON)
|
||||
|
||||
const { scopes, endpoints } = getScopesAndEndpointsForAPI({
|
||||
paths: { ...apiV1Specs.paths, ...apiV2Specs.paths },
|
||||
})
|
||||
addMCPToolsToScopes(scopes, MCPToolScopeMappings)
|
||||
addMCPToolsToScopes(scopes, mcpTools)
|
||||
|
||||
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),
|
||||
}
|
||||
return { scopes, endpoints, mcp_tools: mcpTools }
|
||||
}
|
||||
|
||||
// OPEN API specs look like this (only kept the parts we're interested in):
|
||||
@@ -63,7 +56,7 @@ export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap>
|
||||
// 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
|
||||
// the write group is doc-only (the MCP endpoint reports execute_sql under database_read only). 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
|
||||
@@ -147,6 +140,38 @@ const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => {
|
||||
}
|
||||
}
|
||||
|
||||
// The mgmt-api endpoint that projects the MCP_TOOL_AUTH descriptor (which also drives enforcement)
|
||||
// to tool -> FGA permission groups. Fetched like the OpenAPI spec above.
|
||||
const fetchMcpToolPermissions = async () => {
|
||||
try {
|
||||
const response = await fetch(`${NEXT_PUBLIC_API_DOMAIN}/platform/mcp-tools-permissions`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
}
|
||||
const responseText = await response.text()
|
||||
|
||||
const retryAfter = response.headers.get('Retry-After') ?? undefined
|
||||
throw new InternalServerError(`MCP tool permissions responded with ${response.status}`, {
|
||||
status: response.status,
|
||||
body: responseText,
|
||||
...(retryAfter !== undefined && { retryAfter }),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof InternalServerError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
throw new InternalServerError(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified OPEN API specs schemas that only defines what we care about for scoped tokens
|
||||
|
||||
const OPEN_API_PATH_METHOD_SCHEMA = z.object({
|
||||
@@ -172,3 +197,6 @@ const OPEN_API_PATH_ITEM_SCHEMA = z.preprocess(
|
||||
const API_SPECS_SCHEMA = z.object({
|
||||
paths: z.record(z.string(), OPEN_API_PATH_ITEM_SCHEMA),
|
||||
})
|
||||
|
||||
// tool name -> OR-of-AND FGA permission groups, as served by GET /platform/mcp-tools-permissions.
|
||||
const MCP_TOOLS_SCHEMA: z.ZodType<McpMap> = z.record(z.string(), z.array(z.array(z.string())))
|
||||
Reference in new issue
Block a user