feat(studio): role-aware access feedback for scoped access tokens

Scoped PATs are enforced server-side as the intersection of the token's
granted scopes and the owner's live role, re-checked on every request. The
UI previously said nothing about this: any member could select any
permission scope for any resource, get no warning at creation, and the
token view kept rendering stored scopes as fact after the owner's access
changed. This adds advisory (never blocking) feedback across the whole
scoped-token flow so users learn at selection time -- not at their first
403 -- what a token can actually do.

Core (AccessToken.roles.ts, all pure and unit-tested):

- FGA_SCOPE_MINIMUM_ROLE: all 83 permission scopes transcribed from the
  OpenFGA model's role unions, mapped to the lowest base role that holds
  them. A drift-guard test pins the key set to the scope ids published in
  @supabase/shared-types, so upstream additions fail CI here with
  re-transcription instructions.
- estimateRoleLevel: derives the user's base role per org (or per project
  for project-invited members) from the ungated /platform/profile/
  permissions rows via four discriminating ABAC probes, verified against
  the platform's default_permissions seeds. Works for every member type
  with no permission-gated endpoint.
- computeTokenRoleContext + applySelectionToRoleContext: role resolution
  (expensive, memoized) is split from selection evaluation (cheap, re-run
  per permission toggle). Results carry per-resource failing roles,
  including per-project detail for members invited to projects rather
  than the org.

Creation form:

- Orgs where the user only has project-level access are disabled in
  org-scope mode, with an inline explanation.
- Permission rows and the review summary show a red "Exceeds your role"
  pill whose tooltip names the exact resources where requests would be
  denied, the required role, and the user's actual role there (spelling
  out per-project roles for project-invited members).
- The review admonition breaks exceeded permissions down per resource,
  organizations first; the risk badge is computed on the role-capped
  effective scope set.
- The immutability warning moved from a persistent admonition to
  micro-copy at the point of commitment; the permissions step links the
  access-control docs.

Token view sheet:

- Lost access is distinguished from deletion (deleting a project/org
  erases the token's FGA bindings; removal does not): bindings that
  vanished render a "resources no longer exist" state, while bindings the
  user can no longer reach show an anonymous count with a "No longer
  accessible" badge and a "removed from" admonition.
- Accessible resources list name plus ref/slug; capabilities carry the
  same exceeds-role pills; risk reflects what the owner's current role
  allows. Header now has separate Access control and API docs buttons
  (DocsButton gained a label prop).

All signals recompute from live org/project/permission queries, so the
view tracks membership changes without stored state, and everything
degrades to "no warnings" while data loads or on self-hosted.
This commit is contained in:
Wen Bo Xie
2026-08-05 17:29:12 +07:00
parent 84c2e7409a
commit 56ef87a8df
21 changed files with 1995 additions and 355 deletions

View File

@@ -16,6 +16,10 @@ export const CLASSIC_TOKEN_WARNING = {
export const MCP_UNSUPPORTED_TITLE = "This token doesn't work with the Supabase MCP server yet"
export const MCP_UNSUPPORTED_DESCRIPTION = 'Support for scoped tokens is coming soon.'
/** Shared tail for every "this token can no longer be used" message. */
export const TOKEN_DENIED_REMEDIATION =
'Requests with this token will be denied. Delete this token and create a new one with the resources and permissions you need.'
export const EXPIRES_AT_OPTIONS = {
hour: { value: 'hour', label: '1 hour' },
day: { value: 'day', label: '1 day' },

View File

@@ -0,0 +1,121 @@
import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
import { addAPIMock } from '@/tests/lib/msw'
import type { Permission } from '@/types'
/**
* Test-only fixtures for the scoped-access-token surfaces. Never import from production code —
* this module pulls in the MSW test harness.
*
* The permission-row builders mirror the shape of /platform/profile/permissions rows for each
* base role, per the ABAC default_permissions seeds (platform: middleware-db). Roles inherit
* lower roles' rows. Keep the rows in lockstep with ROLE_PROBES in AccessToken.roles.ts.
*/
type AccessControlPermission = components['schemas']['AccessControlPermission']
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
/** Satisfies both Studio's `Permission` type and the API's `AccessControlPermission` row shape. */
export type PermissionRowFixture = Permission & {
organization_id: number | null
project_ids: number[] | null
}
export const permissionRow = (
organization_slug: string,
actions: string[],
resources: string[],
project_refs: string[] = []
): PermissionRowFixture => ({
actions: actions as Permission['actions'],
condition: null as unknown as Permission['condition'],
organization_id: null,
organization_slug,
project_ids: null,
resources,
restrictive: false,
project_refs,
})
export const memberRows = (slug: string, refs: string[] = []) => [
permissionRow(slug, ['read:Read'], ['members', 'organizations', 'auth.subject_roles'], refs),
]
export const readonlyRows = (slug: string, refs: string[] = []) => [
...memberRows(slug, refs),
permissionRow(slug, ['analytics:Read', 'tenant:Sql:Read:Select'], ['%'], refs),
]
export const developerRows = (slug: string, refs: string[] = []) => [
...readonlyRows(slug, refs),
permissionRow(
slug,
['functions:Write', 'tenant:Sql:Admin:Write', 'tenant:Sql:Query'],
['%'],
refs
),
]
export const administratorRows = (slug: string, refs: string[] = []) => [
...developerRows(slug, refs),
permissionRow(slug, ['write:Create', 'write:Update'], ['projects'], refs),
permissionRow(slug, ['billing:Write', 'infra:Execute'], ['%'], refs),
]
export const ownerRows = (slug: string, refs: string[] = []) => [
...administratorRows(slug, refs),
permissionRow(slug, ['write:Update'], ['organizations'], refs),
permissionRow(slug, ['write:Create', 'write:Delete'], ['auth.subject_roles'], refs),
]
export const MOCK_ORG = { slug: 'acme-prod', name: 'Acme Production' }
export const MOCK_PROJECT = { ref: 'project-1', name: 'Project 1' }
/**
* Registers the GET mocks every scoped-token surface fires on mount: one organization
* ({@link MOCK_ORG}), one project ({@link MOCK_PROJECT}), and the permission scope map.
*/
export const mockScopedTokenEnvironment = () => {
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }),
]),
})
addAPIMock({
method: 'get',
path: '/platform/projects',
response: () =>
HttpResponse.json<ProjectsResponse>({
pagination: { count: 1, limit: 100, offset: 0 },
projects: [
{
...createMockProject({ id: 1, ref: MOCK_PROJECT.ref, name: MOCK_PROJECT.name }),
organization_slug: MOCK_ORG.slug,
preview_branch_refs: [],
},
],
}),
})
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
path: '/scoped-access-token-permissions',
response: () => HttpResponse.json({ scopes: {}, endpoints: {}, mcp_tools: {} }),
})
}
export const mockPermissionsApi = (rows: PermissionRowFixture[]) =>
addAPIMock({
method: 'get',
path: '/platform/profile/permissions',
// Permission['condition'] (jsonLogic operator interfaces) has no index signature, so TS won't
// match it against the API row's `{ [key: string]: unknown }` — the runtime shape is fine.
response: () =>
HttpResponse.json<AccessControlPermission[]>(rows as unknown as AccessControlPermission[]),
})

View File

@@ -447,9 +447,13 @@ const RESOURCE_METADATA_FALLBACK = (
: 'Read-only access to this resource.',
})
export type PermissionLevel = 'user' | 'organization' | 'project'
export interface PermissionCatalogEntry {
/** Derived resource key, e.g. "project:database" */
key: string
/** Which FGA namespace the resource lives in — decides which role (org vs project) governs it. */
level: PermissionLevel
category: PermissionCategoryKey
name: string
description: string
@@ -482,15 +486,16 @@ const getResource = (key: string): string =>
const buildCatalog = (): PermissionCatalogEntry[] => {
const byResource = new Map<
string,
{ title: string; readScopes: string[]; writeScopes: string[] }
{ level: PermissionLevel; title: string; readScopes: string[]; writeScopes: string[] }
>()
for (const [scope, scopePerms] of Object.entries(FGA)) {
const level = scope.toLowerCase() as PermissionLevel
for (const [permKey, perm] of Object.entries(scopePerms)) {
const resourceKey = `${scope.toLowerCase()}:${getResource(permKey)}`
const resourceKey = `${level}:${getResource(permKey)}`
const action = getAction(permKey)
if (!byResource.has(resourceKey)) {
byResource.set(resourceKey, { title: perm.title, readScopes: [], writeScopes: [] })
byResource.set(resourceKey, { level, title: perm.title, readScopes: [], writeScopes: [] })
}
const entry = byResource.get(resourceKey)!
if (action === 'read') entry.readScopes.push(perm.id)
@@ -499,11 +504,12 @@ const buildCatalog = (): PermissionCatalogEntry[] => {
}
const catalog: PermissionCatalogEntry[] = []
for (const [key, { title, readScopes, writeScopes }] of byResource.entries()) {
for (const [key, { level, title, readScopes, writeScopes }] of byResource.entries()) {
const meta =
RESOURCE_METADATA[key] ?? RESOURCE_METADATA_FALLBACK(key, title, writeScopes.length > 0)
catalog.push({
key,
level,
category: meta.category,
name: meta.name,
description: meta.description,
@@ -541,17 +547,25 @@ export const PERMISSION_CATALOG_BY_CATEGORY: CategoryWithEntries[] = PERMISSION_
/** Map of resource key -> selected mode. Absent keys are treated as 'none'. */
export type PermissionSelection = Record<string, PermissionMode>
/** FGA scope ids a catalog entry grants at the given mode. */
export const getEntryScopes = (
entry: PermissionCatalogEntry,
mode: PermissionMode
): ScopedAccessTokenPermission[] => {
if (mode === 'none') return []
if (mode === 'readwrite') return [...entry.readScopes, ...entry.writeScopes]
return entry.readScopes
}
/** Flattens a selection into the concrete FGA scope ids to send to the API. */
export const selectionToScopes = (
selection: PermissionSelection
): ScopedAccessTokenPermission[] => {
const scopes: ScopedAccessTokenPermission[] = []
for (const [key, mode] of Object.entries(selection)) {
if (mode === 'none') continue
const entry = CATALOG_BY_KEY.get(key)
if (!entry) continue
scopes.push(...entry.readScopes)
if (mode === 'readwrite') scopes.push(...entry.writeScopes)
scopes.push(...getEntryScopes(entry, mode))
}
return Array.from(new Set(scopes))
}
@@ -588,12 +602,39 @@ export const RISK_LEVEL_LABEL: Record<RiskLevel, string> = {
high: 'High risk',
}
export const PERMISSION_MODE_LABEL: Record<PermissionMode, string> = {
none: 'None',
read: 'Read',
readwrite: 'Read-write',
}
export const RISK_BADGE_VARIANT: Record<RiskLevel, 'success' | 'warning' | 'destructive'> = {
low: 'success',
medium: 'warning',
high: 'destructive',
}
export const RISK_TONE_VARIANT: Record<
OverallRisk['tone'],
'default' | 'success' | 'warning' | 'destructive'
> = {
default: 'default',
...RISK_BADGE_VARIANT,
}
export const RISK_DOT_CLASS: Record<RiskLevel, string> = {
low: 'bg-brand-600',
medium: 'bg-warning-600',
high: 'bg-destructive-600',
}
export type ResourceAccessMode = 'project' | 'organization' | 'account'
export interface OverallRisk {
/** Minimal | Low | Medium | Elevated | High */
level: string
text: string
/** Explanation without the level prefix — the level renders separately (e.g. as a badge). */
description: string
tone: 'default' | 'low' | 'medium' | 'high'
}
@@ -607,7 +648,7 @@ export const computeOverallRisk = (
): OverallRisk => {
const active = Object.entries(selection).filter(([, mode]) => mode !== 'none')
if (active.length === 0) {
return { level: 'Minimal', text: 'Minimal — no capabilities', tone: 'default' }
return { level: 'Minimal', description: 'no capabilities', tone: 'default' }
}
const anyWrite = active.some(([, mode]) => mode === 'readwrite')
@@ -639,5 +680,5 @@ export const computeOverallRisk = (
tone = 'low'
}
return { level, text: `${level}${scopeWord} ${accessWord} access`, tone }
return { level, description: `${scopeWord} ${accessWord} access`, tone }
}

View File

@@ -0,0 +1,343 @@
import { permissions } from '@supabase/shared-types'
import { describe, expect, it } from 'vitest'
import {
administratorRows,
developerRows,
memberRows,
ownerRows,
readonlyRows,
permissionRow as row,
} from './AccessToken.fixtures'
import { getCatalogEntry, type PermissionSelection } from './AccessToken.permissions'
import {
applySelectionToRoleContext,
computeTokenRoleContext,
estimateRoleLevel,
FGA_SCOPE_MINIMUM_ROLE,
getIsProjectScopedOnly,
requiredRoleForEntry,
type TokenRoleContextArgs,
} from './AccessToken.roles'
type EvaluateTokenAccessArgs = TokenRoleContextArgs & { selection: PermissionSelection }
/** Composes the two production entry points the way `useTokenAccessEvaluation` does. */
const evaluateTokenAccess = ({ selection, ...contextArgs }: EvaluateTokenAccessArgs) =>
applySelectionToRoleContext(computeTokenRoleContext(contextArgs), selection)
const ORG = { slug: 'acme' }
const OTHER_ORG = { slug: 'globex' }
const PROJECT = { ref: 'abcdefghij1234567890', organization_slug: 'acme' }
const OTHER_PROJECT = { ref: 'klmnopqrst1234567890', organization_slug: 'acme' }
const baseArgs: Omit<EvaluateTokenAccessArgs, 'permissions'> = {
selection: {},
resourceAccess: 'organization',
organizationSlugs: [ORG.slug],
projectRefs: [],
organizations: [ORG, OTHER_ORG],
projects: [PROJECT, OTHER_PROJECT],
}
describe('FGA_SCOPE_MINIMUM_ROLE', () => {
it('covers exactly the scope ids published in @supabase/shared-types', () => {
const publishedIds = Object.values(permissions.FgaPermissions)
.flatMap((group) => Object.values(group))
.map((permission) => permission.id)
.sort()
const mappedIds = Object.keys(FGA_SCOPE_MINIMUM_ROLE).sort()
// If this fails, a scope was added/removed upstream: re-transcribe the role unions from the
// OpenFGA model (platform: openfga/model/supabase.fga) into FGA_SCOPE_MINIMUM_ROLE.
expect(mappedIds).toEqual(publishedIds)
})
})
describe('estimateRoleLevel', () => {
it('identifies each base role from its permission rows', () => {
expect(estimateRoleLevel(ownerRows(ORG.slug), ORG.slug)).toBe('owner')
expect(estimateRoleLevel(administratorRows(ORG.slug), ORG.slug)).toBe('administrator')
expect(estimateRoleLevel(developerRows(ORG.slug), ORG.slug)).toBe('developer')
expect(estimateRoleLevel(readonlyRows(ORG.slug), ORG.slug)).toBe('readonly')
expect(estimateRoleLevel(memberRows(ORG.slug), ORG.slug)).toBe('member')
expect(estimateRoleLevel([], ORG.slug)).toBe('none')
})
it('scopes the estimate to the queried organization', () => {
const rows = [...ownerRows(ORG.slug), ...readonlyRows(OTHER_ORG.slug)]
expect(estimateRoleLevel(rows, ORG.slug)).toBe('owner')
expect(estimateRoleLevel(rows, OTHER_ORG.slug)).toBe('readonly')
})
it('resolves project-scoped roles only for their projects', () => {
const rows = developerRows(ORG.slug, [PROJECT.ref])
expect(estimateRoleLevel(rows, ORG.slug, PROJECT.ref)).toBe('developer')
// Org-level (no project) the same user is only a member.
expect(estimateRoleLevel(rows, ORG.slug)).toBe('member')
})
})
describe('project-scoped membership helpers', () => {
it('detects project-scoped-only membership', () => {
expect(getIsProjectScopedOnly(developerRows(ORG.slug, [PROJECT.ref]), ORG.slug)).toBe(true)
expect(getIsProjectScopedOnly(developerRows(ORG.slug), ORG.slug)).toBe(false)
expect(getIsProjectScopedOnly([], ORG.slug)).toBe(false)
})
})
describe('requiredRoleForEntry', () => {
it('maps read and readwrite modes to the FGA role unions', () => {
const database = getCatalogEntry('project:database')!
expect(requiredRoleForEntry(database, 'read')).toBe('readonly')
expect(requiredRoleForEntry(database, 'readwrite')).toBe('developer')
const members = getCatalogEntry('organization:members')!
expect(requiredRoleForEntry(members, 'read')).toBe('readonly')
expect(requiredRoleForEntry(members, 'readwrite')).toBe('administrator')
const orgAdmin = getCatalogEntry('organization:admin')!
expect(requiredRoleForEntry(orgAdmin, 'readwrite')).toBe('owner')
})
it('takes the strictest scope when readwrite spans multiple write scopes', () => {
// branching_production_write is developer, but create/delete require administrator.
const branching = getCatalogEntry('project:branching_production')!
expect(requiredRoleForEntry(branching, 'readwrite')).toBe('administrator')
})
})
describe('evaluateTokenAccess', () => {
it('is unknown while permissions are loading', () => {
const result = evaluateTokenAccess({
...baseArgs,
selection: { 'project:database': 'readwrite' },
permissions: undefined,
})
expect(result.status).toBe('unknown')
expect(result.exceedingEntryKeys).toEqual([])
expect(result.entries['project:database'].status).toBe('unknown')
})
it('passes everything the users role covers', () => {
const result = evaluateTokenAccess({
...baseArgs,
selection: { 'project:database': 'readwrite', 'organization:members': 'readwrite' },
permissions: administratorRows(ORG.slug),
})
expect(result.status).toBe('evaluated')
expect(result.exceedingEntryKeys).toEqual([])
expect(result.effectiveSelection).toEqual({
'project:database': 'readwrite',
'organization:members': 'readwrite',
})
})
it('flags selections above the users role and downgrades the effective mode', () => {
const result = evaluateTokenAccess({
...baseArgs,
selection: {
'project:database': 'readwrite', // requires developer
'project:advisors': 'read', // requires readonly
},
permissions: readonlyRows(ORG.slug),
})
expect(result.exceedingEntryKeys).toEqual(['project:database'])
expect(result.entries['project:database']).toMatchObject({
status: 'exceeds-role',
effectiveMode: 'read',
requiredRole: 'developer',
})
expect(result.effectiveSelection).toEqual({
'project:database': 'read',
'project:advisors': 'read',
})
})
it('drops entries whose read mode already exceeds the role', () => {
const result = evaluateTokenAccess({
...baseArgs,
selection: { 'project:api_gateway_keys': 'read' }, // read requires developer
permissions: readonlyRows(ORG.slug),
})
expect(result.entries['project:api_gateway_keys']).toMatchObject({
status: 'exceeds-role',
effectiveMode: 'none',
})
expect(result.effectiveSelection).toEqual({})
})
it('uses the weakest role across multiple bound organizations and names the failing ones', () => {
const result = evaluateTokenAccess({
...baseArgs,
organizationSlugs: [ORG.slug, OTHER_ORG.slug],
selection: { 'project:database': 'readwrite' },
permissions: [...ownerRows(ORG.slug), ...readonlyRows(OTHER_ORG.slug)],
})
expect(result.exceedingEntryKeys).toEqual(['project:database'])
// Only the org where the role is insufficient is called out.
expect(result.entries['project:database'].failingResources).toEqual([
{
type: 'organization',
id: OTHER_ORG.slug,
label: OTHER_ORG.slug,
role: 'readonly',
projectScopedRoles: undefined,
},
])
})
it('evaluates project mode per selected project for project-scoped members', () => {
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref],
selection: { 'project:database': 'readwrite', 'organization:members': 'read' },
permissions: developerRows(ORG.slug, [PROJECT.ref]),
})
// 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([
{
type: 'organization',
id: ORG.slug,
label: ORG.slug,
role: 'member',
projectScopedRoles: [{ label: PROJECT.ref, role: 'developer' }],
},
])
})
it('explains org-level failures for members invited only to a project', () => {
// Read-only on one project, selecting Organization Settings read-write (requires Owner).
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref],
selection: { 'organization:admin': 'readwrite' },
permissions: readonlyRows(ORG.slug, [PROJECT.ref]),
organizations: [{ ...ORG, name: 'Acme Corp' }],
projects: [{ ...PROJECT, name: 'Acme production' }],
})
expect(result.entries['organization:admin']).toMatchObject({
status: 'exceeds-role',
requiredRole: 'owner',
failingResources: [
{
type: 'organization',
id: ORG.slug,
label: 'Acme Corp',
role: 'member',
projectScopedRoles: [{ label: 'Acme production', role: 'readonly' }],
},
],
})
})
it('attaches project-scoped detail even when stray org-level rows exist', () => {
// Real permissions data can include org-level rows (e.g. restrictive rules) alongside a
// project-scoped role; the per-project detail must still resolve.
const strayOrgRow = row(ORG.slug, ['read:Read'], ['notifications'])
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref],
selection: { 'organization:admin': 'readwrite' },
permissions: [...readonlyRows(ORG.slug, [PROJECT.ref]), strayOrgRow],
projects: [{ ...PROJECT, name: 'Acme production' }],
})
expect(result.entries['organization:admin'].failingResources).toEqual([
{
type: 'organization',
id: ORG.slug,
label: ORG.slug,
role: 'member',
projectScopedRoles: [{ label: 'Acme production', role: 'readonly' }],
},
])
})
it('does not attach project-scoped detail for organization-wide members', () => {
const result = evaluateTokenAccess({
...baseArgs,
selection: { 'organization:admin': 'readwrite' },
permissions: developerRows(ORG.slug),
})
expect(result.entries['organization:admin'].failingResources).toEqual([
{
type: 'organization',
id: ORG.slug,
label: ORG.slug,
role: 'developer',
projectScopedRoles: undefined,
},
])
})
it('labels failing resources with their display names when provided', () => {
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref],
selection: { 'project:database': 'readwrite' },
permissions: readonlyRows(ORG.slug),
projects: [{ ...PROJECT, name: 'Acme production' }],
})
expect(result.entries['project:database'].failingResources).toEqual([
{ type: 'project', id: PROJECT.ref, label: 'Acme production', role: 'readonly' },
])
})
it('reports bound resources the user can no longer access', () => {
const result = evaluateTokenAccess({
...baseArgs,
organizationSlugs: ['departed-org'],
selection: { 'project:database': 'read' },
permissions: readonlyRows(ORG.slug),
organizations: [ORG],
})
expect(result.inaccessibleOrgSlugs).toEqual(['departed-org'])
expect(result.hasNoAccessibleResource).toBe(true)
expect(result.entries['project:database'].status).toBe('unknown')
})
it('reports partially inaccessible projects while still evaluating the rest', () => {
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
projectRefs: [PROJECT.ref, 'gone-project-ref-123'],
selection: { 'project:database': 'read' },
permissions: readonlyRows(ORG.slug),
})
expect(result.inaccessibleProjectRefs).toEqual(['gone-project-ref-123'])
expect(result.hasNoAccessibleResource).toBe(false)
expect(result.entries['project:database'].status).toBe('ok')
})
it('is unknown before any resource is selected', () => {
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'project',
organizationSlugs: [ORG.slug],
projectRefs: [],
selection: { 'project:database': 'readwrite' },
permissions: readonlyRows(ORG.slug),
})
expect(result.status).toBe('unknown')
expect(result.exceedingEntryKeys).toEqual([])
})
it('never flags account-scoped tokens', () => {
const result = evaluateTokenAccess({
...baseArgs,
resourceAccess: 'account',
organizationSlugs: [],
selection: { 'project:database': 'readwrite' },
permissions: memberRows(ORG.slug),
})
expect(result.exceedingEntryKeys).toEqual([])
expect(result.entries['project:database'].status).toBe('ok')
})
})

View File

@@ -0,0 +1,584 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import type {
PermissionCatalogEntry,
PermissionMode,
PermissionSelection,
ResourceAccessMode,
} from './AccessToken.permissions'
import { getCatalogEntry, getEntryScopes } from './AccessToken.permissions'
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
import type { Permission } from '@/types'
/**
* Client-side estimation of what a scoped token can actually do, given its owner's current role.
*
* Scoped tokens are enforced server-side as the intersection of the token's granted scopes and
* the owner's live role, re-checked on every request. Nothing here gates anything — these helpers
* only power advisory UI (warnings in the creation flow, status badges in the token view) so users
* aren't surprised when an over-provisioned scope returns 403.
*
* The minimum-role table below is transcribed from the OpenFGA authorization model
* (platform: openfga/model/supabase.fga), where every permission is a union of base roles.
* AccessToken.roles.test.ts asserts the table stays in sync with the scope ids in
* `@supabase/shared-types` FgaPermissions.
*/
/** Base-role ladder. `member` covers org membership without a base role (e.g. project-scoped users at org level). */
export const TOKEN_ROLE_LEVELS = [
'none',
'member',
'readonly',
'developer',
'administrator',
'owner',
] as const
export type TokenRoleLevel = (typeof TOKEN_ROLE_LEVELS)[number]
const ROLE_RANK = Object.fromEntries(
TOKEN_ROLE_LEVELS.map((role, index) => [role, index])
) as Record<TokenRoleLevel, number>
export const TOKEN_ROLE_LABEL: Record<TokenRoleLevel, string> = {
none: 'No role',
member: 'Member',
readonly: 'Read-only',
developer: 'Developer',
administrator: 'Administrator',
owner: 'Owner',
}
const rankOf = (role: TokenRoleLevel) => ROLE_RANK[role]
const minRole = (a: TokenRoleLevel, b: TokenRoleLevel): TokenRoleLevel =>
rankOf(a) <= rankOf(b) ? a : b
const maxRole = (a: TokenRoleLevel, b: TokenRoleLevel): TokenRoleLevel =>
rankOf(a) >= rankOf(b) ? a : b
/**
* Lowest base role that holds each FGA permission scope, transcribed from the role unions in the
* OpenFGA model. Keep in the same order as the model for easy diffing.
*
* The drift-guard test only pins the *key set* (scope ids). The role values have no automated
* guard: if a role union changes in the OpenFGA model (e.g. a `_write` scope moves from developer
* to administrator), CI stays green and this advisory UI silently gives stale guidance until the
* value is re-transcribed here. Reviewers of FGA model changes must update this table in the same
* change.
*/
export const FGA_SCOPE_MINIMUM_ROLE: Record<string, TokenRoleLevel> = {
// user — available to any authenticated account, no org role required
organizations_read: 'member',
organizations_create: 'member',
projects_read: 'member',
snippets_read: 'member',
// organization
organization_admin_read: 'member',
organization_admin_write: 'owner',
organization_projects_read: 'member',
organization_projects_create: 'administrator',
members_read: 'readonly',
members_write: 'administrator',
platform_webhooks_organization_read: 'member',
platform_webhooks_organization_write: 'administrator',
// project
project_admin_read: 'member',
project_admin_write: 'administrator',
action_runs_read: 'readonly',
action_runs_write: 'developer',
advisors_read: 'readonly',
analytics_config_read: 'developer',
analytics_config_write: 'administrator',
analytics_logs_read: 'readonly',
analytics_usage_read: 'readonly',
api_gateway_keys_read: 'developer',
api_gateway_keys_write: 'administrator',
auth_config_read: 'readonly',
auth_config_write: 'developer',
auth_signing_keys_read: 'developer',
auth_signing_keys_write: 'developer',
backups_read: 'readonly',
backups_write: 'developer',
branching_development_create: 'developer',
branching_development_delete: 'developer',
branching_development_read: 'readonly',
branching_development_write: 'developer',
branching_production_create: 'administrator',
branching_production_delete: 'administrator',
branching_production_read: 'readonly',
branching_production_write: 'developer',
custom_domain_read: 'readonly',
custom_domain_write: 'administrator',
data_api_config_read: 'readonly',
data_api_config_write: 'administrator',
database_read: 'readonly',
database_write: 'developer',
database_config_read: 'readonly',
database_config_write: 'administrator',
database_jit_read: 'readonly',
database_jit_write: 'administrator',
database_network_bans_read: 'readonly',
database_network_bans_write: 'administrator',
database_network_restrictions_read: 'readonly',
database_network_restrictions_write: 'administrator',
database_migrations_read: 'readonly',
database_migrations_write: 'developer',
database_pooling_config_read: 'readonly',
database_pooling_config_write: 'administrator',
database_readonly_config_read: 'readonly',
database_readonly_config_write: 'administrator',
database_ssl_config_read: 'readonly',
database_ssl_config_write: 'administrator',
database_webhooks_config_read: 'readonly',
database_webhooks_config_write: 'developer',
edge_functions_read: 'readonly',
edge_functions_write: 'developer',
edge_functions_secrets_read: 'readonly',
edge_functions_secrets_write: 'administrator',
infra_add_ons_read: 'readonly',
infra_add_ons_write: 'administrator',
infra_disk_config_read: 'readonly',
infra_disk_config_write: 'administrator',
infra_read_replicas_read: 'readonly',
infra_read_replicas_write: 'administrator',
project_snippets_read: 'readonly',
project_snippets_write: 'readonly',
realtime_config_read: 'readonly',
realtime_config_write: 'administrator',
storage_read: 'readonly',
storage_write: 'developer',
storage_config_read: 'readonly',
storage_config_write: 'administrator',
vanity_subdomain_read: 'administrator',
vanity_subdomain_write: 'administrator',
platform_webhooks_projects_read: 'member',
platform_webhooks_projects_write: 'administrator',
}
/**
* ABAC checks that identify the user's base role from their own permission rows (the ungated
* /platform/profile/permissions response). Base roles inherit each other's rows
* (Owner ⊃ Administrator ⊃ Developer ⊃ Read-only), so the first probe that passes, walking
* top-down, is the user's level. Each probe is a permission only that role and above holds.
*/
const ROLE_PROBES: { role: TokenRoleLevel; action: string; resource: string }[] = [
{ role: 'owner', action: PermissionAction.UPDATE, resource: 'organizations' },
{ role: 'administrator', action: PermissionAction.CREATE, resource: 'projects' },
{ role: 'developer', action: PermissionAction.FUNCTIONS_WRITE, resource: 'functions' },
{ role: 'readonly', action: PermissionAction.TENANT_SQL_SELECT, resource: 'sql' },
]
/**
* Estimates the user's base role in an organization (or on a specific project, when the user's
* access is project-scoped). Custom roles resolve to the nearest base role by capability, which
* matches how they behave in the FGA model.
*/
export const estimateRoleLevel = (
permissions: Permission[],
organizationSlug: string,
projectRef?: string
): TokenRoleLevel => {
for (const probe of ROLE_PROBES) {
if (
doPermissionsCheck(
permissions,
probe.action,
probe.resource,
undefined,
organizationSlug,
projectRef
)
) {
return probe.role
}
}
const isMember = permissions.some(
(permission) => permission.organization_slug === organizationSlug
)
return isMember ? 'member' : 'none'
}
/** True when every permission row the user holds in the org is limited to specific projects. */
export const getIsProjectScopedOnly = (
permissions: Permission[],
organizationSlug: string
): boolean => {
const orgRows = permissions.filter(
(permission) => permission.organization_slug === organizationSlug
)
if (orgRows.length === 0) return false
return orgRows.every(
(permission) => permission.project_refs !== undefined && permission.project_refs.length > 0
)
}
/** Lowest role that holds every scope in the list. Unknown scope ids assume `owner` (warn rather than promise). */
const requiredRoleForScopes = (scopeIds: string[]): TokenRoleLevel => {
let required: TokenRoleLevel = 'member'
for (const id of scopeIds) {
required = maxRole(required, FGA_SCOPE_MINIMUM_ROLE[id] ?? 'owner')
}
return required
}
/** Lowest role that can exercise a catalog entry at the given mode. */
export const requiredRoleForEntry = (
entry: PermissionCatalogEntry,
mode: PermissionMode
): TokenRoleLevel =>
mode === 'none' ? 'member' : requiredRoleForScopes(getEntryScopes(entry, mode))
export type EntryAccessStatus = 'ok' | 'exceeds-role' | 'unknown'
/** A token-bound resource where the user's current role can't exercise the selected mode. */
export interface FailingResource {
type: 'organization' | 'project'
/** Org slug or project ref — unique, unlike `label`. Use for React keys and grouping. */
id: string
/** Display name of the org/project, falling back to its slug/ref. */
label: string
/** The user's current role on that resource. */
role: TokenRoleLevel
/**
* Set when the user has no organization-level role here but does hold roles on specific
* projects (they were invited to projects, not the org). Lets the UI say "your role is
* Read-only on the project X" instead of an opaque org-level pseudo-role.
*/
projectScopedRoles?: { label: string; role: TokenRoleLevel }[]
}
export interface EntryAccess {
status: EntryAccessStatus
/** Highest mode the user's current role can exercise for this entry. */
effectiveMode: PermissionMode
/** Lowest role that could exercise the selected mode. */
requiredRole: TokenRoleLevel
/** Resources where the selected mode would be denied (empty unless status is 'exceeds-role'). */
failingResources: FailingResource[]
}
export interface TokenAccessEvaluation {
/** 'unknown' while the user's permissions are loading (or on self-hosted) — show no warnings. */
status: 'unknown' | 'evaluated'
/** Token-bound orgs the user can no longer access. */
inaccessibleOrgSlugs: string[]
/** Token-bound projects the user can no longer access. */
inaccessibleProjectRefs: string[]
/** True when a resource-scoped token has no bindings left — everything it was bound to was deleted. */
hasNoBoundResources: boolean
/** True when the token is bound to resources but the user can access none of them. */
hasNoAccessibleResource: boolean
/** Per selected catalog entry key. */
entries: Record<string, EntryAccess>
/** Entry keys whose selected mode exceeds the user's current role. */
exceedingEntryKeys: string[]
/** Selection reduced to what the user's current role can exercise. */
effectiveSelection: PermissionSelection
}
export interface TokenRoleContextArgs {
resourceAccess: ResourceAccessMode
/** Token-bound org slugs (organization mode), or the parent org (project mode, from the form). */
organizationSlugs: string[]
/** Token-bound project refs (project mode). */
projectRefs: string[]
/** The user's own ABAC permission rows; undefined while loading. */
permissions: Permission[] | undefined
/** Organizations the user can currently access. */
organizations: { slug: string; name?: string }[]
/** Projects the user can currently access. */
projects: { ref: string; organization_slug: string; name?: string }[]
}
/**
* Selection-independent role resolution for a token's bound resources. Resolving roles walks the
* user's full permission list several times, so callers should memoize this on its inputs and
* apply (cheap) selection changes via `applySelectionToRoleContext`.
*/
export interface TokenRoleContext {
status: 'unknown' | 'evaluated'
resourceAccess: ResourceAccessMode
inaccessibleOrgSlugs: string[]
inaccessibleProjectRefs: string[]
hasNoBoundResources: boolean
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). */
projectLevels: FailingResource[]
/** Weakest role across orgLevels / projectLevels. */
orgLevel: TokenRoleLevel
projectLevel: TokenRoleLevel
}
const UNKNOWN_ENTRY: EntryAccess = {
status: 'unknown',
effectiveMode: 'none',
requiredRole: 'member',
failingResources: [],
}
const minOver = (levels: FailingResource[]): TokenRoleLevel =>
levels.length === 0
? 'none'
: levels.reduce<TokenRoleLevel>((lowest, level) => minRole(lowest, level.role), 'owner')
export const computeTokenRoleContext = ({
resourceAccess,
organizationSlugs,
projectRefs,
permissions,
organizations,
projects,
}: TokenRoleContextArgs): TokenRoleContext => {
const boundResourceIds =
resourceAccess === 'project'
? projectRefs
: resourceAccess === 'organization'
? organizationSlugs
: []
const hasNoBoundResources = resourceAccess !== 'account' && boundResourceIds.length === 0
const unknownContext = (status: TokenRoleContext['status']): TokenRoleContext => ({
status,
resourceAccess,
inaccessibleOrgSlugs: [],
inaccessibleProjectRefs: [],
hasNoBoundResources,
hasNoAccessibleResource: false,
orgLevels: [],
projectLevels: [],
orgLevel: 'none',
projectLevel: 'none',
})
// Nothing to evaluate while permissions load, or until resources are chosen (mid-form state).
if (permissions === undefined || hasNoBoundResources) return unknownContext('unknown')
if (resourceAccess === 'account') return unknownContext('evaluated')
const knownOrgSlugs = new Set(organizations.map((org) => org.slug))
const projectsByRef = new Map(projects.map((project) => [project.ref, project]))
const inaccessibleOrgSlugs = organizationSlugs.filter((slug) => !knownOrgSlugs.has(slug))
// Only meaningful in project mode — the form can carry stale projectRefs after a mode switch.
const inaccessibleProjectRefs =
resourceAccess === 'project' ? projectRefs.filter((ref) => !projectsByRef.has(ref)) : []
const accessibleOrgSlugs = organizationSlugs.filter((slug) => knownOrgSlugs.has(slug))
const accessibleProjects = projectRefs.flatMap((ref) => projectsByRef.get(ref) ?? [])
const hasNoAccessibleResource =
resourceAccess === 'project' ? accessibleProjects.length === 0 : accessibleOrgSlugs.length === 0
if (hasNoAccessibleResource) {
return {
...unknownContext('evaluated'),
inaccessibleOrgSlugs,
inaccessibleProjectRefs,
hasNoAccessibleResource,
}
}
// Role probes walk every permission row; the same org/project pair is asked for repeatedly
// (project levels + project-scoped detail), so resolve each pair once.
const roleCache = new Map<string, TokenRoleLevel>()
const roleFor = (slug: string, ref?: string): TokenRoleLevel => {
const cacheKey = `${slug}|${ref ?? ''}`
const cached = roleCache.get(cacheKey)
if (cached !== undefined) return cached
const role = estimateRoleLevel(permissions, slug, ref)
roleCache.set(cacheKey, role)
return role
}
const organizationsBySlug = new Map(organizations.map((org) => [org.slug, org]))
// The form passes the parent org even in project mode; the token view may not, so fall back to
// the bound projects' parent orgs when no org slug was provided.
const orgSlugsForLevels =
accessibleOrgSlugs.length > 0
? accessibleOrgSlugs
: Array.from(new Set(accessibleProjects.map((project) => project.organization_slug)))
// For members without an organization-level role, resolve their per-project roles so org-level
// failures can explain the distinction (invited to projects, not the org). In project mode only
// the token-bound projects are relevant; in organization mode (e.g. a token that predates a
// role change) look at every project they can access in the org.
const getProjectScopedRoles = (
slug: string,
orgRole: TokenRoleLevel
): FailingResource['projectScopedRoles'] => {
if (rankOf(orgRole) >= ROLE_RANK.readonly) return undefined
const candidates =
resourceAccess === 'project'
? accessibleProjects.filter((project) => project.organization_slug === slug)
: projects.filter((project) => project.organization_slug === slug)
const roles = candidates.flatMap((project) => {
const role = roleFor(slug, project.ref)
if (rankOf(role) < ROLE_RANK.readonly) return []
return [{ label: project.name ?? project.ref, role }]
})
return roles.length > 0 ? roles : undefined
}
const orgLevels: FailingResource[] = orgSlugsForLevels.map((slug) => {
const role = roleFor(slug)
return {
type: 'organization',
id: slug,
label: organizationsBySlug.get(slug)?.name ?? slug,
role,
projectScopedRoles: getProjectScopedRoles(slug, role),
}
})
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
return {
status: 'evaluated',
resourceAccess,
inaccessibleOrgSlugs,
inaccessibleProjectRefs,
hasNoBoundResources,
hasNoAccessibleResource,
orgLevels,
projectLevels,
orgLevel: minOver(orgLevels),
projectLevel: minOver(projectLevels),
}
}
/**
* Applies a scope selection to a resolved role context. Cheap — safe to re-run on every
* permission toggle. Account-scoped (legacy/user) tokens track the owner's access by definition,
* so every entry evaluates as 'ok' there.
*/
export const applySelectionToRoleContext = (
context: TokenRoleContext,
selection: PermissionSelection
): TokenAccessEvaluation => {
const base = {
status: context.status,
inaccessibleOrgSlugs: context.inaccessibleOrgSlugs,
inaccessibleProjectRefs: context.inaccessibleProjectRefs,
hasNoBoundResources: context.hasNoBoundResources,
hasNoAccessibleResource: context.hasNoAccessibleResource,
exceedingEntryKeys: [] as string[],
effectiveSelection: selection,
}
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') {
return {
...base,
entries: Object.fromEntries(
selectedKeys.map((key): [string, EntryAccess] => [
key,
{
status: 'ok',
effectiveMode: selection[key],
requiredRole: 'member',
failingResources: [],
},
])
),
}
}
if (context.status === 'unknown' || context.hasNoAccessibleResource) {
return {
...base,
entries: Object.fromEntries(selectedKeys.map((key) => [key, UNKNOWN_ENTRY])),
}
}
const { orgLevel, projectLevel, orgLevels, projectLevels } = context
const entries: Record<string, EntryAccess> = {}
const exceedingEntryKeys: string[] = []
const effectiveSelection: PermissionSelection = {}
for (const key of selectedKeys) {
const mode = selection[key]
const entry = getCatalogEntry(key)
if (!entry) continue
const availableLevel =
entry.level === 'user' ? 'owner' : entry.level === 'organization' ? orgLevel : projectLevel
const requiredRole = requiredRoleForEntry(entry, mode)
let effectiveMode: PermissionMode = 'none'
if (rankOf(availableLevel) >= rankOf(requiredRole)) {
effectiveMode = mode
} else if (
mode === 'readwrite' &&
rankOf(availableLevel) >= rankOf(requiredRoleForEntry(entry, 'read'))
) {
effectiveMode = 'read'
}
const status: EntryAccessStatus = effectiveMode === mode ? 'ok' : 'exceeds-role'
const relevantLevels =
entry.level === 'user' ? [] : entry.level === 'organization' ? orgLevels : projectLevels
const failingResources =
status === 'exceeds-role'
? relevantLevels.filter((level) => rankOf(level.role) < rankOf(requiredRole))
: []
entries[key] = { status, effectiveMode, requiredRole, failingResources }
if (status === 'exceeds-role') exceedingEntryKeys.push(key)
if (effectiveMode !== 'none') effectiveSelection[key] = effectiveMode
}
return { ...base, entries, exceedingEntryKeys, effectiveSelection }
}
export interface FailingResourceGroup {
type: 'organization' | 'project'
resource: FailingResource
entries: { key: string; name: string; mode: PermissionMode; requiredRole: TokenRoleLevel }[]
}
/**
* Inverts the evaluation's entry → failingResources mapping into resource → failing entries
* (organizations first, then alphabetical) for per-resource breakdowns.
*/
export const groupFailingResources = (
evaluation: TokenAccessEvaluation,
selection: PermissionSelection
): FailingResourceGroup[] => {
const groups = new Map<string, FailingResourceGroup>()
for (const key of evaluation.exceedingEntryKeys) {
const entryAccess = evaluation.entries[key]
const entry = getCatalogEntry(key)
const mode = selection[key]
if (entryAccess === undefined || entry === undefined || mode === undefined) continue
for (const resource of entryAccess.failingResources) {
const groupKey = `${resource.type}:${resource.id}`
let group = groups.get(groupKey)
if (group === undefined) {
group = { type: resource.type, resource, entries: [] }
groups.set(groupKey, group)
}
group.entries.push({ key, name: entry.name, mode, requiredRole: entryAccess.requiredRole })
}
}
return Array.from(groups.values()).sort((a, b) => {
if (a.type === b.type) return a.resource.label.localeCompare(b.resource.label)
return a.type === 'organization' ? -1 : 1
})
}

View File

@@ -0,0 +1,74 @@
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import {
PERMISSION_MODE_LABEL,
type PermissionCatalogEntry,
type PermissionMode,
} from '../AccessToken.permissions'
import { TOKEN_ROLE_LABEL, type EntryAccess, type FailingResource } from '../AccessToken.roles'
const MAX_LISTED_RESOURCES = 5
const MAX_LISTED_PROJECT_ROLES = 3
/**
* One line per failing resource. Members invited to projects (not the org) get their real
* per-project role spelled out. Shared with the review step's per-resource breakdown.
*/
export const failingResourceLine = (resource: FailingResource): string => {
if (resource.projectScopedRoles !== undefined && resource.projectScopedRoles.length > 0) {
const listed = resource.projectScopedRoles
.slice(0, MAX_LISTED_PROJECT_ROLES)
.map((project) => `${TOKEN_ROLE_LABEL[project.role]} on the project ${project.label}`)
.join(', ')
const overflow = resource.projectScopedRoles.length - MAX_LISTED_PROJECT_ROLES
const roles = overflow > 0 ? `${listed}, and ${overflow} more` : listed
return `${resource.label} — your role is ${roles}`
}
if (resource.role === 'member' || resource.role === 'none') {
return resource.type === 'organization'
? `${resource.label} — you don't have an organization-level role`
: `${resource.label} — you don't have a role on this project`
}
return `${resource.label} — your role is ${TOKEN_ROLE_LABEL[resource.role]}`
}
interface ExceedsRoleBadgeProps {
entry: PermissionCatalogEntry
mode: PermissionMode
access: EntryAccess
}
/**
* "Exceeds your role" pill with a tooltip naming exactly which resources deny the permission and
* why. Shared between the permissions step, the review step, and the token view sheet.
*/
export const ExceedsRoleBadge = ({ entry, mode, access }: ExceedsRoleBadgeProps) => {
const failingResources = access.failingResources
const overflowCount = failingResources.length - MAX_LISTED_RESOURCES
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>
<Badge variant="destructive" className="cursor-help">
Exceeds your role
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-80 space-y-1.5">
<p className="text-xs">
{entry.name} ({PERMISSION_MODE_LABEL[mode]}) requires the{' '}
{TOKEN_ROLE_LABEL[access.requiredRole]} role or above
{entry.level === 'organization' && ' at the organization level'}. Requests will be denied
on:
</p>
<ul className="text-xs text-foreground-light space-y-0.5">
{failingResources.slice(0, MAX_LISTED_RESOURCES).map((resource) => (
<li key={resource.id}>{failingResourceLine(resource)}</li>
))}
{overflowCount > 0 && <li>and {overflowCount} more</li>}
</ul>
</TooltipContent>
</Tooltip>
)
}

View File

@@ -8,6 +8,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants'
import { countConfigured, PermissionMode } from '../../AccessToken.permissions'
import { useTokenAccessEvaluation } from '../../hooks/useTokenAccessEvaluation'
import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils'
import { NewScopedTokenFormReview } from './NewScopedTokenFormReview'
import { PermissionsAccordion } from './PermissionsAccordion'
@@ -49,12 +50,26 @@ export const NewScopedTokenForm = ({
const resourceSectionRef = useRef<HTMLDivElement>(null)
const resourceAccess = useWatch({ control: form.control, name: 'resourceAccess' })
const selection = useWatch({ control: form.control, name: 'permissions' })
const organizationSlugs = useWatch({
control: form.control,
name: 'organizationSlugs',
defaultValue: [],
})
const projectRefs = useWatch({ control: form.control, name: 'projectRefs', defaultValue: [] })
const configuredCount = useWatch({
control: form.control,
name: 'permissions',
compute: (selection) => countConfigured(selection),
})
const access = useTokenAccessEvaluation({
selection,
resourceAccess,
organizationSlugs,
projectRefs,
enabled: resourceAccess !== 'account',
})
const { data: permissionScopeMap, isError } = useGetEnabledEndpointsForCapability()
useEffect(() => {
@@ -116,6 +131,7 @@ export const NewScopedTokenForm = ({
Only need a token for specific projects or organizations?{' '}
<button
type="button"
tabIndex={0}
className={InlineLinkClassName}
onClick={() =>
form.setValue('resourceAccess', 'project', { shouldValidate: true })
@@ -139,6 +155,7 @@ export const NewScopedTokenForm = ({
selection={selection}
onChange={handlePermissionChange}
permissionScopeMap={permissionScopeMap}
access={access}
/>
{showMissingPermissionsWarning && (
<div className="space-y-3 px-5 sm:px-6 pb-6">
@@ -159,6 +176,7 @@ export const NewScopedTokenForm = ({
) : (
<NewScopedTokenFormReview
values={formValues}
access={access}
permissionScopeMap={permissionScopeMap}
onSelectLegacyToken={() => {
handleSelectLegacyMode()
@@ -173,11 +191,16 @@ export const NewScopedTokenForm = ({
) : (
<StepIndicator step={step === 'form' ? 1 : 2} total={2} label="Configure" />
)}
<div className="flex gap-2">
<div className="flex items-center gap-3">
{step === 'review' && (
<Button variant="default" disabled={isPending} onClick={() => setStep('form')}>
Back
</Button>
<>
<span className="text-xs text-foreground-lighter">
Access can't be changed after creation
</span>
<Button variant="default" disabled={isPending} onClick={() => setStep('form')}>
Back
</Button>
</>
)}
<SheetClose asChild disabled={isPending}>
<Button variant="default">Cancel</Button>

View File

@@ -1,72 +1,67 @@
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { Badge, cn } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { MCP_UNSUPPORTED_DESCRIPTION, MCP_UNSUPPORTED_TITLE } from '../../AccessToken.constants'
import {
computeOverallRisk,
PERMISSION_CATALOG_BY_CATEGORY,
PERMISSION_MODE_LABEL,
selectionToScopes,
type OverallRisk,
type PermissionCatalogEntry,
type PermissionMode,
type RiskLevel,
} from '../../AccessToken.permissions'
import {
groupFailingResources,
TOKEN_ROLE_LABEL,
type TokenAccessEvaluation,
} from '../../AccessToken.roles'
import { useCapabilitySummary } from '../../hooks/useCapabilitySummary'
import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData'
import { failingResourceLine } from '../ExceedsRoleBadge'
import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from '../TokenSummaryRows'
import { EXPIRY_OPTIONS, type TokenFormValues } from './NewScopedTokenForm.utils'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
interface ReviewStepProps {
values: TokenFormValues
access: TokenAccessEvaluation
permissionScopeMap: PermissionScopeMap | undefined
/** Switches the form back to step one in legacy (account-wide) token mode. */
onSelectLegacyToken: () => void
}
const RISK_TONE_VARIANT: Record<
OverallRisk['tone'],
'default' | 'success' | 'warning' | 'destructive'
> = {
default: 'default',
low: 'success',
medium: 'warning',
high: 'destructive',
}
const modeLabel = (mode: PermissionMode) =>
mode === 'readwrite' ? 'Read-write' : mode === 'read' ? 'Read' : 'None'
const RISK_DOT_CLASS: Record<RiskLevel, string> = {
low: 'bg-brand-600',
medium: 'bg-warning-600',
high: 'bg-destructive-600',
}
export const NewScopedTokenFormReview = ({
values,
access,
permissionScopeMap,
onSelectLegacyToken,
}: ReviewStepProps) => {
const { organizations, projects } = useOrgAndProjectData()
const selection = values.permissions
const grantedScopes = useMemo(() => selectionToScopes(selection), [selection])
const hasExceedingCapabilities = access.exceedingEntryKeys.length > 0
// Exceeded permissions grouped by the resource where they fail, so the admonition reads per
// org/project rather than as one flat permission list.
const exceedingByResource = useMemo(
() => groupFailingResources(access, selection),
[access, selection]
)
const risk = useMemo(
() => computeOverallRisk(selection, values.resourceAccess),
[selection, values.resourceAccess]
() => computeOverallRisk(access.effectiveSelection, values.resourceAccess),
[access.effectiveSelection, values.resourceAccess]
)
const resourceSummary = useMemo(() => {
if (values.resourceAccess === 'project') {
const selectedProjects = projects.filter((p) => values.projectRefs.includes(p.ref))
return {
title: 'Project',
items: selectedProjects.length > 0 ? selectedProjects.map((p) => p.name) : ['-'],
title: 'Projects',
items:
selectedProjects.length > 0
? selectedProjects.map((p) => ({ key: p.ref, label: p.name, sublabel: p.ref }))
: [{ key: 'none', label: '-', sublabel: undefined }],
}
}
if (values.resourceAccess === 'organization') {
@@ -74,11 +69,17 @@ export const NewScopedTokenFormReview = ({
values.organizationSlugs.includes(o.slug)
)
return {
title: 'Organization',
items: selectedOrganizations.length > 0 ? selectedOrganizations.map((o) => o.name) : ['-'],
title: 'Organizations',
items:
selectedOrganizations.length > 0
? selectedOrganizations.map((o) => ({ key: o.slug, label: o.name, sublabel: o.slug }))
: [{ key: 'none', label: '-', sublabel: undefined }],
}
}
return { title: 'Account', items: ['Account-level access'] }
return {
title: 'Account',
items: [{ key: 'account', label: 'Account-level access', sublabel: undefined }],
}
}, [values, projects, organizations])
const expiresSummary = useMemo(() => {
@@ -90,43 +91,13 @@ export const NewScopedTokenFormReview = ({
return EXPIRY_OPTIONS.find((o) => o.value === values.expiresAt)?.label ?? values.expiresAt
}, [values])
const activeByCategory = useMemo(
() =>
PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
...category,
entries: category.entries
.map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
.filter(({ mode }) => mode !== 'none'),
})).filter((category) => category.entries.length > 0),
[selection]
)
const hasCapabilities = grantedScopes.length > 0
const mcpTools = useMemo(
() => getEnabledMcpTools({ grantedScopes, permissionScopeMap }),
[grantedScopes, permissionScopeMap]
)
const capabilityGroups = useMemo(() => {
const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
[]
for (const category of activeByCategory) {
for (const { entry, mode } of category.entries) {
const capabilityScopes =
mode === 'readwrite' ? [...entry.readScopes, ...entry.writeScopes] : entry.readScopes
const endpoints = getEnabledEndpointsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
if (endpoints.length > 0) {
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
}
}
}
return groups
}, [activeByCategory, grantedScopes, permissionScopeMap])
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
})
const rows: [string, React.ReactNode][] = [
['Name', values.tokenName || <span className="text-foreground-lighter">Untitled token</span>],
@@ -139,9 +110,7 @@ export const NewScopedTokenFormReview = ({
</p>
<div className="divide-y">
{resourceSummary.items.map((item) => (
<p key={item} className="py-2 text-sm text-foreground">
{item}
</p>
<ResourceSummaryItem key={item.key} label={item.label} sublabel={item.sublabel} />
))}
</div>
</div>,
@@ -149,68 +118,53 @@ export const NewScopedTokenFormReview = ({
[
'Capabilities',
hasCapabilities ? (
<div className="space-y-4">
{activeByCategory.map((category) => (
<div key={category.key} className="space-y-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
{category.name}
</p>
<div className="divide-y">
{category.entries.map(({ entry, mode }) => (
<div
key={entry.key}
className="flex items-center justify-between gap-2 text-sm py-2"
>
<span className="flex items-center gap-2">
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
RISK_DOT_CLASS[entry.risk]
)}
/>
<span className="text-foreground text-wrap">{entry.name}</span>
</span>
<span className="text-foreground-lighter text-xs font-mono uppercase font-normal text-right">
{modeLabel(mode)}
</span>
</div>
))}
</div>
</div>
))}
</div>
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
) : (
<span className="text-foreground-lighter">No capabilities selected</span>
),
],
[
'Risk level',
<span key="risk" className="flex flex-wrap items-center gap-2">
<span className="flex">
<Badge variant={RISK_TONE_VARIANT[risk.tone]}>{risk.level} Risk</Badge>
</span>
<span className="text-sm text-foreground leading-px">
{risk.text.replace(`${risk.level}`, '')}
</span>
</span>,
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
],
]
return (
<div className="space-y-6 px-5 sm:px-6 py-6">
{hasCapabilities ? (
<Admonition
type="warning"
title="Token access can't be updated after creation"
description="To change its access, delete this token and create a new one."
/>
) : (
{!hasCapabilities && (
<Admonition
type="warning"
title="This token has no capabilities"
description="Go back and grant at least one permission before creating it."
/>
)}
{hasExceedingCapabilities && (
<Admonition
type="warning"
title="Some permissions exceed your current role for the selected resources"
description={
<div className="space-y-2">
<p>
A token only works with permissions you currently hold. Requests with these
permissions will be denied until your role includes them:
</p>
{exceedingByResource.map((group) => (
<div key={`${group.type}:${group.resource.id}`}>
<p className="font-medium">{failingResourceLine(group.resource)}</p>
<ul className="list-disc pl-4">
{group.entries.map((groupEntry) => (
<li key={groupEntry.key}>
{groupEntry.name} ({PERMISSION_MODE_LABEL[groupEntry.mode]}) requires{' '}
{TOKEN_ROLE_LABEL[groupEntry.requiredRole]}
</li>
))}
</ul>
</div>
))}
</div>
}
/>
)}
<div className="flex flex-col gap-3">
<h3 className="text-sm">Token summary</h3>
<dl className="divide-y rounded-md border bg-surface-300">
@@ -237,7 +191,7 @@ export const NewScopedTokenFormReview = ({
<div className="flex items-center justify-between border-b bg-surface-100 px-3 py-2">
<span className="text-xs text-foreground">{entry.name}</span>
<span className="text-[11px] font-mono uppercase text-foreground-lighter">
{mode === 'readwrite' ? 'Read-write' : 'Read'}
{PERMISSION_MODE_LABEL[mode]}
</span>
</div>
<div className="divide-y">
@@ -266,6 +220,7 @@ export const NewScopedTokenFormReview = ({
{MCP_UNSUPPORTED_DESCRIPTION} If you need MCP server access now,{' '}
<button
type="button"
tabIndex={0}
className={InlineLinkClassName}
onClick={onSelectLegacyToken}
>

View File

@@ -1,6 +1,8 @@
import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui'
import type { PermissionCatalogEntry, PermissionMode } from '../../AccessToken.permissions'
import type { EntryAccess } from '../../AccessToken.roles'
import { ExceedsRoleBadge } from '../ExceedsRoleBadge'
import { RiskMarker } from './RiskMarker'
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
@@ -9,6 +11,7 @@ interface PermissionRowProps {
mode: PermissionMode
onChange: (mode: PermissionMode) => void
permissionScopeMap: PermissionScopeMap | undefined
entryAccess?: EntryAccess
}
export const PermissionRow = ({
@@ -16,6 +19,7 @@ export const PermissionRow = ({
mode,
onChange,
permissionScopeMap,
entryAccess,
}: PermissionRowProps) => {
return (
<div className="flex items-center justify-between gap-4 py-4">
@@ -27,6 +31,9 @@ export const PermissionRow = ({
</span>
</Label>
<RiskMarker entry={entry} permissionScopeMap={permissionScopeMap} />
{entryAccess?.status === 'exceeds-role' && (
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
)}
</span>
<p id={`${entry.key}-permissions-description`} className="text-xs text-foreground-lighter">
{entry.description}

View File

@@ -7,19 +7,24 @@ import {
type PermissionMode,
type PermissionSelection,
} from '../../AccessToken.permissions'
import type { TokenAccessEvaluation } from '../../AccessToken.roles'
import { PermissionRow } from './PermissionRow'
import { InlineLink } from '@/components/ui/InlineLink'
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { DOCS_URL } from '@/lib/constants'
interface PermissionsAccordionProps {
selection: PermissionSelection
onChange: (key: string, mode: PermissionMode) => void
permissionScopeMap: PermissionScopeMap | undefined
access?: TokenAccessEvaluation
}
export const PermissionsAccordion = ({
selection,
onChange,
permissionScopeMap,
access,
}: PermissionsAccordionProps) => {
const [openCategories, setOpenCategories] = useState<string[]>([])
@@ -28,7 +33,12 @@ export const PermissionsAccordion = ({
<div>
<h3 className="text-sm text-foreground">Permissions</h3>
<p className="text-foreground-lighter text-sm">
Grant the minimum access this token needs. Everything defaults to None.
Grant the minimum access this token needs. Everything defaults to None. Permissions follow
your role in the organizations and projects you are a member of see{' '}
<InlineLink href={`${DOCS_URL}/guides/platform/access-control`}>
access control
</InlineLink>{' '}
for how roles work.
</p>
</div>
@@ -72,6 +82,7 @@ export const PermissionsAccordion = ({
mode={selection[entry.key] ?? 'none'}
onChange={(mode) => onChange(entry.key, mode)}
permissionScopeMap={permissionScopeMap}
entryAccess={access?.entries[entry.key]}
/>
</div>
))}

View File

@@ -0,0 +1,70 @@
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
MOCK_PROJECT,
mockPermissionsApi,
mockScopedTokenEnvironment,
readonlyRows,
} from '../../AccessToken.fixtures'
import { NewScopedTokenSheet } from '../NewScopedTokenSheet'
import { customRender } from '@/tests/lib/custom-render'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
// Disabling orgs for project-scoped members reads /platform/profile/permissions, which only
// fires on the platform for a logged-in user — neither is true in the default test environment.
vi.mock('common', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('common')
return { ...actual, useIsLoggedIn: () => true }
})
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, IS_PLATFORM: true }
})
const user = userEvent.setup()
describe('ResourceAccessStep organization selector', () => {
beforeEach(() => {
mockScopedTokenEnvironment()
})
const openOrganizationSelector = async () => {
customRender(<NewScopedTokenSheet onCreateExperimentalToken={() => {}} />, {
profileContext: createMockProfileContext(),
})
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
await user.click(await screen.findByRole('radio', { name: /Organization/ }))
fireEvent.click(await screen.findByRole('combobox', { name: 'Organizations' }))
}
test('disables organizations where the user only has project-level access', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug, [MOCK_PROJECT.ref]))
await openOrganizationSelector()
const option = await screen.findByRole('option', { name: new RegExp(MOCK_ORG.name) })
expect(option).toHaveAttribute('aria-disabled', 'true')
expect(
await screen.findByText(
'Your access is limited to specific projects. Create a project-scoped token instead.'
)
).toBeInTheDocument()
})
test('keeps organizations selectable for members with org-wide access', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
await openOrganizationSelector()
const option = await screen.findByRole('option', { name: new RegExp(MOCK_ORG.name) })
expect(option).not.toHaveAttribute('aria-disabled', 'true')
expect(
screen.queryByText(
'Your access is limited to specific projects. Create a project-scoped token instead.'
)
).toBeNull()
})
})

View File

@@ -24,9 +24,11 @@ import {
} from 'ui-patterns/multi-select'
import type { ResourceAccessMode } from '../../AccessToken.permissions'
import { getIsProjectScopedOnly } from '../../AccessToken.roles'
import { useOrgAndProjectData } from '../../hooks/useOrgAndProjectData'
import type { TokenFormValues } from './NewScopedTokenForm.utils'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
import { ProjectInfoInfinite } from '@/data/projects/projects-infinite-query'
import { Organization } from '@/types'
@@ -89,6 +91,20 @@ export const ResourceAccessStep = ({
const resourceAccess = useWatch({ control, name: 'resourceAccess' })
const organizationSlugs = useWatch({ control, name: 'organizationSlugs', defaultValue: [] })
// Users invited to specific projects (rather than the whole org) can't select that org for an
// org-wide token. Skipped while permissions are still loading so nothing gets disabled by
// mistake. The project list itself needs no permission filter — /platform/projects is already
// scoped server-side to what the user can access.
const { data: permissions } = usePermissionsQuery()
const projectScopedOrgSlugs = useMemo(() => {
if (permissions === undefined) return new Set<string>()
return new Set(
organizations
.map((org) => org.slug)
.filter((slug) => getIsProjectScopedOnly(permissions, slug))
)
}, [permissions, organizations])
const projectsForOrg = useMemo(
() => projects.filter((project) => organizationSlugs.includes(project.organization_slug)),
[projects, organizationSlugs]
@@ -106,7 +122,12 @@ export const ResourceAccessStep = ({
description={
<p className="text-foreground-lighter text-sm">
Need a token with full access to your account or one for the Supabase MCP server?{' '}
<button type="button" className={InlineLinkClassName} onClick={onSelectLegacyToken}>
<button
type="button"
tabIndex={0}
className={InlineLinkClassName}
onClick={onSelectLegacyToken}
>
Create legacy token
</button>
</p>
@@ -193,7 +214,7 @@ export const ResourceAccessStep = ({
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
disabled={!organizationSlugs}
disabled={organizationSlugs.length === 0}
className="w-full"
>
<MultiSelectorTrigger
@@ -252,11 +273,26 @@ export const ResourceAccessStep = ({
<MultiSelectorContent>
<MultiSelectorInput placeholder="Search organizations" showResetIcon />
<MultiSelectorList>
{organizations.map((organization) => (
<MultiSelectorItem key={organization.slug} value={organization.slug}>
{organization.name}
</MultiSelectorItem>
))}
{organizations.map((organization) => {
const isProjectScopedOnly = projectScopedOrgSlugs.has(organization.slug)
return (
<MultiSelectorItem
key={organization.slug}
value={organization.slug}
disabled={isProjectScopedOnly}
>
<span className="flex flex-col gap-0.5">
<span>{organization.name}</span>
{isProjectScopedOnly && (
<span className="text-foreground-lighter">
Your access is limited to specific projects. Create a project-scoped
token instead.
</span>
)}
</span>
</MultiSelectorItem>
)
})}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>

View File

@@ -1,21 +1,15 @@
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import {
RISK_BADGE_VARIANT,
RISK_LEVEL_LABEL,
type PermissionCatalogEntry,
type RiskLevel,
} from '../../AccessToken.permissions'
import {
getMcpToolsForScopes,
PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
const RISK_VARIANT: Record<RiskLevel, 'success' | 'warning' | 'destructive'> = {
low: 'success',
medium: 'warning',
high: 'destructive',
}
interface RiskMarkerProps {
entry: PermissionCatalogEntry
/** When false, renders the dot + label without the explanatory tooltip (used in the review list). */
@@ -32,7 +26,7 @@ export const RiskMarker = ({
}: RiskMarkerProps) => {
const marker = (
<Badge
variant={RISK_VARIANT[entry.risk]}
variant={RISK_BADGE_VARIANT[entry.risk]}
className={cn(withTooltip && 'cursor-help', className)}
>
{RISK_LEVEL_LABEL[entry.risk]}
@@ -52,7 +46,7 @@ export const RiskMarker = ({
<span tabIndex={0}>{marker}</span>
</TooltipTrigger>
<TooltipContent side="top" align="center" className="w-72 space-y-2 p-3">
<Badge variant={RISK_VARIANT[entry.risk]}>{RISK_LEVEL_LABEL[entry.risk]}</Badge>
<Badge variant={RISK_BADGE_VARIANT[entry.risk]}>{RISK_LEVEL_LABEL[entry.risk]}</Badge>
<p className="text-xs text-foreground-light">{entry.riskReason}</p>
{(entry.allowsRead.length > 0 || entry.allowsWrite.length > 0) && (
<div className="flex flex-col gap-5 mt-5">

View File

@@ -4,14 +4,12 @@ import { platformComponents as components } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test } from 'vitest'
import { mockScopedTokenEnvironment } from '../AccessToken.fixtures'
import { NewScopedTokenSheet } from './NewScopedTokenSheet'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganizationResponse, createMockProject } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
type OrganizationResponse = components['schemas']['OrganizationResponse']
type ProjectsResponse = components['schemas']['ListProjectsPaginatedResponse']
type CreateTokenResponse = components['schemas']['CreateScopedAccessTokenResponse']
type CreateClassicTokenResponse = components['schemas']['CreateAccessTokenResponse']
@@ -19,72 +17,6 @@ const user = userEvent.setup({
writeToClipboard: true,
})
const PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: 'test@example.com',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
const mockOrganizations = () =>
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: () =>
HttpResponse.json<OrganizationResponse[]>([
createMockOrganizationResponse({ slug: 'acme-prod', name: 'Acme Production' }),
]),
})
const mockProjects = () =>
addAPIMock({
method: 'get',
path: '/platform/projects',
response: () =>
HttpResponse.json<ProjectsResponse>({
pagination: { count: 1, limit: 100, offset: 0 },
projects: [
{
...createMockProject({
id: 1,
ref: 'project-1',
name: 'Project 1',
organization_id: 1,
}),
organization_slug: 'acme-prod',
preview_branch_refs: [],
},
],
}),
})
const mockPermissionsMap = () =>
addAPIMock({
method: 'get',
// @ts-expect-error Studio API is missing from types
path: '/scoped-access-token-permissions',
response: () =>
HttpResponse.json({
scopes: {},
endpoints: {},
mcp_tools: {},
}),
})
const mockCreateToken = () =>
addAPIMock({
method: 'post',
@@ -121,13 +53,11 @@ const mockCreateClassicToken = () =>
describe('NewScopedTokenSheet', () => {
const renderSheet = () =>
customRender(<NewScopedTokenSheet onCreateExperimentalToken={() => {}} />, {
profileContext: PROFILE_CONTEXT,
profileContext: createMockProfileContext(),
})
beforeEach(() => {
mockPermissionsMap()
mockOrganizations()
mockProjects()
mockScopedTokenEnvironment()
mockCreateToken()
mockCreateClassicToken()
})

View File

@@ -0,0 +1,113 @@
import { Badge, cn } from 'ui'
import {
PERMISSION_MODE_LABEL,
RISK_DOT_CLASS,
RISK_TONE_VARIANT,
type OverallRisk,
type PermissionCatalogEntry,
type PermissionMode,
} from '../AccessToken.permissions'
import type { EntryAccess } from '../AccessToken.roles'
import { ExceedsRoleBadge } from './ExceedsRoleBadge'
/**
* Presentational pieces of the token summary shared by the review step
* (NewScopedTokenFormReview) and the token view sheet (ViewTokenSheet), so the two surfaces
* can't drift apart.
*/
interface CapabilityCategoryListProps {
categories: {
key: string
name: string
entries: { entry: PermissionCatalogEntry; mode: PermissionMode }[]
}[]
/** Per-entry access evaluation; entries flagged 'exceeds-role' get the warning pill. */
accessEntries: Record<string, EntryAccess>
}
export const CapabilityCategoryList = ({
categories,
accessEntries,
}: CapabilityCategoryListProps) => (
<div className="space-y-4">
{categories.map((category) => (
<div key={category.key} className="space-y-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
{category.name}
</p>
<div className="divide-y">
{category.entries.map(({ entry, mode }) => {
const entryAccess = accessEntries[entry.key]
return (
<div key={entry.key} className="flex items-center justify-between gap-2 text-sm py-2">
<span className="flex flex-wrap items-center gap-2">
<span
className={cn('h-1.5 w-1.5 shrink-0 rounded-full', RISK_DOT_CLASS[entry.risk])}
/>
<span className="text-foreground text-wrap">{entry.name}</span>
{entryAccess?.status === 'exceeds-role' && (
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
)}
</span>
<span className="text-foreground-lighter text-xs font-mono uppercase font-normal text-right">
{PERMISSION_MODE_LABEL[mode]}
</span>
</div>
)
})}
</div>
</div>
))}
</div>
)
interface RiskLevelSummaryProps {
risk: OverallRisk
/** True when some selected permissions exceed the owner's role, so the risk is role-capped. */
showRoleCaveat: boolean
}
export const RiskLevelSummary = ({ risk, showRoleCaveat }: RiskLevelSummaryProps) => (
<div className="space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="flex">
<Badge variant={RISK_TONE_VARIANT[risk.tone]}>{risk.level} Risk</Badge>
</span>
<span className="text-sm text-foreground leading-px">{risk.description}</span>
</span>
{showRoleCaveat && (
<p className="text-xs text-foreground-lighter">
Based on what your current role allows this token to do.
</p>
)}
</div>
)
interface ResourceSummaryItemProps {
label: string
/** Mono-rendered identifier under the name — the org slug or project ref. */
sublabel?: string
isInaccessible?: boolean
}
export const ResourceSummaryItem = ({
label,
sublabel,
isInaccessible = false,
}: ResourceSummaryItemProps) => (
<div className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="flex flex-col gap-0.5">
<span
className={cn('text-sm', isInaccessible ? 'text-foreground-lighter' : 'text-foreground')}
>
{label}
</span>
{sublabel !== undefined && (
<span className="font-mono text-xs text-foreground-lighter">{sublabel}</span>
)}
</span>
{isInaccessible && <Badge variant="destructive">No longer accessible</Badge>}
</div>
)

View File

@@ -0,0 +1,143 @@
import { screen } from '@testing-library/react'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
MOCK_ORG,
mockPermissionsApi,
mockScopedTokenEnvironment,
ownerRows,
readonlyRows,
} from '../AccessToken.fixtures'
import { ViewTokenSheet } from './ViewTokenSheet'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { createMockProfileContext } from '@/tests/lib/profile-helpers'
type TokenResponse = components['schemas']['GetScopedAccessTokenResponse']
mockAnimationsApi()
// The role evaluation reads /platform/profile/permissions, which only fires on the platform for a
// logged-in user — neither is true in the default test environment.
vi.mock('common', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('common')
return { ...actual, useIsLoggedIn: () => true }
})
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, IS_PLATFORM: true }
})
const TOKEN_BASE = {
created_at: '2026-08-01T00:00:00.000Z',
expires_at: null,
id: 'token-1',
last_used_at: null,
name: 'CI token',
token_alias: 'sbp_test123',
} satisfies Partial<TokenResponse>
const mockToken = (token: TokenResponse) =>
addAPIMock({
method: 'get',
path: '/platform/profile/scoped-access-tokens/:id',
response: () => HttpResponse.json<TokenResponse>(token),
})
describe('ViewTokenSheet', () => {
beforeEach(() => {
mockScopedTokenEnvironment()
})
const renderSheet = () =>
customRender(<ViewTokenSheet visible tokenId="token-1" onClose={() => {}} />, {
profileContext: createMockProfileContext(),
})
test('shows no access warnings when the role covers every permission', async () => {
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'organization',
organization_slugs: [MOCK_ORG.slug],
permissions: ['database_read', 'database_write'],
})
renderSheet()
// Bound org resolves with its name and slug, meaning evaluation completed without warnings.
expect(await screen.findByText(MOCK_ORG.name)).toBeInTheDocument()
expect(screen.getByText(MOCK_ORG.slug)).toBeInTheDocument()
expect(screen.queryByText('Exceeds your role')).toBeNull()
expect(
screen.queryByText('Some permissions exceed your current role for the selected resources')
).toBeNull()
expect(screen.queryByText('This token no longer has access')).toBeNull()
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
})
test('marks permissions above the current role without blocking the rest', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'organization',
organization_slugs: [MOCK_ORG.slug],
// database_write requires Developer; the owner of this token is Read-only.
permissions: ['database_read', 'database_write'],
})
renderSheet()
expect(
await screen.findByText(
'Some permissions exceed your current role for the selected resources'
)
).toBeInTheDocument()
expect(await screen.findByText('Exceeds your role')).toBeInTheDocument()
// Advisory only — the other (destructive) states must not fire.
expect(screen.queryByText('This token no longer has access')).toBeNull()
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
})
test('reports lost access when the user was removed from every bound resource', async () => {
mockPermissionsApi(readonlyRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'organization',
// Bound to an org the user can no longer see.
organization_slugs: ['departed-org'],
permissions: ['members_read'],
})
renderSheet()
expect(await screen.findByText('This token no longer has access')).toBeInTheDocument()
expect(
await screen.findByText(/You were removed from the organizations this token is bound to/)
).toBeInTheDocument()
// The lost resource renders as an anonymous count, never its slug.
expect(await screen.findByText('1 organization')).toBeInTheDocument()
expect(await screen.findByText('No longer accessible')).toBeInTheDocument()
expect(screen.queryByText('departed-org')).toBeNull()
expect(screen.queryByText("This token's resources no longer exist")).toBeNull()
})
test('reports deleted resources when a token has no bindings left', async () => {
mockPermissionsApi(ownerRows(MOCK_ORG.slug))
mockToken({
...TOKEN_BASE,
scope: 'project',
// Deleting a project erases the token's binding to it.
project_refs: [],
permissions: ['database_read'],
})
renderSheet()
expect(await screen.findByText("This token's resources no longer exist")).toBeInTheDocument()
expect(
(await screen.findAllByText(/Every project this token was bound to has been deleted/)).length
).toBeGreaterThan(0)
expect(screen.queryByText('This token no longer has access')).toBeNull()
})
})

View File

@@ -1,29 +1,29 @@
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { Badge, cn, ScrollArea, Sheet, SheetContent, SheetHeader } from 'ui'
import { cn, ScrollArea, Sheet, SheetContent, SheetHeader } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { MCP_UNSUPPORTED_DESCRIPTION, MCP_UNSUPPORTED_TITLE } from '../AccessToken.constants'
import {
MCP_UNSUPPORTED_DESCRIPTION,
MCP_UNSUPPORTED_TITLE,
TOKEN_DENIED_REMEDIATION,
} from '../AccessToken.constants'
import {
computeOverallRisk,
PERMISSION_CATALOG_BY_CATEGORY,
PERMISSION_MODE_LABEL,
scopesToSelection,
type OverallRisk,
type PermissionCatalogEntry,
type PermissionMode,
type ResourceAccessMode,
type RiskLevel,
} from '../AccessToken.permissions'
import { useCapabilitySummary } from '../hooks/useCapabilitySummary'
import { useOrgAndProjectData } from '../hooks/useOrgAndProjectData'
import { useTokenAccessEvaluation } from '../hooks/useTokenAccessEvaluation'
import { CapabilityCategoryList, ResourceSummaryItem, RiskLevelSummary } from './TokenSummaryRows'
import { DocsButton } from '@/components/ui/DocsButton'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
useGetEnabledEndpointsForCapability,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query'
import { DOCS_URL } from '@/lib/constants'
import { pluralize } from '@/lib/helpers'
interface ViewTokenSheetProps {
visible: boolean
@@ -31,33 +31,15 @@ interface ViewTokenSheetProps {
onClose: () => void
}
const RISK_TONE_VARIANT: Record<
OverallRisk['tone'],
'default' | 'success' | 'warning' | 'destructive'
> = {
default: 'default',
low: 'success',
medium: 'warning',
high: 'destructive',
}
const RISK_DOT_CLASS: Record<RiskLevel, string> = {
low: 'bg-brand-600',
medium: 'bg-warning-600',
high: 'bg-destructive-600',
}
const modeLabel = (mode: PermissionMode) =>
mode === 'readwrite' ? 'Read-write' : mode === 'read' ? 'Read' : 'None'
const SCOPE_TO_RESOURCE_ACCESS: Record<'user' | 'organization' | 'project', ResourceAccessMode> = {
user: 'account',
organization: 'organization',
project: 'project',
}
const EMPTY_BINDINGS: string[] = []
export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProps) {
const { organizations, projects } = useOrgAndProjectData()
const { data: permissionScopeMap } = useGetEnabledEndpointsForCapability()
const {
@@ -73,72 +55,114 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
}
)
// The sheet stays mounted (hidden) on the tokens page; don't fetch — and above all don't drain
// the full paginated project list — until it's actually opened on a token.
const { organizations, projects } = useOrgAndProjectData({ enabled: visible && !!token })
const resourceAccess = token ? SCOPE_TO_RESOURCE_ACCESS[token.scope] : 'project'
const grantedScopes = useMemo(() => token?.permissions ?? [], [token?.permissions])
const selection = useMemo(() => scopesToSelection(grantedScopes), [grantedScopes])
const tokenOrganizationSlugs = token?.organization_slugs ?? EMPTY_BINDINGS
const tokenProjectRefs = token?.project_refs ?? EMPTY_BINDINGS
const access = useTokenAccessEvaluation({
selection,
resourceAccess,
organizationSlugs: tokenOrganizationSlugs,
projectRefs: tokenProjectRefs,
enabled: visible && !!token,
})
const hasExceedingCapabilities = access.exceedingEntryKeys.length > 0
// Deleting a project/org erases the token's binding to it, so a resource-scoped token with no
// bindings left means everything it was bound to has been deleted.
const hasNoBoundResources = token !== undefined && access.hasNoBoundResources
const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project'
// Deleted bindings are erased from the token, so the original count is unknowable — the
// phrasing has to work for any number of resources.
const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted`
const risk = useMemo(
() => computeOverallRisk(selection, resourceAccess),
[selection, resourceAccess]
() => computeOverallRisk(access.effectiveSelection, resourceAccess),
[access.effectiveSelection, resourceAccess]
)
const activeByCategory = useMemo(
() =>
PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
...category,
entries: category.entries
.map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
.filter(({ mode }) => mode !== 'none'),
})).filter((category) => category.entries.length > 0),
[selection]
)
const hasCapabilities = grantedScopes.length > 0
const mcpTools = useMemo(
() => getEnabledMcpTools({ grantedScopes, permissionScopeMap }),
[grantedScopes, permissionScopeMap]
)
const capabilityGroups = useMemo(() => {
const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
[]
for (const category of activeByCategory) {
for (const { entry, mode } of category.entries) {
const capabilityScopes =
mode === 'readwrite' ? [...entry.readScopes, ...entry.writeScopes] : entry.readScopes
const endpoints = getEnabledEndpointsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
if (endpoints.length > 0) {
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
}
}
}
return groups
}, [activeByCategory, grantedScopes, permissionScopeMap])
const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({
selection,
grantedScopes,
permissionScopeMap,
})
// Accessible resources render with their name and ref/slug. Resources the user has lost access
// to are aggregated into an anonymous count — their identifiers aren't shown.
const resourceSummary = useMemo(() => {
const inaccessibleCountItem = (lostCount: number, noun: string) =>
lostCount === 0
? []
: [
{
key: 'inaccessible',
label: `${lostCount} ${pluralize(lostCount, noun)}`,
sublabel: undefined,
isInaccessible: true,
},
]
if (resourceAccess === 'project') {
const selectedProjects = projects.filter((p) => (token?.project_refs ?? []).includes(p.ref))
const projectsByRef = new Map(projects.map((project) => [project.ref, project]))
const accessible = tokenProjectRefs.flatMap((ref) => {
const name = projectsByRef.get(ref)?.name
if (name === undefined) return []
return [{ key: ref, label: name, sublabel: ref, isInaccessible: false }]
})
return {
title: 'Project',
items: selectedProjects.length > 0 ? selectedProjects.map((p) => p.name) : ['-'],
title: 'Projects',
items: [
...accessible,
...inaccessibleCountItem(access.inaccessibleProjectRefs.length, 'project'),
],
}
}
if (resourceAccess === 'organization') {
const selectedOrganizations = organizations.filter((o) =>
(token?.organization_slugs ?? []).includes(o.slug)
)
const organizationsBySlug = new Map(organizations.map((org) => [org.slug, org]))
const accessible = tokenOrganizationSlugs.flatMap((slug) => {
const name = organizationsBySlug.get(slug)?.name
if (name === undefined) return []
return [{ key: slug, label: name, sublabel: slug, isInaccessible: false }]
})
return {
title: 'Organization',
items: selectedOrganizations.length > 0 ? selectedOrganizations.map((o) => o.name) : ['-'],
title: 'Organizations',
items: [
...accessible,
...inaccessibleCountItem(access.inaccessibleOrgSlugs.length, 'organization'),
],
}
}
return { title: 'Account', items: ['Account-level access'] }
}, [resourceAccess, token, projects, organizations])
return {
title: 'Account',
items: [
{
key: 'account',
label: 'Account-level access',
sublabel: undefined,
isInaccessible: false,
},
],
}
}, [
resourceAccess,
tokenProjectRefs,
tokenOrganizationSlugs,
projects,
organizations,
access.inaccessibleProjectRefs,
access.inaccessibleOrgSlugs,
])
const rows: [string, React.ReactNode][] = token
? [
@@ -185,10 +209,19 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{resourceSummary.title}
</p>
<div className="divide-y">
{resourceSummary.items.length === 0 && hasNoBoundResources && (
<p className="py-2 text-sm text-foreground-lighter">{boundResourcesDeletedText}</p>
)}
{resourceSummary.items.length === 0 && !hasNoBoundResources && (
<p className="py-2 text-sm text-foreground-lighter">-</p>
)}
{resourceSummary.items.map((item) => (
<p key={item} className="py-2 text-sm text-foreground">
{item}
</p>
<ResourceSummaryItem
key={item.key}
label={item.label}
sublabel={item.sublabel}
isInaccessible={item.isInaccessible}
/>
))}
</div>
</div>,
@@ -196,50 +229,14 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
[
'Capabilities',
hasCapabilities ? (
<div className="space-y-4">
{activeByCategory.map((category) => (
<div key={category.key} className="space-y-2">
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
{category.name}
</p>
<div className="divide-y">
{category.entries.map(({ entry, mode }) => (
<div
key={entry.key}
className="flex items-center justify-between gap-2 text-sm py-2"
>
<span className="flex items-center gap-2">
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
RISK_DOT_CLASS[entry.risk]
)}
/>
<span className="text-foreground text-wrap">{entry.name}</span>
</span>
<span className="text-foreground-lighter text-xs font-mono uppercase font-normal text-right">
{modeLabel(mode)}
</span>
</div>
))}
</div>
</div>
))}
</div>
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
) : (
<span className="text-foreground-lighter">No capabilities selected</span>
),
],
[
'Risk level',
<span key="risk" className="flex flex-wrap items-center gap-2">
<span className="flex">
<Badge variant={RISK_TONE_VARIANT[risk.tone]}>{risk.level} Risk</Badge>
</span>
<span className="text-sm text-foreground leading-px">
{risk.text.replace(`${risk.level}`, '')}
</span>
</span>,
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
],
]
: []
@@ -255,7 +252,18 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
<p className="truncate" title={`View access for ${token?.name}`}>
View access for {token?.name}
</p>
<DocsButton href={`${DOCS_URL}/reference/api/introduction`} />
<div className="flex items-center gap-2">
<DocsButton
href={`${DOCS_URL}/guides/platform/access-control`}
topic="Access control"
label="Access control docs"
/>
<DocsButton
href={`${DOCS_URL}/reference/api/introduction`}
topic="Management API"
label="API docs"
/>
</div>
</SheetHeader>
<ScrollArea className="flex-1">
<div className="space-y-6 px-5 sm:px-6 py-6">
@@ -275,6 +283,27 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{token && (
<>
{hasNoBoundResources && (
<Admonition
type="destructive"
title="This token's resources no longer exist"
description={`${boundResourcesDeletedText}. ${TOKEN_DENIED_REMEDIATION}`}
/>
)}
{access.hasNoAccessibleResource && (
<Admonition
type="destructive"
title="This token no longer has access"
description={`You were removed from the ${resourceNoun}s this token is bound to. ${TOKEN_DENIED_REMEDIATION}`}
/>
)}
{hasExceedingCapabilities && !access.hasNoAccessibleResource && (
<Admonition
type="warning"
title="Some permissions exceed your current role for the selected resources"
description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them."
/>
)}
<div className="flex flex-col gap-3">
<h3 className="text-sm">Token summary</h3>
<dl className="divide-y rounded-md border bg-surface-300">
@@ -301,7 +330,7 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
<div className="flex items-center justify-between border-b bg-surface-100 px-3 py-2">
<span className="text-xs text-foreground">{entry.name}</span>
<span className="text-[11px] font-mono uppercase text-foreground-lighter">
{mode === 'readwrite' ? 'Read-write' : 'Read'}
{PERMISSION_MODE_LABEL[mode]}
</span>
</div>
<div className="divide-y">

View File

@@ -0,0 +1,68 @@
import { useMemo } from 'react'
import {
getEntryScopes,
PERMISSION_CATALOG_BY_CATEGORY,
type PermissionCatalogEntry,
type PermissionMode,
type PermissionSelection,
} from '../AccessToken.permissions'
import {
getEnabledEndpointsForCapability,
getEnabledMcpTools,
PermissionScopeMap,
} from '@/data/scoped-access-tokens/permission-scope-map-query'
interface UseCapabilitySummaryArgs {
selection: PermissionSelection
grantedScopes: string[]
permissionScopeMap: PermissionScopeMap | undefined
}
/**
* Selection-derived summary data shared by the review step and the token view sheet: selected
* entries grouped by catalog category, the Management API endpoints each capability enables,
* and the enabled MCP tools.
*/
export const useCapabilitySummary = ({
selection,
grantedScopes,
permissionScopeMap,
}: UseCapabilitySummaryArgs) => {
const activeByCategory = useMemo(
() =>
PERMISSION_CATALOG_BY_CATEGORY.map((category) => ({
...category,
entries: category.entries
.map((entry) => ({ entry, mode: selection[entry.key] ?? 'none' }))
.filter(({ mode }) => mode !== 'none'),
})).filter((category) => category.entries.length > 0),
[selection]
)
const mcpTools = useMemo(
() => getEnabledMcpTools({ grantedScopes, permissionScopeMap }),
[grantedScopes, permissionScopeMap]
)
const capabilityGroups = useMemo(() => {
const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
[]
for (const category of activeByCategory) {
for (const { entry, mode } of category.entries) {
const capabilityScopes = getEntryScopes(entry, mode)
const endpoints = getEnabledEndpointsForCapability({
capabilityScopes,
allGrantedScopes: grantedScopes,
permissionScopeMap,
})
if (endpoints.length > 0) {
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
}
}
}
return groups
}, [activeByCategory, grantedScopes, permissionScopeMap])
return { activeByCategory, mcpTools, capabilityGroups }
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query'
@@ -10,11 +10,28 @@ interface UseOrgAndProjectDataOptions {
export const useOrgAndProjectData = (options: UseOrgAndProjectDataOptions = {}) => {
const { enabled = true } = options
const { data: organizations = [], isLoading: isLoadingOrgs } = useOrganizationsQuery({ enabled })
const {
data: organizations = [],
isLoading: isLoadingOrgs,
isError: isErrorOrgs,
} = useOrganizationsQuery({ enabled })
const { data: projectsData, isLoading: isLoadingProjects } = useProjectsInfiniteQuery({
limit: 100,
})
const {
data: projectsData,
isLoading: isLoadingFirstPage,
isError: isErrorProjects,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useProjectsInfiniteQuery({ limit: 100 }, { enabled })
// Callers evaluate token access against this list, treating any bound project missing from it
// as inaccessible — so a truncated page would raise false "no longer accessible" alarms for
// accounts with more projects than one page holds. Drain every page, and report loading until
// the list is complete so evaluations stay 'unknown' rather than wrong.
useEffect(() => {
if (enabled && hasNextPage && !isFetchingNextPage) fetchNextPage()
}, [enabled, hasNextPage, isFetchingNextPage, fetchNextPage])
const projects = useMemo(
() => projectsData?.pages.flatMap((page) => page.projects) ?? [],
@@ -25,6 +42,8 @@ export const useOrgAndProjectData = (options: UseOrgAndProjectDataOptions = {})
organizations,
projects,
isLoadingOrgs,
isLoadingProjects,
isLoadingProjects: isLoadingFirstPage || hasNextPage,
isErrorOrgs,
isErrorProjects,
}
}

View File

@@ -0,0 +1,73 @@
import { useMemo } from 'react'
import type { PermissionSelection, ResourceAccessMode } from '../AccessToken.permissions'
import {
applySelectionToRoleContext,
computeTokenRoleContext,
type TokenAccessEvaluation,
} from '../AccessToken.roles'
import { useOrgAndProjectData } from './useOrgAndProjectData'
import { usePermissionsQuery } from '@/data/permissions/permissions-query'
interface UseTokenAccessEvaluationArgs {
selection: PermissionSelection
resourceAccess: ResourceAccessMode
organizationSlugs: string[]
projectRefs: string[]
enabled?: boolean
}
/**
* Evaluates a token's scope selection and bound resources against the current user's live access.
* Advisory only — actual enforcement is the per-request intersection on the API side. While the
* underlying queries load (or on self-hosted), the evaluation reports `status: 'unknown'` and
* callers must show no warnings rather than flash false ones.
*
* Role resolution (the expensive part) is memoized separately from the selection, so toggling
* permissions in the form only re-runs the cheap selection pass.
*/
export const useTokenAccessEvaluation = ({
selection,
resourceAccess,
organizationSlugs,
projectRefs,
enabled = true,
}: UseTokenAccessEvaluationArgs): TokenAccessEvaluation => {
const { data: permissions } = usePermissionsQuery({ enabled })
const {
organizations,
projects,
isLoadingOrgs,
isLoadingProjects,
isErrorOrgs,
isErrorProjects,
} = useOrgAndProjectData({ enabled })
// Org/project lists still loading or failed: resources the user *does* have access to would
// read as inaccessible, so report unknown instead.
const hasCompleteResourceLists =
!isLoadingOrgs && !isLoadingProjects && !isErrorOrgs && !isErrorProjects
const context = useMemo(
() =>
computeTokenRoleContext({
resourceAccess,
organizationSlugs,
projectRefs,
permissions: hasCompleteResourceLists ? permissions : undefined,
organizations,
projects,
}),
[
resourceAccess,
organizationSlugs,
projectRefs,
permissions,
organizations,
projects,
hasCompleteResourceLists,
]
)
return useMemo(() => applySelectionToRoleContext(context, selection), [context, selection])
}

View File

@@ -6,9 +6,11 @@ interface DocsButtonProps {
abbrev?: boolean
className?: string
topic?: string
/** Custom button text, e.g. to distinguish multiple docs buttons side by side. */
label?: string
}
export const DocsButton = ({ href, abbrev = true, className, topic }: DocsButtonProps) => {
export const DocsButton = ({ href, abbrev = true, className, topic, label }: DocsButtonProps) => {
return (
<Button
asChild
@@ -23,7 +25,7 @@ export const DocsButton = ({ href, abbrev = true, className, topic }: DocsButton
href={href}
aria-label={topic ? `${topic} documentation (opens in new tab)` : undefined}
>
{abbrev ? 'Docs' : 'Documentation'}
{label ?? (abbrev ? 'Docs' : 'Documentation')}
</a>
</Button>
)