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 new file mode 100644 index 00000000000..af7cfdec5a4 --- /dev/null +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest' + +import { getEndpointsAndMCPToolsForAPI } from './buildAPIPermissionScopeMap' + +describe('getEndpointsAndMCPToolsForAPI', () => { + const openAPISpecs = { + paths: { + '/v1/projects/{ref}/database/migrations': { + get: { + security: [ + { + fga_permissions: ['database_migrations_read'], + }, + ], + }, + post: { + security: [ + { + fga_permissions: ['database_migrations_write'], + }, + ], + }, + }, + '/v1/projects/{ref}/database/migrations/{version}': { + patch: { + security: [ + { + fga_permissions: ['database_migrations_write'], + }, + ], + }, + }, + }, + } + + 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) + + expect(permissionScopeMap).toEqual({ + scopes: { + database_migrations_read: { + endpoints: ['GET /v1/projects/{ref}/database/migrations'], + mcp_tools: ['list_migrations'], + }, + database_migrations_write: { + endpoints: [ + 'POST /v1/projects/{ref}/database/migrations', + 'PATCH /v1/projects/{ref}/database/migrations/{version}', + ], + mcp_tools: ['apply_migration'], + }, + }, + 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'], + }, + mcp_tools, + }) + }) +}) diff --git a/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts new file mode 100644 index 00000000000..62b1d1b20ef --- /dev/null +++ b/apps/studio/app/api/scoped-access-token-permissions/buildAPIPermissionScopeMap.ts @@ -0,0 +1,137 @@ +import lodash from 'lodash' +import z from 'zod' + +// We don't have an OpenAPI that describes mcp tools security requirements so +// we have this json file that must be updated when they change +import mcp_tools_permissions_map from './mcp_tools_permissions_map.json' +import { + EndpointMap, + McpMap, + PermissionScopeMap, + ScopeMap, +} from '@/data/scoped-access-tokens/permission-scope-map-query' + +// 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) + } +} + +export const buildAPIPermissionScopeMap = async (): Promise => { + // Get the permissions map for the API v1 + const apiV1SpecsJSON = await fetchAPIPermissionScope('v1') + const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON) + const permissionScopeMapV1 = getEndpointsAndMCPToolsForAPI(apiV1Specs, mcp_tools_permissions_map) + + // Get the permissions map for the API v2 + const apiV2SpecsJSON = await fetchAPIPermissionScope('v2') + const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON) + const permissionScopeMapV2 = getEndpointsAndMCPToolsForAPI(apiV2Specs, mcp_tools_permissions_map) + + const permissionScope = lodash.mergeWith( + {}, + permissionScopeMapV1, + permissionScopeMapV2, + mergeArrays + ) + + return permissionScope +} + +// OPEN API specs look like this (only kept the parts we're interested in): +// { +// "paths": { +// "/v2/projects/{ref}/analytics/log-drains": { +// "get": { +// "security": [ +// { +// "fga_permissions": [ +// "analytics_config_read" +// ] +// } +// ] +// } +// } +// } +// } +export const getEndpointsAndMCPToolsForAPI = ( + apiSpecs: z.output, + mcp_tools: McpMap +): PermissionScopeMap => { + const scopes: ScopeMap = {} + const endpoints: EndpointMap = {} + + // Loop over each API path to assign endpoints to their scopes and + // scopes to their endpoints + Object.entries(apiSpecs.paths).forEach(([path, methods]) => { + // Loop over each API path method (get, post, etc.) + Object.entries(methods).forEach(([method, methodSpecs]) => { + if (methodSpecs.security == null) return + + methodSpecs.security.forEach((security) => { + if (security.fga_permissions == null) return + + security.fga_permissions.forEach((permission) => { + const endpoint = `${method.toUpperCase()} ${path}` + + // Initialize scope object if needed + scopes[permission] = scopes[permission] || { endpoints: [], mcp_tools: [] } + + // Initialize endpoints array if needed + endpoints[endpoint] = endpoints[endpoint] || [] + + if (!scopes[permission].endpoints.includes(endpoint)) { + scopes[permission].endpoints.push(endpoint) + } + if (!endpoints[endpoint].includes(permission)) { + endpoints[endpoint].push(permission) + } + }) + }) + }) + }) + + // 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)) { + scopes[toolScope].mcp_tools.push(mcpTool) + } + }) + }) + + return { scopes, endpoints, mcp_tools } +} + +const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_DOMAIN}/api/${version}-json`, { + method: 'get', + }) + if (response.ok) { + return response.json() + } + throw new Error(`Unable to fetch scoped token permission scope: ${response.statusText}`) +} + +// Simplified OPEN API specs schemas that only defines what we care about for scoped tokens + +const OPEN_API_PATH_METHOD_SCHEMA = z.object({ + security: z + .array( + z.object({ + fga_permissions: z.array(z.string()).optional(), + }) + ) + .optional(), +}) + +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 + ) + ), +}) diff --git a/apps/studio/app/api/scoped-access-token-permissions/mcp_tools_permissions_map.json b/apps/studio/app/api/scoped-access-token-permissions/mcp_tools_permissions_map.json new file mode 100644 index 00000000000..ba9e8126782 --- /dev/null +++ b/apps/studio/app/api/scoped-access-token-permissions/mcp_tools_permissions_map.json @@ -0,0 +1,32 @@ +{ + "apply_migration": ["database_migrations_write", "database_write"], + "create_branch": ["branching_development_create", "branching_production_create"], + "create_project": ["organization_projects_create"], + "delete_branch": ["branching_development_delete", "branching_production_delete"], + "deploy_edge_function": ["edge_functions_write"], + "execute_sql": ["database_read", "database_write"], + "generate_typescript_types": ["database_read"], + "get_advisors": ["advisors_read", "database_read"], + "get_cost": ["organization_admin_read"], + "get_edge_function": ["edge_functions_read"], + "get_logs": ["analytics_logs_read"], + "get_organization": ["organization_admin_read"], + "get_project": ["project_admin_read"], + "get_project_url": ["project_admin_read"], + "get_publishable_keys": ["api_gateway_keys_read"], + "get_storage_config": ["storage_config_read"], + "list_branches": ["branching_development_read", "branching_production_read"], + "list_edge_functions": ["edge_functions_read"], + "list_extensions": ["database_read"], + "list_migrations": ["database_migrations_read"], + "list_organizations": ["organizations_read"], + "list_projects": ["projects_read"], + "list_storage_buckets": ["storage_read"], + "list_tables": ["database_read"], + "merge_branch": ["branching_development_write", "branching_production_write"], + "pause_project": ["project_admin_write"], + "rebase_branch": ["branching_development_write", "branching_production_write"], + "reset_branch": ["branching_development_write", "branching_production_write"], + "restore_project": ["project_admin_write"], + "update_storage_config": ["storage_config_write"] +} diff --git a/apps/studio/app/api/scoped-access-token-permissions/route.ts b/apps/studio/app/api/scoped-access-token-permissions/route.ts new file mode 100644 index 00000000000..5b1cea520ec --- /dev/null +++ b/apps/studio/app/api/scoped-access-token-permissions/route.ts @@ -0,0 +1,62 @@ +import { IS_PLATFORM } from 'common' +import { NextResponse } from 'next/server' + +import { buildAPIPermissionScopeMap } from './buildAPIPermissionScopeMap' +import { InternalServerError } from '@/lib/api/apiHelpers' + +/** + * Cache on CDN for 5 minutes + * Allow serving stale content for 1 minute while revalidating + */ +const CACHE_CONTROL_SETTINGS = 'public, s-maxage=300, stale-while-revalidate=60' + +export async function OPTIONS() { + if (!IS_PLATFORM) return new Response(null, { status: 404 }) + return new Response(null, { + status: 204, + headers: { + Allow: 'GET, HEAD, OPTIONS', + }, + }) +} + +export async function HEAD() { + if (!IS_PLATFORM) return new Response(null, { status: 404 }) + return new Response(null, { + status: 200, + headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS }, + }) +} + +export async function GET() { + if (!IS_PLATFORM) return new Response(null, { status: 404 }) + + try { + const permissionsScopes = await buildAPIPermissionScopeMap() + return NextResponse.json(permissionsScopes, { + headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS }, + }) + } catch (error) { + let errorCode = 500 + const headers = new Headers() + + if (error instanceof InternalServerError) { + if (typeof error.details?.status === 'number') errorCode = error.details.status + if (errorCode === 420) errorCode = 429 + if (errorCode === 429 && typeof error.details?.retryAfter === 'string') { + headers.set('Retry-After', error.details.retryAfter) + } + console.error('Failed to fetch scoped token permission scope map: %O', { + message: error.message, + details: error.details, + }) + } else { + console.error('Unexpected error fetching scoped token permission scope map: %O', error) + } + + return NextResponse.json( + { error: 'Unable to scoped token permission scope map at this time' }, + { status: errorCode, headers } + ) + } +} 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 e045d4d7f65..7bd9bee642e 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts @@ -7,7 +7,10 @@ import { selectionToScopes, type PermissionSelection, } from './AccessToken.permissions' -import { getEnabledEndpoints, getEnabledMcpTools } from '@/data/access-tokens/permission-scope-map' +import { + getEnabledEndpoints, + getEnabledMcpTools, +} from '@/data/scoped-access-tokens/permission-scope-map-query' describe('selectionToScopes', () => { it('ignores none and returns read scope for read mode', () => { @@ -75,12 +78,53 @@ 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 - expect(getEnabledMcpTools(['database_read'])).not.toContain('execute_sql') - expect(getEnabledMcpTools(['database_read', 'database_write'])).toContain('execute_sql') + expect( + getEnabledMcpTools({ + // Only scope one is granted + grantedScopes: ['database_read'], + permissionScopeMap: { + scopes: {}, + endpoints: {}, + mcp_tools: { + execute_sql: ['database_read', 'database_write'], + }, + }, + }) + ).not.toContain('execute_sql') + expect( + getEnabledMcpTools({ + // Both scopes are granted + grantedScopes: ['database_read', 'database_write'], + permissionScopeMap: { + scopes: {}, + endpoints: {}, + mcp_tools: { + execute_sql: ['database_read', 'database_write'], + }, + }, + }) + ).toContain('execute_sql') }) it('only lists endpoints whose every required scope is granted', () => { - const endpoints = getEnabledEndpoints(['database_read', 'database_write']) - expect(endpoints.every((e) => e.method.length > 0 && e.path.startsWith('/'))).toBe(true) + const endpoints = getEnabledEndpoints({ + grantedScopes: ['database_read', 'database_write'], + permissionScopeMap: { + scopes: {}, + 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'], + }, + 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' }, + ]) }) }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx index f9304b36581..c89a3f583bb 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx @@ -12,6 +12,7 @@ import { PermissionsAccordion } from './PermissionsAccordion' import { ResourceAccessStep } from './ResourceAccessStep' import { StepIndicator } from './StepIndicator' import { TokenDetails } from './TokenDetails' +import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query' const FORM_ID = 'scoped-token-form' @@ -50,6 +51,7 @@ export const NewScopedTokenForm = ({ const values = form.watch() const selection = values.permissions const configuredCount = countConfigured(selection) + const { data: permissionScopeMap } = useGetEnabledEndpointsForCapability() const handleReviewAccess = async () => { if (configuredCount === 0) { @@ -76,7 +78,11 @@ export const NewScopedTokenForm = ({ - + {showMissingPermissionsWarning && (
) : ( - + )} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx index 8a97c2676d8..2aa5633dd83 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx @@ -17,10 +17,12 @@ import { RiskMarker } from './RiskMarker' import { getEnabledEndpointsForCapability, getEnabledMcpTools, -} from '@/data/access-tokens/permission-scope-map' + PermissionScopeMap, +} from '@/data/scoped-access-tokens/permission-scope-map-query' interface ReviewStepProps { values: TokenFormValues + permissionScopeMap: PermissionScopeMap | undefined } const RISK_TEXT_CLASS: Record = { @@ -33,10 +35,10 @@ const RISK_TEXT_CLASS: Record = { const modeLabel = (mode: PermissionMode) => mode === 'readwrite' ? 'Read-write' : mode === 'read' ? 'Read' : 'None' -export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => { +export const NewScopedTokenFormReview = ({ values, permissionScopeMap }: ReviewStepProps) => { const { organizations, projects } = useOrgAndProjectData() const selection = values.permissions - const grantedScopes = useMemo(() => selectionToScopes(selection), [selection]) + const allGrantedScopes = useMemo(() => selectionToScopes(selection), [selection]) const risk = useMemo( () => computeOverallRisk(selection, values.resourceAccess), [selection, values.resourceAccess] @@ -76,9 +78,12 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => { [selection] ) - const hasCapabilities = grantedScopes.length > 0 + const hasCapabilities = allGrantedScopes.length > 0 - const mcpTools = useMemo(() => getEnabledMcpTools(grantedScopes), [grantedScopes]) + const mcpTools = useMemo( + () => getEnabledMcpTools({ grantedScopes: allGrantedScopes, permissionScopeMap }), + [allGrantedScopes, permissionScopeMap] + ) const capabilityGroups = useMemo(() => { const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] = @@ -87,14 +92,18 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => { for (const { entry, mode } of category.entries) { const capabilityScopes = mode === 'readwrite' ? [...entry.readScopes, ...entry.writeScopes] : entry.readScopes - const endpoints = getEnabledEndpointsForCapability(capabilityScopes, grantedScopes) + const endpoints = getEnabledEndpointsForCapability({ + capabilityScopes, + allGrantedScopes, + permissionScopeMap, + }) if (endpoints.length > 0) { groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) }) } } } return groups - }, [activeByCategory, grantedScopes]) + }, [activeByCategory, allGrantedScopes, permissionScopeMap]) const rows: [string, React.ReactNode][] = [ ['Name', values.tokenName || Untitled token], @@ -112,7 +121,11 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => {
{entry.name} · {modeLabel(mode)} - +
))}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx index 3a21ffe0a39..ffdc55ef13c 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionRow.tsx @@ -2,14 +2,21 @@ import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } import type { PermissionCatalogEntry, PermissionMode } from '../../AccessToken.permissions' import { RiskMarker } from './RiskMarker' +import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query' interface PermissionRowProps { entry: PermissionCatalogEntry mode: PermissionMode onChange: (mode: PermissionMode) => void + permissionScopeMap: PermissionScopeMap | undefined } -export const PermissionRow = ({ entry, mode, onChange }: PermissionRowProps) => { +export const PermissionRow = ({ + entry, + mode, + onChange, + permissionScopeMap, +}: PermissionRowProps) => { return (
@@ -17,7 +24,7 @@ export const PermissionRow = ({ entry, mode, onChange }: PermissionRowProps) => {entry.name} permissions - +

{entry.description} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx index d2d49d48467..5b75482ed7e 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx @@ -18,13 +18,19 @@ import { type PermissionSelection, } from '../../AccessToken.permissions' import { PermissionRow } from './PermissionRow' +import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query' interface PermissionsAccordionProps { selection: PermissionSelection onChange: (key: string, mode: PermissionMode) => void + permissionScopeMap: PermissionScopeMap | undefined } -export const PermissionsAccordion = ({ selection, onChange }: PermissionsAccordionProps) => { +export const PermissionsAccordion = ({ + selection, + onChange, + permissionScopeMap, +}: PermissionsAccordionProps) => { const [openCategories, setOpenCategories] = useState([ PERMISSION_CATALOG_BY_CATEGORY[0]?.key, ]) @@ -80,6 +86,7 @@ export const PermissionsAccordion = ({ selection, onChange }: PermissionsAccordi entry={entry} mode={selection[entry.key] ?? 'none'} onChange={(mode) => onChange(entry.key, mode)} + permissionScopeMap={permissionScopeMap} /> ))}

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 e23c7b13baa..d634f327200 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx @@ -5,7 +5,10 @@ import { type PermissionCatalogEntry, type RiskLevel, } from '../../AccessToken.permissions' -import { getMcpToolsForScopes } from '@/data/access-tokens/permission-scope-map' +import { + getMcpToolsForScopes, + PermissionScopeMap, +} from '@/data/scoped-access-tokens/permission-scope-map-query' const DOT_CLASS: Record = { low: 'bg-brand', @@ -24,9 +27,15 @@ interface RiskMarkerProps { /** When false, renders the dot + label without the explanatory tooltip (used in the review list). */ withTooltip?: boolean className?: string + permissionScopeMap: PermissionScopeMap | undefined } -export const RiskMarker = ({ entry, withTooltip = true, className }: RiskMarkerProps) => { +export const RiskMarker = ({ + entry, + withTooltip = true, + className, + permissionScopeMap, +}: RiskMarkerProps) => { const marker = ( diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx index 0d4746b1891..9819eaeb83d 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.tsx @@ -69,7 +69,6 @@ export const NewScopedTokenSheet = () => { setIsOpen(open) } - console.log({ createdToken }) return ( diff --git a/apps/studio/data/access-tokens/permission-scope-map.ts b/apps/studio/data/access-tokens/permission-scope-map.ts deleted file mode 100644 index 59e303d136d..00000000000 --- a/apps/studio/data/access-tokens/permission-scope-map.ts +++ /dev/null @@ -1,100 +0,0 @@ -import rawScopeMap from './permission-scope-map.json' - -/** - * Cross-reference between OpenFGA permission scopes, Management API endpoints, and MCP tools. - * - * TODO: replace with control-plane endpoint. This map is currently checked in as a static - * snapshot generated from the mgmt-api OpenAPI specs + the MCP server tool list. Once the - * control plane exposes a scope -> endpoints/tools endpoint, fetch it via a react-query hook - * instead of importing this JSON. - */ - -export interface ScopeMapEntry { - endpoints: string[] - mcp_tools: string[] -} - -export interface PermissionScopeMap { - _meta: Record - /** scope id -> the endpoints / MCP tools it (partially) authorizes */ - scopes: Record - /** endpoint -> ALL scopes it requires (conjunctive) */ - endpoints: Record - /** MCP tool -> ALL scopes it requires (conjunctive) */ - mcp_tools: Record -} - -export const PERMISSION_SCOPE_MAP = rawScopeMap as unknown as PermissionScopeMap - -export interface EnabledEndpoint { - /** HTTP method, e.g. "GET" */ - method: string - /** Path, e.g. "/v1/projects/{ref}" */ - path: string - /** The raw "METHOD /path" key */ - raw: string -} - -const splitEndpoint = (raw: string): EnabledEndpoint => { - const spaceIndex = raw.indexOf(' ') - if (spaceIndex === -1) return { method: '', path: raw, raw } - return { method: raw.slice(0, spaceIndex), path: raw.slice(spaceIndex + 1), raw } -} - -/** - * 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. - */ -export const getEnabledEndpoints = (grantedScopes: Iterable): EnabledEndpoint[] => { - const granted = new Set(grantedScopes) - return Object.entries(PERMISSION_SCOPE_MAP.endpoints) - .filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope))) - .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. - */ -export const getEnabledMcpTools = (grantedScopes: Iterable): string[] => { - const granted = new Set(grantedScopes) - return Object.entries(PERMISSION_SCOPE_MAP.mcp_tools) - .filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope))) - .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 - * appears once all its scopes are granted, and shows under each contributing capability). - */ -export const getEnabledEndpointsForCapability = ( - capabilityScopes: Iterable, - allGrantedScopes: Iterable -): EnabledEndpoint[] => { - const granted = new Set(allGrantedScopes) - const capability = new Set(capabilityScopes) - return Object.entries(PERMISSION_SCOPE_MAP.endpoints) - .filter( - ([, required]) => - required.length > 0 && - required.every((scope) => granted.has(scope)) && - required.some((scope) => capability.has(scope)) - ) - .map(([raw]) => splitEndpoint(raw)) -} - -/** - * Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of - * the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that - * lists one of these scopes, so users can see what a capability relates to before granting it. - */ -export const getMcpToolsForScopes = (scopeIds: Iterable): string[] => { - const tools = new Set() - for (const id of scopeIds) { - PERMISSION_SCOPE_MAP.scopes[id]?.mcp_tools.forEach((tool) => tools.add(tool)) - } - return Array.from(tools) -} diff --git a/apps/studio/data/scoped-access-tokens/keys.ts b/apps/studio/data/scoped-access-tokens/keys.ts index f3dc146c2bf..b1ef15e336e 100644 --- a/apps/studio/data/scoped-access-tokens/keys.ts +++ b/apps/studio/data/scoped-access-tokens/keys.ts @@ -1,4 +1,5 @@ export const scopedAccessTokenKeys = { list: () => ['scoped-access-tokens'] as const, detail: (id: string) => ['scoped-access-tokens', id] as const, + permissions: () => ['scoped-access-token-permissions'], } 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 new file mode 100644 index 00000000000..4ef208a887f --- /dev/null +++ b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts @@ -0,0 +1,188 @@ +import { useQuery } from '@tanstack/react-query' + +import { scopedAccessTokenKeys } from './keys' +import { BASE_PATH } from '@/lib/constants' +import { ResponseError } from '@/types' + +/** + * Cross-reference between OpenFGA permission scopes, Management API endpoints, and MCP tools. + */ + +export interface ScopeMapEntry { + endpoints: string[] + mcp_tools: string[] +} + +/* e.g + { + "branching_production_read": { + "endpoints": [ + "GET /v1/branches/{branch_id_or_ref}", + "GET /v1/projects/{ref}/branches", + "GET /v1/projects/{ref}/branches/{name}" + ], + "mcp_tools": [ + "list_branches" + ] + } + } +*/ +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 + +export interface PermissionScopeMap { + /** scope id -> the endpoints / MCP tools it (partially) authorizes */ + scopes: ScopeMap + /** endpoint -> ALL scopes it requires (conjunctive) */ + endpoints: EndpointMap + /** MCP tool -> ALL scopes it requires (conjunctive) */ + mcp_tools: McpMap +} + +export interface EnabledEndpoint { + /** HTTP method, e.g. "GET" */ + method: string + /** Path, e.g. "/v1/projects/{ref}" */ + path: string + /** The raw "METHOD /path" key */ + raw: string +} + +const splitEndpoint = (raw: string): EnabledEndpoint => { + const spaceIndex = raw.indexOf(' ') + if (spaceIndex === -1) return { method: '', path: raw, raw } + return { method: raw.slice(0, spaceIndex), path: raw.slice(spaceIndex + 1), raw } +} + +/** + * 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. + */ +export const getEnabledEndpoints = ({ + grantedScopes, + permissionScopeMap, +}: { + grantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): EnabledEndpoint[] => { + if (permissionScopeMap == null) return [] + + const granted = new Set(grantedScopes) + return Object.entries(permissionScopeMap.endpoints) + .filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope))) + .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. + */ +export const getEnabledMcpTools = ({ + grantedScopes, + permissionScopeMap, +}: { + grantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): string[] => { + if (permissionScopeMap == null) return [] + + const granted = new Set(grantedScopes) + return Object.entries(permissionScopeMap.mcp_tools) + .filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope))) + .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 + * appears once all its scopes are granted, and shows under each contributing capability). + */ +export const getEnabledEndpointsForCapability = ({ + capabilityScopes, + allGrantedScopes, + permissionScopeMap, +}: { + capabilityScopes: Iterable + allGrantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): EnabledEndpoint[] => { + if (permissionScopeMap == null) return [] + + 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)) + ) + .map(([raw]) => splitEndpoint(raw)) +} + +/** + * Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of + * the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that + * lists one of these scopes, so users can see what a capability relates to before granting it. + */ +export const getMcpToolsForScopes = ({ + scopeIds, + permissionScopeMap, +}: { + scopeIds: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): string[] => { + if (permissionScopeMap == null) return [] + + const tools = new Set() + for (const id of scopeIds) { + permissionScopeMap.scopes[id]?.mcp_tools.forEach((tool) => tools.add(tool)) + } + return Array.from(tools) +} + +export async function getGetScopedTokenPermissionsForScope(signal?: AbortSignal) { + const response = await fetch(`${BASE_PATH}/api/scoped-access-token-permissions`, { + signal, + method: 'GET', + credentials: 'omit', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('[getIncidentStatus] Failed:', response.status, errorText) + + let retryAfter: number | undefined + const retryAfterHeader = response.headers.get('Retry-After') + if (retryAfterHeader !== null) { + const parsed = Number(retryAfterHeader) + if (Number.isFinite(parsed) && parsed > 0) retryAfter = parsed + } + + throw new ResponseError( + `Failed to fetch incident status: ${response.statusText}`, + response.status, + undefined, + retryAfter + ) + } + + return await response.json() +} + +export type ScopedAccessTokenPermissionsForScopeError = ResponseError + +export const useGetEnabledEndpointsForCapability = () => { + return useQuery({ + queryKey: scopedAccessTokenKeys.permissions(), + queryFn: ({ signal }) => getGetScopedTokenPermissionsForScope(signal), + }) +} diff --git a/apps/studio/data/access-tokens/permission-scope-map.json b/apps/studio/data/scoped-access-tokens/permission-scope-map.json similarity index 100% rename from apps/studio/data/access-tokens/permission-scope-map.json rename to apps/studio/data/scoped-access-tokens/permission-scope-map.json diff --git a/apps/studio/lib/hosted-api-allowlist.ts b/apps/studio/lib/hosted-api-allowlist.ts index 614d863ddc3..8d70221994d 100644 --- a/apps/studio/lib/hosted-api-allowlist.ts +++ b/apps/studio/lib/hosted-api-allowlist.ts @@ -27,6 +27,7 @@ export const HOSTED_SUPPORTED_API_URLS = [ '/api/integrations/stripe-sync', '/content/graphql', '/parse-query', + '/scoped-access-token-permissions', ] // `pathname` must be basePath-relative — Next's `nextUrl.pathname` already is, diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 92c65cbe685..2e149bd25f9 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as OrgChar91_Char93RouteImport } from './routes/org.[_]' import { Route as NewSlugRouteImport } from './routes/new/$slug' import { Route as IntegrationsVercelRouteImport } from './routes/integrations/vercel' import { Route as ApiStatusOverrideRouteImport } from './routes/api/status-override' +import { Route as ApiScopedAccessTokenPermissionsRouteImport } from './routes/api/scoped-access-token-permissions' import { Route as ApiParseQueryRouteImport } from './routes/api/parse-query' import { Route as ApiIncidentStatusRouteImport } from './routes/api/incident-status' import { Route as ApiIncidentBannerRouteImport } from './routes/api/incident-banner' @@ -400,6 +401,12 @@ const ApiStatusOverrideRoute = ApiStatusOverrideRouteImport.update({ path: '/api/status-override', getParentRoute: () => rootRouteImport, } as any) +const ApiScopedAccessTokenPermissionsRoute = + ApiScopedAccessTokenPermissionsRouteImport.update({ + id: '/api/scoped-access-token-permissions', + path: '/api/scoped-access-token-permissions', + getParentRoute: () => rootRouteImport, + } as any) const ApiParseQueryRoute = ApiParseQueryRouteImport.update({ id: '/api/parse-query', path: '/api/parse-query', @@ -2060,6 +2067,7 @@ export interface FileRoutesByFullPath { '/api/incident-banner': typeof ApiIncidentBannerRoute '/api/incident-status': typeof ApiIncidentStatusRoute '/api/parse-query': typeof ApiParseQueryRoute + '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren '/new/$slug': typeof NewSlugRoute @@ -2365,6 +2373,7 @@ export interface FileRoutesByTo { '/api/incident-banner': typeof ApiIncidentBannerRoute '/api/incident-status': typeof ApiIncidentStatusRoute '/api/parse-query': typeof ApiParseQueryRoute + '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren '/new/$slug': typeof NewSlugRoute @@ -2663,6 +2672,7 @@ export interface FileRoutesById { '/api/incident-banner': typeof ApiIncidentBannerRoute '/api/incident-status': typeof ApiIncidentStatusRoute '/api/parse-query': typeof ApiParseQueryRoute + '/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute '/api/status-override': typeof ApiStatusOverrideRoute '/integrations/vercel': typeof IntegrationsVercelRouteWithChildren '/new/$slug': typeof NewSlugRoute @@ -2971,6 +2981,7 @@ export interface FileRouteTypes { | '/api/incident-banner' | '/api/incident-status' | '/api/parse-query' + | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' | '/new/$slug' @@ -3276,6 +3287,7 @@ export interface FileRouteTypes { | '/api/incident-banner' | '/api/incident-status' | '/api/parse-query' + | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' | '/new/$slug' @@ -3573,6 +3585,7 @@ export interface FileRouteTypes { | '/api/incident-banner' | '/api/incident-status' | '/api/parse-query' + | '/api/scoped-access-token-permissions' | '/api/status-override' | '/integrations/vercel' | '/new/$slug' @@ -3871,6 +3884,7 @@ export interface RootRouteChildren { ApiIncidentBannerRoute: typeof ApiIncidentBannerRoute ApiIncidentStatusRoute: typeof ApiIncidentStatusRoute ApiParseQueryRoute: typeof ApiParseQueryRoute + ApiScopedAccessTokenPermissionsRoute: typeof ApiScopedAccessTokenPermissionsRoute ApiStatusOverrideRoute: typeof ApiStatusOverrideRoute IntegrationsVercelRoute: typeof IntegrationsVercelRouteWithChildren NewSlugRoute: typeof NewSlugRoute @@ -4091,6 +4105,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStatusOverrideRouteImport parentRoute: typeof rootRouteImport } + '/api/scoped-access-token-permissions': { + id: '/api/scoped-access-token-permissions' + path: '/api/scoped-access-token-permissions' + fullPath: '/api/scoped-access-token-permissions' + preLoaderRoute: typeof ApiScopedAccessTokenPermissionsRouteImport + parentRoute: typeof rootRouteImport + } '/api/parse-query': { id: '/api/parse-query' path: '/api/parse-query' @@ -6814,6 +6835,7 @@ const rootRouteChildren: RootRouteChildren = { ApiIncidentBannerRoute: ApiIncidentBannerRoute, ApiIncidentStatusRoute: ApiIncidentStatusRoute, ApiParseQueryRoute: ApiParseQueryRoute, + ApiScopedAccessTokenPermissionsRoute: ApiScopedAccessTokenPermissionsRoute, ApiStatusOverrideRoute: ApiStatusOverrideRoute, IntegrationsVercelRoute: IntegrationsVercelRouteWithChildren, NewSlugRoute: NewSlugRoute, diff --git a/apps/studio/routes/api/scoped-access-token-permissions.ts b/apps/studio/routes/api/scoped-access-token-permissions.ts new file mode 100644 index 00000000000..558cfd9f582 --- /dev/null +++ b/apps/studio/routes/api/scoped-access-token-permissions.ts @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' + +// App Router route (apps/studio/app/api/scoped-access-token/route.ts) — already +// Web-native, uses NextResponse which extends `Response`. Direct re-export of +// each HTTP method; no shim needed. +import { GET, HEAD, OPTIONS } from '@/app/api/scoped-access-token-permissions/route' + +export const Route = createFileRoute('/api/scoped-access-token-permissions')({ + server: { + handlers: { + GET: () => GET(), + HEAD: () => HEAD(), + OPTIONS: () => OPTIONS(), + }, + }, +}) diff --git a/apps/studio/vite.config.ts b/apps/studio/vite.config.ts index 0ddb1e5f2ca..ee0bfefb085 100644 --- a/apps/studio/vite.config.ts +++ b/apps/studio/vite.config.ts @@ -547,6 +547,10 @@ export default defineConfig(({ command, mode }) => { postcss: { plugins: [] }, }, ssr: { + optimizeDeps: { + include: ['lodash'], + }, + // `lodash` is CJS; its named-export interop fails in Node ESM unless bundled. // `next/*` must be bundled so our nextCompat shim wins — otherwise Vite's // SSR externalizer leaves `next/router` as a runtime package import and