From 33482bdc887c93db0f402af8184ebab887a2d367 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:19:30 +0100 Subject: [PATCH] feat(studio): lifecycle and role-aware scoped token view sheet (#48848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Extracts the token view sheet slice of #48742 (w3b6x9/scoped-pat-access-feedback, commit 56ef87a). The role-evaluation logic (estimateRoleLevel, computeTokenRoleContext, applySelectionToRoleContext, groupFailingResources) already landed on master via #48805 and #48809 — this PR only wires the view sheet up to it: - Bindings whose project/org was deleted (FGA bindings erased) render a "resources no longer exist" state; bindings the user can no longer reach render an anonymous count with a "No longer accessible" badge and a "removed from" admonition. - Accessible resources list their name plus ref/slug; capabilities show "Exceeds your role" pills and the risk badge reflects what the owner's current role actually allows. - Header split into separate "Access control" and "API docs" buttons. - Everything recomputes from live org/project/permission queries (no stored state) and degrades to no warnings while loading or on self-hosted. Also gives DocsButton an optional `label` prop (defaults preserve existing behavior for every other consumer) so the two header buttons can carry distinct text. ## Summary by CodeRabbit * **New Features** * Enhanced access-token details with permission categories, risk summaries, endpoint, and MCP information. * Added warnings for permissions exceeding the token’s role. * Clearly identifies inaccessible, deleted, or unavailable organizations and projects. * Added resource details and remediation guidance for unusable tokens. * Documentation links can now display custom labels. * **Bug Fixes** * Improved access evaluation when organization or project data is incomplete or access has changed. * Deferred resource loading until token details are opened. Co-authored-by: Claude Sonnet 5 --- .../AccessTokens/AccessToken.constants.ts | 4 + .../AccessTokens/AccessToken.fixtures.ts | 58 ++++ .../AccessTokens/AccessToken.permissions.ts | 22 ++ .../AccessTokens/Scoped/ExceedsRoleBadge.tsx | 74 +++++ .../AccessTokens/Scoped/TokenSummaryRows.tsx | 113 ++++++++ .../Scoped/ViewTokenSheet.test.tsx | 143 ++++++++++ .../AccessTokens/Scoped/ViewTokenSheet.tsx | 267 ++++++++++-------- .../hooks/useCapabilitySummary.ts | 67 +++++ .../hooks/useTokenAccessEvaluation.ts | 67 +++++ apps/studio/components/ui/DocsButton.tsx | 6 +- 10 files changed, 699 insertions(+), 122 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/ExceedsRoleBadge.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts index 4448dfb5f40..c3f765e8bb3 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.constants.ts @@ -6,6 +6,10 @@ export type ScopedAccessTokenPermission = export const CUSTOM_EXPIRY_VALUE = 'custom' +/** 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.' + /** Warning shown on both entry points that create a classic (account-wide) token. */ export const CLASSIC_TOKEN_WARNING = { title: 'Access tokens can be used to control your whole account', diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts index f19ae68170a..830692e2b2c 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.fixtures.ts @@ -1,3 +1,8 @@ +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' /** @@ -8,6 +13,10 @@ import type { Permission } from '@/types' * 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 @@ -62,3 +71,52 @@ export const ownerRows = (slug: string, refs: string[] = []) => [ 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 5e4586d21b6..4b74ad622da 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.ts @@ -623,6 +623,18 @@ 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_DOT_CLASS: Record = { + low: 'bg-brand-600', + medium: 'bg-warning-600', + high: 'bg-destructive-600', +} + export type ResourceAccessMode = 'project' | 'organization' | 'account' export interface OverallRisk { @@ -632,6 +644,16 @@ export interface OverallRisk { tone: 'default' | 'low' | 'medium' | 'high' } +export const RISK_TONE_VARIANT: Record< + OverallRisk['tone'], + 'default' | 'success' | 'warning' | 'destructive' +> = { + default: 'default', + low: 'success', + medium: 'warning', + high: 'destructive', +} + /** * Computes the overall token risk from the selected capabilities and the resource-access breadth. * Account-level tokens are never below "Elevated", even when read-only. 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..67912517bf2 --- /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. + */ +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. + */ +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/TokenSummaryRows.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenSummaryRows.tsx new file mode 100644 index 00000000000..140e38cf8ff --- /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 view sheet's summary section. + */ + +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.text.replace(`${risk.level} — `, '')} + + + {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 7c2b2f8a5b9..edb9183e456 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -1,27 +1,25 @@ 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 { 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 @@ -29,33 +27,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 { @@ -71,72 +51,114 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp } ) + // The sheet stays mounted (hidden) on the tokens page; don't fetch org/project data 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 ? [ @@ -183,10 +205,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} -

+ ))}
, @@ -194,50 +225,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} — `, '')} - - , + , ], ] : [] @@ -253,7 +248,18 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp

View access for {token?.name}

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

Token summary

@@ -299,7 +326,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..76b78959d4a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts @@ -0,0 +1,67 @@ +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 for 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/useTokenAccessEvaluation.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts new file mode 100644 index 00000000000..4d18186d5bc --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useTokenAccessEvaluation.ts @@ -0,0 +1,67 @@ +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 } = useOrgAndProjectData({ + enabled, + }) + + // Org/project lists still loading: resources the user *does* have access to would read as + // inaccessible, so report unknown instead. + const hasCompleteResourceLists = !isLoadingOrgs && !isLoadingProjects + + 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 ( )