mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 03:45:06 +03:00
feat: first draft of view permissions revamp
This commit is contained in:
1 parent
ba5f0f57fd
commit
98566d011a
15 files changed
+1137
-124
No files matched your search
+62
@@ -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'])
|
||||
})
|
||||
})
|
||||
+29
-5
@@ -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 || <span className="text-foreground-lighter">Untitled token</span>],
|
||||
['Expires', expiresSummary],
|
||||
@@ -190,13 +212,15 @@ export const NewScopedTokenFormReview = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{endpoints.map(([method, path]) => (
|
||||
{endpoints.map((endpoint) => (
|
||||
<div
|
||||
key={`${method} ${path}`}
|
||||
key={endpoint.raw}
|
||||
className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span className="w-14 shrink-0 text-foreground-light">{method}</span>
|
||||
<span className="text-foreground">{path}</span>
|
||||
<span className="w-14 shrink-0 text-foreground-light">
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<span className="text-foreground">{endpoint.path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+56
@@ -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<string, EntryAccess>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <span className="text-sm text-foreground-lighter">No capabilities selected</span>
|
||||
}
|
||||
|
||||
const tier = getCapabilityDensityTier(capabilities.length)
|
||||
|
||||
if (tier === 'expanded') {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{capabilities.map((capability) => (
|
||||
<CapabilityCard
|
||||
key={capability.entry.key}
|
||||
capability={capability}
|
||||
collapsible={false}
|
||||
accessEntries={accessEntries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tier === 'accordion') {
|
||||
return (
|
||||
<Accordion type="multiple" className="flex flex-col gap-3">
|
||||
{capabilities.map((capability) => (
|
||||
<CapabilityCard
|
||||
key={capability.entry.key}
|
||||
capability={capability}
|
||||
collapsible
|
||||
accessEntries={accessEntries}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
return <DenseCapabilities capabilities={capabilities} accessEntries={accessEntries} />
|
||||
}
|
||||
+69
@@ -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<string, EntryAccess>
|
||||
}
|
||||
|
||||
const CapabilityCardHeader = ({
|
||||
capability,
|
||||
accessEntries,
|
||||
}: Pick<CapabilityCardProps, 'capability' | 'accessEntries'>) => {
|
||||
const { entry, mode, endpoints, mcpTools } = capability
|
||||
const entryAccess = accessEntries[entry.key]
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-1 items-center justify-between gap-2">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-foreground">{entry.name}</span>
|
||||
<Badge variant={mode === 'readwrite' ? 'warning' : 'default'}>
|
||||
{PERMISSION_MODE_LABEL[mode]}
|
||||
</Badge>
|
||||
{entryAccess?.status === 'exceeds-role' && (
|
||||
<ExceedsRoleBadge entry={entry} mode={mode} access={entryAccess} />
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-foreground-lighter">
|
||||
{endpoints.length} {pluralize(endpoints.length, 'endpoint')}
|
||||
{mcpTools.length > 0 && ` · ${mcpTools.length} ${pluralize(mcpTools.length, 'tool')}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CapabilityCard = ({ capability, collapsible, accessEntries }: CapabilityCardProps) => {
|
||||
const body = (
|
||||
<CapabilityCardBody endpoints={capability.endpoints} mcpTools={capability.mcpTools} />
|
||||
)
|
||||
|
||||
if (!collapsible) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="px-3 py-2">
|
||||
<CapabilityCardHeader capability={capability} accessEntries={accessEntries} />
|
||||
</div>
|
||||
<div className="border-t p-3">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionItem value={capability.entry.key} className="rounded-md border">
|
||||
<AccordionTrigger className="px-3 py-2 font-normal hover:no-underline">
|
||||
<CapabilityCardHeader capability={capability} accessEntries={accessEntries} />
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="border-t">
|
||||
<div className="p-3">{body}</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
+99
@@ -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 (
|
||||
<div className="rounded-md border px-3 py-4 text-center text-xs text-foreground-lighter">
|
||||
No Management API endpoints or MCP tools are enabled by this capability yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{endpoints.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between border-b bg-surface-200 px-3 py-1.5">
|
||||
<span className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
Management API endpoints
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground-lighter">{endpoints.length}</span>
|
||||
</div>
|
||||
<table className="w-full" aria-label="Management API endpoints">
|
||||
<tbody className="divide-y">
|
||||
{visibleEndpoints.map((endpoint) => (
|
||||
<EndpointRow
|
||||
key={endpoint.raw}
|
||||
method={endpoint.method}
|
||||
path={endpoint.path}
|
||||
sharedPrefix={sharedPrefix}
|
||||
methodColumnWidth={methodColumnWidth}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{hiddenEndpointCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setShowAllEndpoints(true)}
|
||||
className="w-full border-t px-3 py-1.5 text-left text-xs text-foreground-light hover:text-foreground"
|
||||
>
|
||||
Show all {endpoints.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mcpTools.length > 0 && (
|
||||
<div className={cn(endpoints.length > 0 && 'border-t border-strong')}>
|
||||
<div className="flex items-center justify-between border-b bg-surface-200 px-3 py-1.5">
|
||||
<span className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
MCP tools
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground-lighter">{mcpTools.length}</span>
|
||||
</div>
|
||||
<table className="w-full" aria-label="MCP tools">
|
||||
<tbody className="divide-y">
|
||||
{mcpTools.map((tool) => (
|
||||
<tr key={tool}>
|
||||
<td className="w-1/3 px-3 py-1.5 align-top font-mono text-xs text-foreground">
|
||||
{tool}
|
||||
</td>
|
||||
{/* No description field exists yet for MCP tools — stubbed rather than fabricated. */}
|
||||
<td className="px-3 py-1.5 text-xs text-foreground-lighter">
|
||||
No description available
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+136
@@ -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<string, EntryAccess>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CapabilityLevelFilter>('all')
|
||||
const [manuallyOpenKeys, setManuallyOpenKeys] = useState<string[]>([])
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
placeholder="Filter by capability or endpoint..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={levelFilter}
|
||||
onValueChange={(value) => {
|
||||
if (value) setLevelFilter(value as CapabilityLevelFilter)
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="all">All</ToggleGroupItem>
|
||||
<ToggleGroupItem value="read">Read</ToggleGroupItem>
|
||||
<ToggleGroupItem value="readwrite">Read-write</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{readwrite.length === 0 && read.length === 0 && (
|
||||
<p className="text-xs text-foreground-lighter">No capabilities match your filter.</p>
|
||||
)}
|
||||
|
||||
<Accordion type="multiple" value={openKeys} onValueChange={setManuallyOpenKeys}>
|
||||
{readwrite.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
Read-write · {readwrite.length}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 rounded-md border border-warning-400 p-2">
|
||||
{readwrite.map((capability) => (
|
||||
<CapabilityCard
|
||||
key={capability.entry.key}
|
||||
capability={capability}
|
||||
collapsible
|
||||
accessEntries={accessEntries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{read.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
Read-only · {read.length}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{visibleRead.map((capability) => (
|
||||
<CapabilityCard
|
||||
key={capability.entry.key}
|
||||
capability={capability}
|
||||
collapsible
|
||||
accessEntries={accessEntries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hiddenReadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setShowAllReadOnly(true)}
|
||||
className="self-start text-xs text-foreground-light hover:text-foreground"
|
||||
>
|
||||
Show {hiddenReadCount} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Accordion>
|
||||
|
||||
{notGranted.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[11px] font-mono uppercase tracking-wide text-foreground-lighter">
|
||||
Not granted · {notGranted.length}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{notGranted.map((entry) => (
|
||||
<Badge key={entry.key} variant="default">
|
||||
{entry.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+47
@@ -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 (
|
||||
<tr>
|
||||
<td className="px-3 py-1.5 align-top" style={{ width: methodColumnWidth }}>
|
||||
{isMutating ? (
|
||||
<Badge variant="warning">{method}</Badge>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-foreground-lighter">{method}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="min-w-0 px-3 py-1.5" title={path}>
|
||||
<div className="flex min-w-0 overflow-hidden whitespace-nowrap font-mono text-xs">
|
||||
{prefix !== '' && (
|
||||
<span className="overflow-hidden text-ellipsis text-foreground-lighter">{prefix}</span>
|
||||
)}
|
||||
<span className="shrink-0 text-foreground">{distinguishing}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
+27
@@ -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<RiskBannerResult['tone'], AdmonitionType> = {
|
||||
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) => (
|
||||
<Admonition
|
||||
type={TONE_TO_ADMONITION_TYPE[risk.tone]}
|
||||
title={`${risk.level} risk`}
|
||||
description={risk.summary}
|
||||
>
|
||||
{showRoleCaveat && 'Based on what your current role allows this token to do.'}
|
||||
</Admonition>
|
||||
)
|
||||
+12
@@ -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
|
||||
+240
@@ -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')
|
||||
})
|
||||
})
|
||||
+180
@@ -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<RiskLevel, number> = { low: 1, medium: 2, high: 3 }
|
||||
const RANK_TO_RISK: Record<number, RiskLevel> = { 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<RiskLevel>((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) }
|
||||
}
|
||||
+98
-1
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
</div>
|
||||
</div>,
|
||||
],
|
||||
[
|
||||
'Capabilities',
|
||||
hasCapabilities ? (
|
||||
<CapabilityCategoryList categories={activeByCategory} accessEntries={access.entries} />
|
||||
) : (
|
||||
<span className="text-foreground-lighter">No capabilities selected</span>
|
||||
),
|
||||
],
|
||||
[
|
||||
'Risk level',
|
||||
<RiskLevelSummary key="risk" risk={risk} showRoleCaveat={hasExceedingCapabilities} />,
|
||||
],
|
||||
]
|
||||
: []
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
|
||||
<RiskBanner risk={risk} showRoleCaveat={hasExceedingCapabilities} />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Token summary</h3>
|
||||
<dl className="divide-y rounded-md border bg-surface-300">
|
||||
@@ -312,62 +305,10 @@ export function ViewTokenSheet({ visible, tokenId, onClose }: ViewTokenSheetProp
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{hasCapabilities && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Management API endpoints enabled</h3>
|
||||
{capabilityGroups.length === 0 ? (
|
||||
<p className="text-xs text-foreground-light">
|
||||
No Management API endpoints are enabled by the selected capabilities.
|
||||
</p>
|
||||
) : (
|
||||
capabilityGroups.map(({ entry, mode, endpoints }) => (
|
||||
<div key={entry.key} className="rounded-md border">
|
||||
<div className="flex items-center justify-between border-b bg-surface-100 px-3 py-2">
|
||||
<span className="text-xs text-foreground">{entry.name}</span>
|
||||
<span className="text-[11px] font-mono uppercase text-foreground-lighter">
|
||||
{PERMISSION_MODE_LABEL[mode]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{endpoints.map(([method, path]) => (
|
||||
<div
|
||||
key={`${method} ${path}`}
|
||||
className="flex items-center gap-2 px-3 py-1.5 font-mono text-xs"
|
||||
>
|
||||
<span className="w-14 shrink-0 text-foreground-light">
|
||||
{method}
|
||||
</span>
|
||||
<span className="text-foreground">{path}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">MCP tools</h3>
|
||||
{mcpTools.length === 0 ? (
|
||||
<p className="text-xs text-foreground-light">
|
||||
No MCP tools are enabled by the selected capabilities.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcpTools.map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="rounded border bg-surface-100 px-2 py-1 font-mono text-xs text-foreground-light"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-sm">Capabilities</h3>
|
||||
<CapabilitiesSection capabilities={capabilities} accessEntries={access.entries} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+33
-38
@@ -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 }
|
||||
}
|
||||
@@ -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<string>
|
||||
allGrantedScopes: Iterable<string>
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user