diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts index deae135f394..41e4218c9a7 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts @@ -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' }, diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts new file mode 100644 index 00000000000..42d93b3687e --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts @@ -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([ + createMockOrganizationResponse({ slug: MOCK_ORG.slug, name: MOCK_ORG.name }), + ]), + }) + addAPIMock({ + method: 'get', + path: '/platform/projects', + response: () => + HttpResponse.json({ + 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(rows as unknown as AccessControlPermission[]), + }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts index a2c6ab69082..c46d15b95c5 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts @@ -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 +/** 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 = { high: 'High risk', } +export const PERMISSION_MODE_LABEL: Record = { + none: 'None', + read: 'Read', + readwrite: 'Read-write', +} + +export const RISK_BADGE_VARIANT: Record = { + 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 = { + 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 } } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts new file mode 100644 index 00000000000..ab3f229e318 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.test.ts @@ -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 = { + 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 user’s 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 user’s 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') + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts new file mode 100644 index 00000000000..c4039ee8bb4 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.roles.ts @@ -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 + +export const TOKEN_ROLE_LABEL: Record = { + 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 = { + // 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 + /** 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((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() + 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 = {} + 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() + 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 + }) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx new file mode 100644 index 00000000000..21b1ae8861a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx @@ -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 ( + + + + + Exceeds your role + + + + +

+ {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: +

+
    + {failingResources.slice(0, MAX_LISTED_RESOURCES).map((resource) => ( +
  • {failingResourceLine(resource)}
  • + ))} + {overflowCount > 0 &&
  • and {overflowCount} more
  • } +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx index 58eeaeeddff..a51ad270af7 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx @@ -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(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?{' '} + <> + + Access can't be changed after creation + + + )} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx index 96a5a7aa650..4e1b5da5185 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx @@ -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 = { - 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 || Untitled token], @@ -139,9 +110,7 @@ export const NewScopedTokenFormReview = ({

{resourceSummary.items.map((item) => ( -

- {item} -

+ ))}
, @@ -149,68 +118,53 @@ export const NewScopedTokenFormReview = ({ [ 'Capabilities', hasCapabilities ? ( -
- {activeByCategory.map((category) => ( -
-

- {category.name} -

-
- {category.entries.map(({ entry, mode }) => ( -
- - - {entry.name} - - - {modeLabel(mode)} - -
- ))} -
-
- ))} -
+ ) : ( No capabilities selected ), ], [ 'Risk level', - - - {risk.level} Risk - - - {risk.text.replace(`${risk.level} — `, '')} - - , + , ], ] return (
- {hasCapabilities ? ( - - ) : ( + {!hasCapabilities && ( )} + {hasExceedingCapabilities && ( + +

+ A token only works with permissions you currently hold. Requests with these + permissions will be denied until your role includes them: +

+ {exceedingByResource.map((group) => ( +
+

{failingResourceLine(group.resource)}

+
    + {group.entries.map((groupEntry) => ( +
  • + {groupEntry.name} ({PERMISSION_MODE_LABEL[groupEntry.mode]}) — requires{' '} + {TOKEN_ROLE_LABEL[groupEntry.requiredRole]} +
  • + ))} +
+
+ ))} +
+ } + /> + )}

Token summary

@@ -237,7 +191,7 @@ export const NewScopedTokenFormReview = ({
{entry.name} - {mode === 'readwrite' ? 'Read-write' : 'Read'} + {PERMISSION_MODE_LABEL[mode]}
@@ -266,6 +220,7 @@ export const NewScopedTokenFormReview = ({ {MCP_UNSUPPORTED_DESCRIPTION} If you need MCP server access now,{' '}

@@ -193,7 +214,7 @@ export const ResourceAccessStep = ({ - {organizations.map((organization) => ( - - {organization.name} - - ))} + {organizations.map((organization) => { + const isProjectScopedOnly = projectScopedOrgSlugs.has(organization.slug) + return ( + + + {organization.name} + {isProjectScopedOnly && ( + + Your access is limited to specific projects. Create a project-scoped + token instead. + + )} + + + ) + })} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx index a9e5bdf292c..a365d2ff516 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/RiskMarker.tsx @@ -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 = { - 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 = ( {RISK_LEVEL_LABEL[entry.risk]} @@ -52,7 +46,7 @@ export const RiskMarker = ({ {marker} - {RISK_LEVEL_LABEL[entry.risk]} + {RISK_LEVEL_LABEL[entry.risk]}

{entry.riskReason}

{(entry.allowsRead.length > 0 || entry.allowsWrite.length > 0) && (
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 6049dae06c7..43d1a8a4b59 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -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([ - createMockOrganizationResponse({ slug: 'acme-prod', name: 'Acme Production' }), - ]), - }) - -const mockProjects = () => - addAPIMock({ - method: 'get', - path: '/platform/projects', - response: () => - HttpResponse.json({ - 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( {}} />, { - profileContext: PROFILE_CONTEXT, + profileContext: createMockProfileContext(), }) beforeEach(() => { - mockPermissionsMap() - mockOrganizations() - mockProjects() + mockScopedTokenEnvironment() mockCreateToken() mockCreateClassicToken() }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx new file mode 100644 index 00000000000..4205ab120b1 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx @@ -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 +} + +export const CapabilityCategoryList = ({ + categories, + accessEntries, +}: CapabilityCategoryListProps) => ( +
+ {categories.map((category) => ( +
+

+ {category.name} +

+
+ {category.entries.map(({ entry, mode }) => { + const entryAccess = accessEntries[entry.key] + return ( +
+ + + {entry.name} + {entryAccess?.status === 'exceeds-role' && ( + + )} + + + {PERMISSION_MODE_LABEL[mode]} + +
+ ) + })} +
+
+ ))} +
+) + +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) => ( +
+ + + {risk.level} Risk + + {risk.description} + + {showRoleCaveat && ( +

+ Based on what your current role allows this token to do. +

+ )} +
+) + +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) => ( +
+ + + {label} + + {sublabel !== undefined && ( + {sublabel} + )} + + {isInaccessible && No longer accessible} +
+) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx new file mode 100644 index 00000000000..97e970b6c51 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx @@ -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>() + 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 + +const mockToken = (token: TokenResponse) => + addAPIMock({ + method: 'get', + path: '/platform/profile/scoped-access-tokens/:id', + response: () => HttpResponse.json(token), + }) + +describe('ViewTokenSheet', () => { + beforeEach(() => { + mockScopedTokenEnvironment() + }) + + const renderSheet = () => + customRender( {}} />, { + 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() + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx index 43c0b4207f6..c79aeb35803 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -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 = { - 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}

+ {resourceSummary.items.length === 0 && hasNoBoundResources && ( +

{boundResourcesDeletedText}

+ )} + {resourceSummary.items.length === 0 && !hasNoBoundResources && ( +

-

+ )} {resourceSummary.items.map((item) => ( -

- {item} -

+ ))}
, @@ -196,50 +229,14 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp [ 'Capabilities', hasCapabilities ? ( -
- {activeByCategory.map((category) => ( -
-

- {category.name} -

-
- {category.entries.map(({ entry, mode }) => ( -
- - - {entry.name} - - - {modeLabel(mode)} - -
- ))} -
-
- ))} -
+ ) : ( No capabilities selected ), ], [ 'Risk level', - - - {risk.level} Risk - - - {risk.text.replace(`${risk.level} — `, '')} - - , + , ], ] : [] @@ -255,7 +252,18 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp

View access for {token?.name}

- +
+ + +
@@ -275,6 +283,27 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp {token && ( <> + {hasNoBoundResources && ( + + )} + {access.hasNoAccessibleResource && ( + + )} + {hasExceedingCapabilities && !access.hasNoAccessibleResource && ( + + )}

Token summary

@@ -301,7 +330,7 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
{entry.name} - {mode === 'readwrite' ? 'Read-write' : 'Read'} + {PERMISSION_MODE_LABEL[mode]}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts new file mode 100644 index 00000000000..bdec3550e91 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts @@ -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 } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts index b211c9b689b..c40253a53d4 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useOrgAndProjectData.ts @@ -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, } } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts new file mode 100644 index 00000000000..acbd60df25f --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts @@ -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]) +} diff --git a/apps/studio/components/ui/DocsButton.tsx b/apps/studio/components/ui/DocsButton.tsx index 9a3de4e8e9e..45b5588c20e 100644 --- a/apps/studio/components/ui/DocsButton.tsx +++ b/apps/studio/components/ui/DocsButton.tsx @@ -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 ( )