diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts index 4aab626e393..fc568fae59a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.permissions.test.ts @@ -12,6 +12,7 @@ import { getEnabledEndpoints, getEnabledEndpointsForCapability, getEnabledMcpTools, + getEnabledMcpToolsForCapability, normalizePermissionScopeMap, type PermissionScopeMap, } from '@/data/scoped-access-tokens/permission-scope-map-query' @@ -341,3 +342,64 @@ describe('getEnabledEndpointsForCapability', () => { ).toEqual(['PUT /api/upgrade']) }) }) + +describe('getEnabledMcpToolsForCapability', () => { + it('attributes a tool to each capability whose scope is in a fully-granted group', () => { + const permissionScopeMap = scopeMap({ + mcp_tools: { + list_branches: [['branching_development_read'], ['branching_production_read']], + }, + }) + const allGrantedScopes = ['branching_development_read', 'branching_production_read'] + + expect( + getEnabledMcpToolsForCapability({ + capabilityScopes: ['branching_development_read'], + allGrantedScopes, + permissionScopeMap, + }) + ).toEqual(['list_branches']) + expect( + getEnabledMcpToolsForCapability({ + capabilityScopes: ['branching_production_read'], + allGrantedScopes, + permissionScopeMap, + }) + ).toEqual(['list_branches']) + }) + + it('does not attribute a tool to a capability whose own group is unsatisfied', () => { + const enabled = getEnabledMcpToolsForCapability({ + capabilityScopes: ['branching_production_read'], + allGrantedScopes: ['branching_development_read'], + permissionScopeMap: scopeMap({ + mcp_tools: { + list_branches: [['branching_development_read'], ['branching_production_read']], + }, + }), + }) + + expect(enabled).toEqual([]) + }) + + it('requires every scope of the capability group to be granted', () => { + const permissionScopeMap = scopeMap({ + mcp_tools: { upgrade_project: [['project_admin_read', 'database_read']] }, + }) + + expect( + getEnabledMcpToolsForCapability({ + capabilityScopes: ['database_read'], + allGrantedScopes: ['database_read'], + permissionScopeMap, + }) + ).toEqual([]) + expect( + getEnabledMcpToolsForCapability({ + capabilityScopes: ['database_read'], + allGrantedScopes: ['database_read', 'project_admin_read'], + permissionScopeMap, + }) + ).toEqual(['upgrade_project']) + }) +}) 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 3f71e882fb7..ed79e8172e2 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenFormReview.tsx @@ -4,6 +4,7 @@ import { Admonition } from 'ui-patterns/Admonition' import { computeOverallRisk, + PERMISSION_CATALOG_BY_CATEGORY, PERMISSION_MODE_LABEL, selectionToScopes, } from '../../AccessToken.permissions' @@ -88,12 +89,33 @@ export const NewScopedTokenFormReview = ({ const hasCapabilities = grantedScopes.length > 0 - const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({ + const { capabilities } = useCapabilitySummary({ selection, grantedScopes, permissionScopeMap, }) + 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( + () => Array.from(new Set(capabilities.flatMap((capability) => capability.mcpTools))), + [capabilities] + ) + + const capabilityGroups = useMemo( + () => capabilities.filter((capability) => capability.endpoints.length > 0), + [capabilities] + ) + const rows: [string, React.ReactNode][] = [ ['Name', values.tokenName || Untitled token], ['Expires', expiresSummary], @@ -190,13 +212,15 @@ export const NewScopedTokenFormReview = ({
- {endpoints.map(([method, path]) => ( + {endpoints.map((endpoint) => (
- {method} - {path} + + {endpoint.method} + + {endpoint.path}
))}
diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx new file mode 100644 index 00000000000..a9a9d2611ea --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilitiesSection.tsx @@ -0,0 +1,56 @@ +import { Accordion } from 'ui' + +import type { EntryAccess } from '../../AccessToken.roles' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { CapabilityCard } from './CapabilityCard' +import { DenseCapabilities } from './DenseCapabilities' +import { getCapabilityDensityTier } from './TokenCapabilities.utils' + +interface CapabilitiesSectionProps { + capabilities: CapabilitySummaryEntry[] + accessEntries: Record +} + +/** + * Switches capability presentation on granted count: a handful render fully expanded, a moderate + * number collapse into an accordion, and a large grant switches to the dense, filterable view. + */ +export const CapabilitiesSection = ({ capabilities, accessEntries }: CapabilitiesSectionProps) => { + if (capabilities.length === 0) { + return No capabilities selected + } + + const tier = getCapabilityDensityTier(capabilities.length) + + if (tier === 'expanded') { + return ( +
+ {capabilities.map((capability) => ( + + ))} +
+ ) + } + + if (tier === 'accordion') { + return ( + + {capabilities.map((capability) => ( + + ))} + + ) + } + + return +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx new file mode 100644 index 00000000000..2927d5300a7 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCard.tsx @@ -0,0 +1,69 @@ +import { AccordionContent, AccordionItem, AccordionTrigger, Badge } from 'ui' + +import { PERMISSION_MODE_LABEL } from '../../AccessToken.permissions' +import type { EntryAccess } from '../../AccessToken.roles' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { ExceedsRoleBadge } from '../ExceedsRoleBadge' +import { CapabilityCardBody } from './CapabilityCardBody' +import { pluralize } from '@/lib/helpers' + +interface CapabilityCardProps { + capability: CapabilitySummaryEntry + /** Accordion tiers wrap the header in a trigger button; the ≤2 tier renders it inert. */ + collapsible: boolean + accessEntries: Record +} + +const CapabilityCardHeader = ({ + capability, + accessEntries, +}: Pick) => { + const { entry, mode, endpoints, mcpTools } = capability + const entryAccess = accessEntries[entry.key] + + return ( +
+ + {entry.name} + + {PERMISSION_MODE_LABEL[mode]} + + {entryAccess?.status === 'exceeds-role' && ( + + )} + + + {endpoints.length} {pluralize(endpoints.length, 'endpoint')} + {mcpTools.length > 0 && ` · ${mcpTools.length} ${pluralize(mcpTools.length, 'tool')}`} + +
+ ) +} + +export const CapabilityCard = ({ capability, collapsible, accessEntries }: CapabilityCardProps) => { + const body = ( + + ) + + if (!collapsible) { + return ( +
+
+ +
+
{body}
+
+ ) + } + + return ( + + + + + +
{body}
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx new file mode 100644 index 00000000000..988f8a2a61b --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityCardBody.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react' +import { cn } from 'ui' + +import { EndpointRow } from './EndpointRow' +import { MAX_VISIBLE_ENDPOINT_ROWS } from './TokenCapabilities.constants' +import { getSharedPathPrefix } from './TokenCapabilities.utils' +import type { EnabledEndpoint } from '@/data/scoped-access-tokens/permission-scope-map-query' + +interface CapabilityCardBodyProps { + endpoints: EnabledEndpoint[] + mcpTools: string[] +} + +/** + * One bordered container holding two labelled, independently-tabled groups — endpoints and MCP + * tools — rather than a single table with interleaved header rows, per the two-table a11y pattern. + */ +export const CapabilityCardBody = ({ endpoints, mcpTools }: CapabilityCardBodyProps) => { + const [showAllEndpoints, setShowAllEndpoints] = useState(false) + + if (endpoints.length === 0 && mcpTools.length === 0) { + return ( +
+ No Management API endpoints or MCP tools are enabled by this capability yet. +
+ ) + } + + const sharedPrefix = getSharedPathPrefix(endpoints.map((endpoint) => endpoint.path)) + const methodColumnWidth = `${Math.max(0, ...endpoints.map((endpoint) => endpoint.method.length)) + 2}ch` + const visibleEndpoints = endpoints.slice( + 0, + showAllEndpoints ? endpoints.length : MAX_VISIBLE_ENDPOINT_ROWS + ) + const hiddenEndpointCount = endpoints.length - visibleEndpoints.length + + return ( +
+ {endpoints.length > 0 && ( +
+
+ + Management API endpoints + + {endpoints.length} +
+ + + {visibleEndpoints.map((endpoint) => ( + + ))} + +
+ {hiddenEndpointCount > 0 && ( + + )} +
+ )} + {mcpTools.length > 0 && ( +
0 && 'border-t border-strong')}> +
+ + MCP tools + + {mcpTools.length} +
+ + + {mcpTools.map((tool) => ( + + + {/* No description field exists yet for MCP tools — stubbed rather than fabricated. */} + + + ))} + +
+ {tool} + + No description available +
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx new file mode 100644 index 00000000000..12e54f94373 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/DenseCapabilities.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { Accordion, Badge, Input, ToggleGroup, ToggleGroupItem } from 'ui' + +import type { EntryAccess } from '../../AccessToken.roles' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { CapabilityCard } from './CapabilityCard' +import { DENSE_READONLY_PREVIEW_ROWS } from './TokenCapabilities.constants' +import { + filterCapabilities, + getNotGrantedCatalogEntries, + groupCapabilitiesByLevel, + type CapabilityLevelFilter, +} from './TokenCapabilities.utils' + +interface DenseCapabilitiesProps { + capabilities: CapabilitySummaryEntry[] + accessEntries: Record +} + +/** + * 9+ capabilities: a text/path filter and read/read-write segmented control narrow a level-grouped + * list. Read-write is pinned first and never truncated; read-only previews a few rows. A path match + * auto-expands its parent capability on top of whatever the user has manually opened. + */ +export const DenseCapabilities = ({ capabilities, accessEntries }: DenseCapabilitiesProps) => { + const [query, setQuery] = useState('') + const [levelFilter, setLevelFilter] = useState('all') + const [manuallyOpenKeys, setManuallyOpenKeys] = useState([]) + const [showAllReadOnly, setShowAllReadOnly] = useState(false) + + const filtered = filterCapabilities(capabilities, query, levelFilter) + const forcedOpenKeys = filtered + .filter((match) => match.matchedByPath) + .map((match) => match.capability.entry.key) + const openKeys = Array.from(new Set([...manuallyOpenKeys, ...forcedOpenKeys])) + + const { readwrite, read } = groupCapabilitiesByLevel(filtered.map((match) => match.capability)) + const notGranted = getNotGrantedCatalogEntries(capabilities) + + const visibleRead = showAllReadOnly ? read : read.slice(0, DENSE_READONLY_PREVIEW_ROWS) + const hiddenReadCount = read.length - visibleRead.length + + return ( +
+
+ setQuery(event.target.value)} + className="max-w-xs" + /> + { + if (value) setLevelFilter(value as CapabilityLevelFilter) + }} + > + All + Read + Read-write + +
+ + {readwrite.length === 0 && read.length === 0 && ( +

No capabilities match your filter.

+ )} + + + {readwrite.length > 0 && ( +
+

+ Read-write · {readwrite.length} +

+
+ {readwrite.map((capability) => ( + + ))} +
+
+ )} + + {read.length > 0 && ( +
+

+ Read-only · {read.length} +

+
+ {visibleRead.map((capability) => ( + + ))} +
+ {hiddenReadCount > 0 && ( + + )} +
+ )} +
+ + {notGranted.length > 0 && ( +
+

+ Not granted · {notGranted.length} +

+
+ {notGranted.map((entry) => ( + + {entry.name} + + ))} +
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx new file mode 100644 index 00000000000..92527243027 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/EndpointRow.tsx @@ -0,0 +1,47 @@ +import { Badge } from 'ui' + +import { splitEndpointPath } from './TokenCapabilities.utils' + +interface EndpointRowProps { + method: string + path: string + /** Shared leading segments across the group, rendered muted ahead of the distinguishing part. */ + sharedPrefix: string + /** Sized by the caller for the longest method present in the group. */ + methodColumnWidth: string +} + +/** + * The muted prefix span shrinks with an end-ellipsis while the distinguishing segment stays + * fixed-width — visually equivalent to truncating the full path in its middle, without needing to + * measure pixel widths. + */ +export const EndpointRow = ({ + method, + path, + sharedPrefix, + methodColumnWidth, +}: EndpointRowProps) => { + const { prefix, distinguishing } = splitEndpointPath(path, sharedPrefix) + const isMutating = method !== 'GET' + + return ( + + + {isMutating ? ( + {method} + ) : ( + {method} + )} + + +
+ {prefix !== '' && ( + {prefix} + )} + {distinguishing} +
+ + + ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx new file mode 100644 index 00000000000..c4f50328f1a --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/RiskBanner.tsx @@ -0,0 +1,27 @@ +import { Admonition } from 'ui-patterns/Admonition' +import type { AdmonitionType } from 'ui-patterns/Admonition' + +import type { RiskBannerResult } from './TokenCapabilities.utils' + +const TONE_TO_ADMONITION_TYPE: Record = { + default: 'default', + low: 'success', + medium: 'warning', + high: 'destructive', +} + +interface RiskBannerProps { + risk: RiskBannerResult + /** True when some selected permissions exceed the owner's role, so the risk is role-capped. */ + showRoleCaveat: boolean +} + +export const RiskBanner = ({ risk, showRoleCaveat }: RiskBannerProps) => ( + + {showRoleCaveat && 'Based on what your current role allows this token to do.'} + +) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts new file mode 100644 index 00000000000..dc873289023 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.constants.ts @@ -0,0 +1,12 @@ +/** + * Density-tier thresholds for the capabilities section, keyed to the number of granted + * capabilities. Provisional — revisit with product/design once real tokens exercise them. + */ +export const CAPABILITY_DENSITY_EXPANDED_MAX = 2 +export const CAPABILITY_DENSITY_ACCORDION_MAX = 8 + +/** Endpoint rows shown per capability before collapsing behind "Show all N". */ +export const MAX_VISIBLE_ENDPOINT_ROWS = 8 + +/** Read-only capability rows shown in dense mode before "Show N more". */ +export const DENSE_READONLY_PREVIEW_ROWS = 3 diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts new file mode 100644 index 00000000000..a2491cc0839 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' + +import { getCatalogEntry } from '../../AccessToken.permissions' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { + computeRiskBanner, + filterCapabilities, + getCapabilityDensityTier, + getNotGrantedCatalogEntries, + getSharedPathPrefix, + groupCapabilitiesByLevel, + splitEndpointPath, +} from './TokenCapabilities.utils' + +const endpoint = (method: string, path: string) => ({ method, path, raw: `${method} ${path}` }) + +// 'project:database' is catalog-high and writable; 'project:advisors' is catalog-low and read-only. +const databaseCapability = ( + mode: CapabilitySummaryEntry['mode'], + endpoints: CapabilitySummaryEntry['endpoints'] = [] +): CapabilitySummaryEntry => ({ + entry: getCatalogEntry('project:database')!, + mode, + endpoints, + mcpTools: [], +}) + +const advisorsCapability = (mode: CapabilitySummaryEntry['mode']): CapabilitySummaryEntry => ({ + entry: getCatalogEntry('project:advisors')!, + mode, + endpoints: [], + mcpTools: [], +}) + +describe('getCapabilityDensityTier', () => { + it('is expanded at 2 or fewer capabilities', () => { + expect(getCapabilityDensityTier(0)).toBe('expanded') + expect(getCapabilityDensityTier(2)).toBe('expanded') + }) + + it('is accordion between 3 and 8 capabilities', () => { + expect(getCapabilityDensityTier(3)).toBe('accordion') + expect(getCapabilityDensityTier(8)).toBe('accordion') + }) + + it('is dense at 9 or more capabilities', () => { + expect(getCapabilityDensityTier(9)).toBe('dense') + expect(getCapabilityDensityTier(50)).toBe('dense') + }) +}) + +describe('getSharedPathPrefix', () => { + it('returns nothing for a single endpoint — there is nothing to share', () => { + expect(getSharedPathPrefix(['/v1/projects/{ref}'])).toBe('') + }) + + it('finds the longest shared leading segments across paths', () => { + expect( + getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions/{slug}']) + ).toBe('/v1/projects/{ref}/functions/') + }) + + it('never cuts a shared prefix mid-segment', () => { + // "functions" and "functions-secrets" share characters but not a path segment. + expect( + getSharedPathPrefix(['/v1/projects/{ref}/functions', '/v1/projects/{ref}/functions-secrets']) + ).toBe('/v1/projects/{ref}/') + }) + + it('shares only the common leading segments, not any further', () => { + expect(getSharedPathPrefix(['/v1/branches', '/v1/organizations'])).toBe('/v1/') + }) + + it('returns nothing when paths share no leading segment at all', () => { + expect(getSharedPathPrefix(['/v1/branches', '/v2/organizations'])).toBe('') + }) + + it('keeps at least the last segment distinguishing when every path is identical', () => { + expect(getSharedPathPrefix(['/v1/projects/{ref}', '/v1/projects/{ref}'])).toBe('/v1/projects/') + }) +}) + +describe('splitEndpointPath', () => { + it('splits off the shared prefix when the path starts with it', () => { + expect(splitEndpointPath('/v1/projects/{ref}/functions', '/v1/projects/{ref}/')).toEqual({ + prefix: '/v1/projects/{ref}/', + distinguishing: 'functions', + }) + }) + + it('treats the whole path as distinguishing when there is no shared prefix', () => { + expect(splitEndpointPath('/v1/projects/{ref}', '')).toEqual({ + prefix: '', + distinguishing: '/v1/projects/{ref}', + }) + }) +}) + +describe('groupCapabilitiesByLevel', () => { + it('splits granted capabilities into read-write and read-only', () => { + const capabilities = [databaseCapability('readwrite'), advisorsCapability('read')] + + const { readwrite, read } = groupCapabilitiesByLevel(capabilities) + expect(readwrite.map((c) => c.entry.key)).toEqual(['project:database']) + expect(read.map((c) => c.entry.key)).toEqual(['project:advisors']) + }) +}) + +describe('getNotGrantedCatalogEntries', () => { + it('returns every catalog entry when nothing is granted', () => { + expect(getNotGrantedCatalogEntries([]).map((e) => e.key)).toContain('project:database') + }) + + it('excludes granted entries', () => { + const notGranted = getNotGrantedCatalogEntries([databaseCapability('read')]) + expect(notGranted.map((e) => e.key)).not.toContain('project:database') + }) +}) + +describe('filterCapabilities', () => { + const capabilities = [ + databaseCapability('readwrite', [endpoint('GET', '/v1/projects/{ref}/database')]), + advisorsCapability('read'), + ] + + it('returns everything, unmatched by path, when the query is empty', () => { + const result = filterCapabilities(capabilities, '', 'all') + expect(result.map((r) => r.capability.entry.key)).toEqual([ + 'project:database', + 'project:advisors', + ]) + expect(result.every((r) => !r.matchedByPath)).toBe(true) + }) + + it('matches by capability name', () => { + const result = filterCapabilities(capabilities, 'advisors', 'all') + expect(result.map((r) => r.capability.entry.key)).toEqual(['project:advisors']) + expect(result[0].matchedByPath).toBe(false) + }) + + it('matches by endpoint path and flags it as a path match', () => { + const result = filterCapabilities(capabilities, '/database', 'all') + expect(result.map((r) => r.capability.entry.key)).toEqual(['project:database']) + expect(result[0].matchedByPath).toBe(true) + }) + + it('applies the level filter before matching', () => { + expect(filterCapabilities(capabilities, '', 'read')).toHaveLength(1) + expect(filterCapabilities(capabilities, '', 'readwrite')).toHaveLength(1) + }) + + it('drops capabilities matching neither the name nor any endpoint path', () => { + expect(filterCapabilities(capabilities, 'storage', 'all')).toEqual([]) + }) +}) + +describe('computeRiskBanner', () => { + it('reports Minimal with no active capabilities', () => { + const risk = computeRiskBanner({ + effectiveSelection: {}, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['proj-1'], + }) + expect(risk).toEqual({ level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' }) + }) + + it('downgrades a read-only grant so it never outranks read-write on a lower-risk resource', () => { + // project:database is catalog-high; read-only downgrades it to medium. + const risk = computeRiskBanner({ + effectiveSelection: { 'project:database': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['proj-1'], + }) + expect(risk.level).toBe('Medium') + }) + + it('takes the max risk across capabilities, not just a write flag', () => { + const risk = computeRiskBanner({ + effectiveSelection: { 'project:database': 'readwrite', 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['proj-1'], + }) + expect(risk.level).toBe('High') + }) + + it('escalates account-wide read-write access to High', () => { + const risk = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'readwrite' }, + resourceAccess: 'account', + organizationSlugs: [], + projectRefs: [], + }) + expect(risk.level).toBe('High') + expect(risk.summary).toContain('across your entire account') + }) + + it('escalates project scope spanning many bound projects', () => { + const narrow = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['a'], + }) + const broad = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['a', 'b', 'c', 'd', 'e', 'f'], + }) + expect(narrow.level).toBe('Low') + expect(broad.level).toBe('Medium') + }) + + it('summarizes mixed read and read-write grants with explicit scope breadth', () => { + const risk = computeRiskBanner({ + effectiveSelection: { + 'project:database': 'readwrite', + 'project:advisors': 'read', + 'project:storage': 'read', + }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: ['a', 'b', 'c'], + }) + expect(risk.summary).toBe('Read-write on 1 capability, read on 2, across 3 projects.') + }) + + it('states scope breadth even when nothing is bound', () => { + const risk = computeRiskBanner({ + effectiveSelection: { 'project:advisors': 'read' }, + resourceAccess: 'project', + organizationSlugs: [], + projectRefs: [], + }) + expect(risk.summary).toContain('with no projects bound') + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts new file mode 100644 index 00000000000..52f136316bb --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/TokenCapabilities.utils.ts @@ -0,0 +1,180 @@ +import { + getCatalogEntry, + PERMISSION_CATALOG, + type PermissionCatalogEntry, + type PermissionSelection, + type ResourceAccessMode, + type RiskLevel, +} from '../../AccessToken.permissions' +import type { CapabilitySummaryEntry } from '../../hooks/useCapabilitySummary' +import { + CAPABILITY_DENSITY_ACCORDION_MAX, + CAPABILITY_DENSITY_EXPANDED_MAX, +} from './TokenCapabilities.constants' +import { pluralize } from '@/lib/helpers' + +export type CapabilityDensityTier = 'expanded' | 'accordion' | 'dense' + +export const getCapabilityDensityTier = (count: number): CapabilityDensityTier => { + if (count <= CAPABILITY_DENSITY_EXPANDED_MAX) return 'expanded' + if (count <= CAPABILITY_DENSITY_ACCORDION_MAX) return 'accordion' + return 'dense' +} + +/** + * Longest shared leading path segments across a group of endpoint paths, so the UI can mute the + * boilerplate prefix and highlight only the segment that distinguishes each row. Matching is + * segment-aware (split on "/") so a shared prefix never cuts a path mid-segment. A single-endpoint + * group has nothing to share, so the whole path is treated as the distinguishing part. + */ +export const getSharedPathPrefix = (paths: string[]): string => { + if (paths.length < 2) return '' + + const segmentLists = paths.map((path) => path.split('/')) + const [first, ...rest] = segmentLists + let matched = 0 + while (matched < first.length && rest.every((segments) => segments[matched] === first[matched])) { + matched++ + } + // Every path is identical (not just the shortest one fully consumed as a prefix of a longer + // one) — back off one segment so each path keeps at least the last as its distinguishing part. + if (segmentLists.every((segments) => segments.length === matched)) matched -= 1 + // matched === 1 only captures the empty segment before the leading "/", shared trivially by + // every absolute path — not a meaningful prefix. + if (matched <= 1) return '' + return first.slice(0, matched).join('/') + '/' +} + +export const splitEndpointPath = (path: string, sharedPrefix: string) => + sharedPrefix !== '' && path.startsWith(sharedPrefix) + ? { prefix: sharedPrefix, distinguishing: path.slice(sharedPrefix.length) } + : { prefix: '', distinguishing: path } + +export const groupCapabilitiesByLevel = (capabilities: CapabilitySummaryEntry[]) => ({ + readwrite: capabilities.filter((capability) => capability.mode === 'readwrite'), + read: capabilities.filter((capability) => capability.mode === 'read'), +}) + +/** Catalog entries the token doesn't grant at all — dense mode's "Not granted" group. */ +export const getNotGrantedCatalogEntries = ( + capabilities: CapabilitySummaryEntry[] +): PermissionCatalogEntry[] => { + const grantedKeys = new Set(capabilities.map((capability) => capability.entry.key)) + return PERMISSION_CATALOG.filter((entry) => !grantedKeys.has(entry.key)) +} + +export type CapabilityLevelFilter = 'all' | 'read' | 'readwrite' + +export interface FilteredCapability { + capability: CapabilitySummaryEntry + /** True when the match came from an endpoint path rather than just the capability name. */ + matchedByPath: boolean +} + +/** Local, in-memory filter for dense mode: matches capability name or any enabled endpoint path. */ +export const filterCapabilities = ( + capabilities: CapabilitySummaryEntry[], + query: string, + levelFilter: CapabilityLevelFilter +): FilteredCapability[] => { + const normalizedQuery = query.trim().toLowerCase() + + return capabilities + .filter((capability) => levelFilter === 'all' || capability.mode === levelFilter) + .flatMap((capability) => { + if (normalizedQuery === '') return [{ capability, matchedByPath: false }] + + const nameMatches = capability.entry.name.toLowerCase().includes(normalizedQuery) + const matchedByPath = capability.endpoints.some((endpoint) => + endpoint.path.toLowerCase().includes(normalizedQuery) + ) + if (!nameMatches && !matchedByPath) return [] + return [{ capability, matchedByPath }] + }) +} + +const RISK_RANK: Record = { low: 1, medium: 2, high: 3 } +const RANK_TO_RISK: Record = { 1: 'low', 2: 'medium', 3: 'high' } + +/** One severity tier down, floored at 'low' — a read-only grant never outranks a read-write one. */ +const downgradeRisk = (risk: RiskLevel): RiskLevel => RANK_TO_RISK[Math.max(1, RISK_RANK[risk] - 1)] + +export interface RiskBannerResult { + level: 'Minimal' | 'Low' | 'Medium' | 'High' + tone: 'default' | 'low' | 'medium' | 'high' + summary: string +} + +/** + * Computes the risk banner from the grant itself, never from a stored string. Severity is a max() + * over every granted capability's catalog risk — downgraded a tier for read-only grants so a + * read-only high-risk resource never outranks read-write on a medium one — then escalated for + * account-wide tokens and resource bindings spanning many orgs/projects. + */ +export const computeRiskBanner = ({ + effectiveSelection, + resourceAccess, + organizationSlugs, + projectRefs, +}: { + effectiveSelection: PermissionSelection + resourceAccess: ResourceAccessMode + organizationSlugs: string[] + projectRefs: string[] +}): RiskBannerResult => { + const active = Object.entries(effectiveSelection).filter(([, mode]) => mode !== 'none') + + if (active.length === 0) { + return { level: 'Minimal', tone: 'default', summary: 'No capabilities granted.' } + } + + const readWriteCount = active.filter(([, mode]) => mode === 'readwrite').length + const readCount = active.length - readWriteCount + + const maxRisk = active.reduce((max, [key, mode]) => { + const entry = getCatalogEntry(key) + if (!entry) return max + const effectiveRisk: RiskLevel = mode === 'readwrite' ? entry.risk : downgradeRisk(entry.risk) + return RISK_RANK[effectiveRisk] > RISK_RANK[max] ? effectiveRisk : max + }, 'low') + + let rank = RISK_RANK[maxRisk] + if (resourceAccess === 'account') { + rank = Math.max(rank, RISK_RANK.medium) + (readWriteCount > 0 ? 1 : 0) + } else if (resourceAccess === 'organization' && organizationSlugs.length > 3) { + rank += 1 + } else if (resourceAccess === 'project' && projectRefs.length > 5) { + rank += 1 + } + rank = Math.min(rank, RISK_RANK.high) + + const level = rank === RISK_RANK.high ? 'High' : rank === RISK_RANK.medium ? 'Medium' : 'Low' + const tone = rank === RISK_RANK.high ? 'high' : rank === RISK_RANK.medium ? 'medium' : 'low' + + const resourceNoun = resourceAccess === 'organization' ? 'organization' : 'project' + const boundCount = + resourceAccess === 'organization' ? organizationSlugs.length : projectRefs.length + const scopeText = + resourceAccess === 'account' + ? 'across your entire account' + : boundCount === 0 + ? `with no ${resourceNoun}s bound` + : `across ${boundCount} ${pluralize(boundCount, resourceNoun)}` + + const segments: string[] = [] + if (readWriteCount > 0) { + segments.push( + `read-write on ${readWriteCount} ${pluralize(readWriteCount, 'capability', 'capabilities')}` + ) + } + if (readCount > 0) { + segments.push( + segments.length === 0 + ? `read on ${readCount} ${pluralize(readCount, 'capability', 'capabilities')}` + : `read on ${readCount}` + ) + } + const sentence = `${segments.join(', ')}, ${scopeText}.` + + return { level, tone, summary: sentence.charAt(0).toUpperCase() + sentence.slice(1) } +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx index 97e970b6c51..bf1e4261e6f 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' import { platformComponents as components } from 'api-types' import { mockAnimationsApi } from 'jsdom-testing-mocks' import { HttpResponse } from 'msw' @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' import { MOCK_ORG, + MOCK_PROJECT, mockPermissionsApi, mockScopedTokenEnvironment, ownerRows, @@ -140,4 +141,100 @@ describe('ViewTokenSheet', () => { ).toBeGreaterThan(0) expect(screen.queryByText('This token no longer has access')).toBeNull() }) + + test('renders capability cards with attributed endpoints, MCP tools, and a risk banner', async () => { + mockPermissionsApi(ownerRows(MOCK_ORG.slug)) + mockToken({ + ...TOKEN_BASE, + scope: 'project', + project_refs: [MOCK_PROJECT.ref], + permissions: ['advisors_read', 'database_read', 'database_write'], + }) + addAPIMock({ + method: 'get', + // @ts-expect-error Studio API is missing from types + path: '/scoped-access-token-permissions', + response: () => + HttpResponse.json({ + scopes: {}, + endpoints: { + 'GET /v1/projects/{ref}/advisors/security': [['advisors_read']], + 'GET /v1/projects/{ref}/database': [['database_read']], + 'POST /v1/projects/{ref}/database/query': [['database_write']], + }, + mcp_tools: { + get_advisors: [['advisors_read']], + execute_sql: [['database_write']], + }, + }), + }) + renderSheet() + + // ≤2 capabilities render fully expanded — both cards are visible without interaction. + expect(await screen.findByText('Advisors')).toBeInTheDocument() + expect(screen.getByText('Database')).toBeInTheDocument() + expect(screen.getByText('Read-write')).toBeInTheDocument() + expect(screen.getByText('Read')).toBeInTheDocument() + + expect(screen.getByTitle('/v1/projects/{ref}/advisors/security')).toBeInTheDocument() + expect(screen.getByTitle('/v1/projects/{ref}/database')).toBeInTheDocument() + expect(screen.getByTitle('/v1/projects/{ref}/database/query')).toBeInTheDocument() + // Non-GET methods get a tinted badge; GET stays plain — both still render as text. + expect(screen.getByText('POST')).toBeInTheDocument() + + expect(screen.getByText('get_advisors')).toBeInTheDocument() + expect(screen.getByText('execute_sql')).toBeInTheDocument() + // MCP tools have no description field yet — stubbed visibly rather than fabricated. + expect(screen.getAllByText('No description available').length).toBe(2) + + // project:database is catalog-high risk and granted read-write — max() over capabilities. + expect(screen.getByText('High risk')).toBeInTheDocument() + expect( + screen.getByText('Read-write on 1 capability, read on 1, across 1 project.') + ).toBeInTheDocument() + }) + + test('switches to the dense, filterable view at 9+ granted capabilities', async () => { + mockPermissionsApi(ownerRows(MOCK_ORG.slug)) + mockToken({ + ...TOKEN_BASE, + scope: 'project', + project_refs: [MOCK_PROJECT.ref], + permissions: [ + 'advisors_read', + 'database_read', + 'database_write', + 'backups_read', + 'custom_domain_read', + 'edge_functions_read', + 'storage_read', + 'realtime_config_read', + 'vanity_subdomain_read', + 'infra_add_ons_read', + ], + }) + renderSheet() + + expect( + await screen.findByPlaceholderText('Filter by capability or endpoint...') + ).toBeInTheDocument() + expect(screen.getByText('Read-write · 1')).toBeInTheDocument() + expect(screen.getByText('Read-only · 8')).toBeInTheDocument() + expect(screen.getByText(/Not granted · \d+/)).toBeInTheDocument() + // Read-only previews only 3 of the 8 rows until "Show N more" is clicked. + expect(screen.getByText('Show 5 more')).toBeInTheDocument() + expect(screen.queryByText('Storage')).toBeNull() + + fireEvent.click(screen.getByText('Show 5 more')) + expect(screen.getByText('Storage')).toBeInTheDocument() + expect(screen.getByText('Backups')).toBeInTheDocument() + + // Filtering re-derives the read-only bucket, so a narrowed result isn't re-truncated. + fireEvent.change(screen.getByPlaceholderText('Filter by capability or endpoint...'), { + target: { value: 'storage' }, + }) + + expect(screen.getByText('Storage')).toBeInTheDocument() + expect(screen.queryByText('Backups')).toBeNull() + }) }) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx index edb9183e456..30cb1650cc1 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.tsx @@ -5,16 +5,14 @@ import { Admonition } from 'ui-patterns/Admonition' import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { TOKEN_DENIED_REMEDIATION } from '../AccessToken.constants' -import { - computeOverallRisk, - PERMISSION_MODE_LABEL, - scopesToSelection, - type ResourceAccessMode, -} from '../AccessToken.permissions' +import { scopesToSelection, type ResourceAccessMode } 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 { CapabilitiesSection } from './TokenCapabilities/CapabilitiesSection' +import { RiskBanner } from './TokenCapabilities/RiskBanner' +import { computeRiskBanner } from './TokenCapabilities/TokenCapabilities.utils' +import { ResourceSummaryItem } from './TokenSummaryRows' import { DocsButton } from '@/components/ui/DocsButton' import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query' import { useScopedAccessTokenQuery } from '@/data/scoped-access-tokens/scoped-access-token-query' @@ -82,13 +80,17 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp const boundResourcesDeletedText = `Every ${resourceNoun} this token was bound to has been deleted` const risk = useMemo( - () => computeOverallRisk(access.effectiveSelection, resourceAccess), - [access.effectiveSelection, resourceAccess] + () => + computeRiskBanner({ + effectiveSelection: access.effectiveSelection, + resourceAccess, + organizationSlugs: tokenOrganizationSlugs, + projectRefs: tokenProjectRefs, + }), + [access.effectiveSelection, resourceAccess, tokenOrganizationSlugs, tokenProjectRefs] ) - const hasCapabilities = grantedScopes.length > 0 - - const { activeByCategory, mcpTools, capabilityGroups } = useCapabilitySummary({ + const { capabilities } = useCapabilitySummary({ selection, grantedScopes, permissionScopeMap, @@ -222,18 +224,6 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp , ], - [ - 'Capabilities', - hasCapabilities ? ( - - ) : ( - No capabilities selected - ), - ], - [ - 'Risk level', - , - ], ] : [] @@ -300,6 +290,9 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp description="A token only works with permissions you currently hold. Permissions marked below will be denied until your role includes them." /> )} + + +

Token summary

@@ -312,62 +305,10 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
- {hasCapabilities && ( - <> -
-

Management API endpoints enabled

- {capabilityGroups.length === 0 ? ( -

- No Management API endpoints are enabled by the selected capabilities. -

- ) : ( - capabilityGroups.map(({ entry, mode, endpoints }) => ( -
-
- {entry.name} - - {PERMISSION_MODE_LABEL[mode]} - -
-
- {endpoints.map(([method, path]) => ( -
- - {method} - - {path} -
- ))} -
-
- )) - )} -
- -
-

MCP tools

- {mcpTools.length === 0 ? ( -

- No MCP tools are enabled by the selected capabilities. -

- ) : ( -
- {mcpTools.map((tool) => ( - - {tool} - - ))} -
- )} -
- - )} +
+

Capabilities

+ +
)} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts index 76b78959d4a..891a56cc2ef 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/hooks/useCapabilitySummary.ts @@ -2,15 +2,16 @@ import { useMemo } from 'react' import { getEntryScopes, - PERMISSION_CATALOG_BY_CATEGORY, + PERMISSION_CATALOG, type PermissionCatalogEntry, type PermissionMode, type PermissionSelection, } from '../AccessToken.permissions' import { getEnabledEndpointsForCapability, - getEnabledMcpTools, - PermissionScopeMap, + getEnabledMcpToolsForCapability, + type EnabledEndpoint, + type PermissionScopeMap, } from '@/data/scoped-access-tokens/permission-scope-map-query' interface UseCapabilitySummaryArgs { @@ -19,49 +20,43 @@ interface UseCapabilitySummaryArgs { permissionScopeMap: PermissionScopeMap | undefined } +export interface CapabilitySummaryEntry { + entry: PermissionCatalogEntry + mode: PermissionMode + endpoints: EnabledEndpoint[] + mcpTools: string[] +} + /** - * 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. + * Selection-derived summary data for the token view sheet: every granted catalog entry paired with + * the Management API endpoints and MCP tools it enables. */ 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 capabilities = useMemo(() => { + const result: CapabilitySummaryEntry[] = [] + for (const entry of PERMISSION_CATALOG) { + const mode = selection[entry.key] ?? 'none' + if (mode === 'none') continue - 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]) }) - } - } + const capabilityScopes = getEntryScopes(entry, mode) + const endpoints = getEnabledEndpointsForCapability({ + capabilityScopes, + allGrantedScopes: grantedScopes, + permissionScopeMap, + }) + const mcpTools = getEnabledMcpToolsForCapability({ + capabilityScopes, + allGrantedScopes: grantedScopes, + permissionScopeMap, + }) + result.push({ entry, mode, endpoints, mcpTools }) } - return groups - }, [activeByCategory, grantedScopes, permissionScopeMap]) + return result + }, [selection, grantedScopes, permissionScopeMap]) - return { activeByCategory, mcpTools, capabilityGroups } + return { capabilities } } diff --git a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts index 604cc8d68eb..c662c8f3667 100644 --- a/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts +++ b/apps/studio/data/scoped-access-tokens/permission-scope-map-query.ts @@ -154,6 +154,34 @@ export const getEnabledEndpointsForCapability = ({ .map(([raw]) => splitEndpoint(raw)) } +/** + * MCP-tool counterpart to getEnabledEndpointsForCapability: the MCP tools enabled by the complete + * granted-scope set that owe that to `capabilityScopes`, for grouping enabled tools under the + * capability that contributes them. + */ +export const getEnabledMcpToolsForCapability = ({ + capabilityScopes, + allGrantedScopes, + permissionScopeMap, +}: { + capabilityScopes: Iterable + allGrantedScopes: Iterable + permissionScopeMap: PermissionScopeMap | undefined +}): string[] => { + if (permissionScopeMap == null) return [] + + const granted = new Set(allGrantedScopes) + const capability = new Set(capabilityScopes) + return Object.entries(permissionScopeMap.mcp_tools) + .filter(([, groups]) => + groups.some( + (group) => + group.some((scope) => capability.has(scope)) && group.every((scope) => granted.has(scope)) + ) + ) + .map(([tool]) => tool) +} + /** * Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of * the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that