Scoped PAT: refactor the scope permission map to be computed from live

OPEN API specs
This commit is contained in:
Gildas Garcia committed 2026-07-27 16:26:42 +02:00
1 parent 6808096cf4
commit 73f3885724
19 files changed
+639 -122

No files matched your search

@@ -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,
})
})
})
@@ -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<PermissionScopeMap> => {
// 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<typeof API_SPECS_SCHEMA>,
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
)
),
})
@@ -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"]
}
@@ -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 }
)
}
}
@@ -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' },
])
})
})
@@ -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 = ({
<ResourceAccessStep form={form} />
</div>
<Separator />
<PermissionsAccordion selection={selection} onChange={handlePermissionChange} />
<PermissionsAccordion
selection={selection}
onChange={handlePermissionChange}
permissionScopeMap={permissionScopeMap}
/>
{showMissingPermissionsWarning && (
<div className="space-y-3 px-5 sm:px-6 pb-6">
<Admonition
@@ -92,7 +98,7 @@ export const NewScopedTokenForm = ({
</form>
</Form>
) : (
<NewScopedTokenFormReview values={values} />
<NewScopedTokenFormReview values={values} permissionScopeMap={permissionScopeMap} />
)}
</ScrollArea>
<SheetFooter className="mt-auto flex w-full items-center justify-between! border-t py-4">
@@ -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<OverallRisk['tone'], string> = {
@@ -33,10 +35,10 @@ const RISK_TEXT_CLASS: Record<OverallRisk['tone'], string> = {
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 || <span className="text-foreground-lighter">Untitled token</span>],
@@ -112,7 +121,11 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => {
<div key={entry.key} className="flex items-center gap-2 text-sm">
<span className="text-foreground">{entry.name}</span>
<span className="text-foreground-light">· {modeLabel(mode)}</span>
<RiskMarker entry={entry} withTooltip={false} />
<RiskMarker
entry={entry}
withTooltip={false}
permissionScopeMap={permissionScopeMap}
/>
</div>
))}
</div>
@@ -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 (
<div className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0 space-y-1">
@@ -17,7 +24,7 @@ export const PermissionRow = ({ entry, mode, onChange }: PermissionRowProps) =>
<span className="text-sm text-foreground">
{entry.name} <span className="sr-only">permissions</span>
</span>
<RiskMarker entry={entry} />
<RiskMarker entry={entry} permissionScopeMap={permissionScopeMap} />
</Label>
<p id={`${entry.key}-permissions-description`} className="text-xs text-foreground-light">
{entry.description}
@@ -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<string[]>([
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}
/>
))}
</div>
@@ -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<RiskLevel, string> = {
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 = (
<span
className={cn(
@@ -43,7 +52,10 @@ export const RiskMarker = ({ entry, withTooltip = true, className }: RiskMarkerP
if (!withTooltip) return marker
const mcpTools = getMcpToolsForScopes([...entry.readScopes, ...entry.writeScopes])
const mcpTools = getMcpToolsForScopes({
scopeIds: [...entry.readScopes, ...entry.writeScopes],
permissionScopeMap,
})
return (
<Tooltip>
@@ -69,7 +69,6 @@ export const NewScopedTokenSheet = () => {
setIsOpen(open)
}
console.log({ createdToken })
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetTrigger asChild>
@@ -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<string, unknown>
/** scope id -> the endpoints / MCP tools it (partially) authorizes */
scopes: Record<string, ScopeMapEntry>
/** endpoint -> ALL scopes it requires (conjunctive) */
endpoints: Record<string, string[]>
/** MCP tool -> ALL scopes it requires (conjunctive) */
mcp_tools: Record<string, string[]>
}
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<string>): 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>): 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<string>,
allGrantedScopes: Iterable<string>
): 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>): string[] => {
const tools = new Set<string>()
for (const id of scopeIds) {
PERMISSION_SCOPE_MAP.scopes[id]?.mcp_tools.forEach((tool) => tools.add(tool))
}
return Array.from(tools)
}
@@ -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'],
}
@@ -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<string, ScopeMapEntry>
// e.g { 'GET /v2/projects/{ref}/analytics/log-drains': ['analytics_config_read'] }
export type EndpointMap = Record<string, string[]>
// e.g { 'deploy_edge_function': ['edge_functions_write'] }
export type McpMap = Record<string, string[]>
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<string>
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<string>
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<string>
allGrantedScopes: Iterable<string>
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<string>
permissionScopeMap: PermissionScopeMap | undefined
}): string[] => {
if (permissionScopeMap == null) return []
const tools = new Set<string>()
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 = <TData = PermissionScopeMap>() => {
return useQuery<TData, ScopedAccessTokenPermissionsForScopeError, TData>({
queryKey: scopedAccessTokenKeys.permissions(),
queryFn: ({ signal }) => getGetScopedTokenPermissionsForScope(signal),
})
}
+1
View File
@@ -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,
+22
View File
@@ -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,
@@ -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(),
},
},
})
+4
View File
@@ -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