diff --git a/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts b/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts index 20932566e42..7e2b8da4823 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/MCPToolScopeMappings.ts @@ -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 = { + 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 = { @@ -157,17 +174,30 @@ export const legacyOauthScopeToFgaPermissionMap: Record = { } /* - * 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[][] => + 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 diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts index 367d6b3917f..d59a83bbd63 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts @@ -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, v2: Record) => { + 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) + }) }) diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts index 57c7ffc70f0..b6b568e6022 100644 --- a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts @@ -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 => { - // 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, - 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 ): Omit => { 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), }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts index 5275f6ace06..f19ae68170a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts @@ -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'], diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts index 7bd9bee642e..4aab626e393 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts @@ -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 => ({ + 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) => + 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']) + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts index 3b94a29c952..1417df70d0d 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts @@ -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' } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts index ab3f229e318..e6011571470 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts @@ -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' }], diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts index c4039ee8bb4..06eff7f10d9 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts @@ -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 /** 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 = {} 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 { diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx index a9e5bdf292c..92c99c051ae 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx @@ -76,8 +76,12 @@ export const RiskMarker = ({ )} {mcpTools.length > 0 && (
+ {/* 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". */}

- MCP tools + Related MCP tools

{mcpTools.join(', ')}

diff --git a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts index 976b6a0d127..604cc8d68eb 100644 --- a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts +++ b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts @@ -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 -// e.g { 'GET /v2/projects/{ref}/analytics/log-drains': ['analytics_config_read'] } -export type EndpointMap = Record -// e.g { 'deploy_edge_function': ['edge_functions_write'] } -export type McpMap = Record + +/** 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 +// e.g { 'deploy_edge_function': [['edge_functions_write']] } +export type McpMap = Record 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) => + 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 = () => { - return useQuery({ +export const useGetEnabledEndpointsForCapability = () => { + return useQuery({ queryKey: scopedAccessTokenKeys.permissions(), queryFn: ({ signal }) => getGetScopedTokenPermissionsForScope(signal), }) diff --git a/apps/studio/types/base.ts b/apps/studio/types/base.ts index 089e876b3fa..23852a697bc 100644 --- a/apps/studio/types/base.ts +++ b/apps/studio/types/base.ts @@ -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 {