feat(studio): add permission presets to scoped pat creation form (#49381)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

This adds a quick presets selector to scoped pat permissions. No access,
read-only and full access.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added permission presets for scoped access tokens: No access,
Read-only, and Full access.
  * Added a selector to quickly configure permissions across resources.
  * Displays “Custom” when individual permissions differ from a preset.
  * Shows warnings and guidance for high-risk full-access permissions.
* Automatically uses read-only access for resources that do not support
write permissions.

* **Tests**
* Added coverage for preset selection, application, warnings, ordering,
and custom configurations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
kemal.earth authored and GitHub committed 2026-08-21 11:12:37 +01:00
1 parent f0cb024139
commit 31497ba127
6 files changed
+402 -19

No files matched your search

@@ -0,0 +1,117 @@
import { describe, expect, test } from 'vitest'
import {
getCatalogEntry,
PERMISSION_CATALOG,
PERMISSION_CATALOG_BY_CATEGORY,
type PermissionSelection,
} from './AccessToken.permissions'
import {
applyPreset,
getActivePresetId,
getFullAccessDescription,
getPreset,
PERMISSION_PRESETS,
} from './AccessToken.presets'
const NONE = getPreset('none')!
const READ = getPreset('read')!
const FULL = getPreset('full')!
describe('PERMISSION_PRESETS', () => {
test('offers no access, read-only and full access in that order', () => {
expect(PERMISSION_PRESETS.map((preset) => preset.id)).toEqual(['none', 'read', 'full'])
})
test('resolves every catalog entry to a mode its row can render', () => {
for (const entry of PERMISSION_CATALOG) {
expect(NONE.resolve(entry)).toBe('none')
expect(READ.resolve(entry)).toBe('read')
expect(FULL.resolve(entry)).toBe(entry.writable ? 'readwrite' : 'read')
}
})
test('caps full access at read for resources with no write scopes', () => {
const readOnlyEntries = PERMISSION_CATALOG.filter((entry) => !entry.writable)
expect(readOnlyEntries.length).toBeGreaterThan(0)
for (const entry of readOnlyEntries) {
expect(FULL.resolve(entry)).toBe('read')
}
})
test('only marks full access as risky, and only it carries a description', () => {
expect(
PERMISSION_PRESETS.filter((preset) => preset.isRisky).map((preset) => preset.id)
).toEqual(['full'])
expect(
PERMISSION_PRESETS.filter((preset) => preset.description !== undefined).map(
(preset) => preset.id
)
).toEqual(['full'])
})
})
describe('getFullAccessDescription', () => {
test('names high-risk resources that exist in the catalog', () => {
const description = getFullAccessDescription()
expect(description).toBe(
'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.'
)
for (const key of ['project:database', 'project:api_gateway_keys', 'organization:members']) {
expect(getCatalogEntry(key)).toBeDefined()
expect(getCatalogEntry(key)!.risk).toBe('high')
// The copy claims write access on these specifically, so they have to be writable
expect(getCatalogEntry(key)!.writable).toBe(true)
}
})
})
describe('applyPreset', () => {
test('sets every catalog entry by default', () => {
const selection = applyPreset(READ, {})
expect(Object.keys(selection)).toHaveLength(PERMISSION_CATALOG.length)
expect(Object.values(selection).every((mode) => mode === 'read')).toBe(true)
})
test('overwrites existing manual choices', () => {
const selection = applyPreset(NONE, { 'project:database': 'readwrite' })
expect(selection['project:database']).toBe('none')
})
test('leaves entries outside the given subset untouched', () => {
const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')!
const before: PermissionSelection = { 'project:advisors': 'read' }
const selection = applyPreset(FULL, before, database.entries)
expect(selection['project:advisors']).toBe('read')
expect(selection['project:database']).toBe('readwrite')
expect(Object.keys(selection)).toHaveLength(database.entries.length + 1)
})
})
describe('getActivePresetId', () => {
test('reads an empty selection as no access', () => {
expect(getActivePresetId({})).toBe('none')
})
test('identifies a selection produced by each preset', () => {
for (const preset of PERMISSION_PRESETS) {
expect(getActivePresetId(applyPreset(preset, {}))).toBe(preset.id)
}
})
test('returns null once a single row diverges', () => {
const selection = applyPreset(READ, {})
selection['project:storage'] = 'readwrite'
expect(getActivePresetId(selection)).toBeNull()
})
test('ignores rows outside the given subset', () => {
const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')!
const selection = applyPreset(READ, {}, database.entries)
selection['project:storage'] = 'readwrite'
expect(getActivePresetId(selection)).toBeNull()
expect(getActivePresetId(selection, database.entries)).toBe('read')
})
})
@@ -0,0 +1,111 @@
import {
getCatalogEntry,
PERMISSION_CATALOG,
type PermissionCatalogEntry,
type PermissionMode,
type PermissionSelection,
} from './AccessToken.permissions'
/**
* Bulk presets for the permission list. Setting 42 rows one at a time is the main reason people
* abandon the scoped token flow, so a preset applies a mode to every row at once and the user
* fine-tunes from there.
*
* A preset is data, not JSX: `resolve` maps a single catalog entry to a mode, and every helper
* below takes an `entries` subset (defaulting to the whole catalog) so the same presets can later
* back per-category "set all" controls, or move server-side.
*/
export type PermissionPresetId = 'none' | 'read' | 'full'
export interface PermissionPreset {
id: PermissionPresetId
label: string
/** Optional subtext, shown beneath the label in the menu. */
description?: string
/** Surfaces the description as an inline warning once applied. */
isRisky?: boolean
/** Announced in a live region once the preset is applied. */
announcement: string
resolve: (entry: PermissionCatalogEntry) => PermissionMode
}
/**
* High-risk resources named in the Full access warning. Keyed by catalog key so the copy can only
* name scopes that actually exist — AccessToken.presets.test.ts asserts every key resolves.
*/
const FULL_ACCESS_HIGH_RISK: { key: string; noun: string }[] = [
{ key: 'project:database', noun: 'your database' },
{ key: 'project:api_gateway_keys', noun: 'API keys' },
{ key: 'organization:members', noun: 'organization members' },
]
export const getFullAccessDescription = (): string => {
const nouns = FULL_ACCESS_HIGH_RISK.filter(({ key }) => getCatalogEntry(key) !== undefined).map(
({ noun }) => noun
)
if (nouns.length === 0) return 'Grants the highest access each resource offers.'
const listed =
nouns.length === 1
? nouns[0]
: `${nouns.slice(0, -1).join(', ')}, and ${nouns[nouns.length - 1]}`
return `Grants the highest access each resource offers, including write access to ${listed}.`
}
export const PERMISSION_PRESETS: PermissionPreset[] = [
{
id: 'none',
label: 'No access',
announcement: 'All permissions set to none',
resolve: () => 'none',
},
{
id: 'read',
label: 'Read-only',
announcement: 'All permissions set to read',
resolve: () => 'read',
},
{
id: 'full',
label: 'Full access',
description: getFullAccessDescription(),
isRisky: true,
announcement: 'All permissions set to read-write',
// Five resources expose no write scopes, so read is their highest level — resolving them to
// 'readwrite' would store a mode their Select can't render.
resolve: (entry) => (entry.writable ? 'readwrite' : 'read'),
},
]
export const getPreset = (id: PermissionPresetId): PermissionPreset | undefined =>
PERMISSION_PRESETS.find((preset) => preset.id === id)
/** Applies a preset over `entries`, leaving any selection outside that subset untouched. */
export const applyPreset = (
preset: PermissionPreset,
selection: PermissionSelection,
entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
): PermissionSelection => {
const next = { ...selection }
for (const entry of entries) {
next[entry.key] = preset.resolve(entry)
}
return next
}
/**
* The preset `selection` currently matches across `entries`, or null when it matches none of them
* — the "Custom" state, which is displayed but never selectable.
*/
export const getActivePresetId = (
selection: PermissionSelection,
entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
): PermissionPresetId | null => getActivePreset(selection, entries)?.id ?? null
export const getActivePreset = (
selection: PermissionSelection,
entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
): PermissionPreset | undefined =>
PERMISSION_PRESETS.find((preset) =>
entries.every((entry) => (selection[entry.key] ?? 'none') === preset.resolve(entry))
)
@@ -19,6 +19,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants'
import { countConfigured, PermissionMode } from '../../AccessToken.permissions'
import { applyPreset, type PermissionPreset } from '../../AccessToken.presets'
import { useTokenAccessEvaluation } from '../../hooks/useTokenAccessEvaluation'
import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils'
import { NewScopedTokenFormReview } from './NewScopedTokenFormReview'
@@ -122,6 +123,12 @@ export const NewScopedTokenForm = ({
if (mode !== 'none') setShowMissingPermissionsWarning(false)
}
const handleApplyPreset = (preset: PermissionPreset) => {
const next = applyPreset(preset, selection)
form.setValue('permissions', next)
if (countConfigured(next) > 0) setShowMissingPermissionsWarning(false)
}
return (
<>
{/* Radix wraps viewport children in an inline-styled display:table div that grows to fit
@@ -170,6 +177,7 @@ export const NewScopedTokenForm = ({
<PermissionsAccordion
selection={selection}
onChange={handlePermissionChange}
onApplyPreset={handleApplyPreset}
access={access}
/>
{showMissingPermissionsWarning && (
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { Select, SelectContent, SelectItem, SelectTrigger } from 'ui'
import type { PermissionSelection } from '../../AccessToken.permissions'
import {
getActivePreset,
PERMISSION_PRESETS,
type PermissionPreset,
} from '../../AccessToken.presets'
interface PermissionPresetSelectProps {
selection: PermissionSelection
onApplyPreset: (preset: PermissionPreset) => void
}
export const PermissionPresetSelect = ({
selection,
onApplyPreset,
}: PermissionPresetSelectProps) => {
const [announcement, setAnnouncement] = useState('')
const activePreset = getActivePreset(selection)
const handleSelectPreset = (id: string) => {
const preset = PERMISSION_PRESETS.find((candidate) => candidate.id === id)
if (preset === undefined) return
onApplyPreset(preset)
setAnnouncement(preset.announcement)
}
return (
// A div root so FormLayout's flex-row-reverse column stretches the trigger to its full width,
// lining it up with the resource selects in the section above.
<div>
{/* An empty value leaves every option unchecked, which is how "Custom" reads: a state the
selection can land in, never one you can pick. */}
<Select value={activePreset?.id ?? ''} onValueChange={handleSelectPreset}>
<SelectTrigger aria-label="Permission preset">
<span>Preset · {activePreset?.label ?? 'Custom'}</span>
</SelectTrigger>
{/* Pinned to the trigger width, or the Full access description would stretch the dropdown
far wider than the selects above. */}
<SelectContent className="w-(--radix-select-trigger-width)">
{PERMISSION_PRESETS.map((preset) => (
<SelectItem
key={preset.id}
value={preset.id}
aria-describedby={
preset.description === undefined ? undefined : `preset-${preset.id}-description`
}
className="items-start"
>
<span className="flex flex-col gap-0.5">
<span>{preset.label}</span>
{preset.description !== undefined && (
<span
id={`preset-${preset.id}-description`}
className="text-xs text-foreground-lighter"
>
{preset.description}
</span>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* The menu unmounts on select, so the announcement lives outside it to survive the close. */}
<span className="sr-only" role="status">
{announcement}
</span>
</div>
)
}
@@ -1,5 +1,7 @@
import { useState } from 'react'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, cn } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
import {
countConfiguredInCategory,
@@ -7,7 +9,9 @@ import {
type PermissionMode,
type PermissionSelection,
} from '../../AccessToken.permissions'
import { getActivePreset, type PermissionPreset } from '../../AccessToken.presets'
import type { TokenAccessEvaluation } from '../../AccessToken.roles'
import { PermissionPresetSelect } from './PermissionPresetSelect'
import { PermissionRow } from './PermissionRow'
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
@@ -15,36 +19,51 @@ import { DOCS_URL } from '@/lib/constants'
interface PermissionsAccordionProps {
selection: PermissionSelection
onChange: (key: string, mode: PermissionMode) => void
onApplyPreset: (preset: PermissionPreset) => void
access?: TokenAccessEvaluation
}
export const PermissionsAccordion = ({
selection,
onChange,
onApplyPreset,
access,
}: PermissionsAccordionProps) => {
const [openCategories, setOpenCategories] = useState<string[]>([])
const activePreset = getActivePreset(selection)
// Derived, so editing any row back off the preset clears the warning with it.
const riskyPreset = activePreset?.isRisky === true ? activePreset : undefined
return (
<div className="space-y-3 px-5 sm:px-6 py-6">
<div>
<h3 className="text-sm text-foreground">Permissions</h3>
<p className="text-foreground-lighter text-sm">
Grant the minimum access this token needs. Everything defaults to None. Permissions follow
your role in the organizations and projects you're a member of — see{' '}
<InlineLink href={`${DOCS_URL}/guides/platform/access-control`}>
access control
</InlineLink>{' '}
for how roles work.
</p>
</div>
<Accordion
type="multiple"
value={openCategories}
onValueChange={setOpenCategories}
className="mt-2"
<section className="space-y-4 px-5 sm:px-6 py-6">
<FormLayout
layout="flex-row-reverse"
label="Permissions"
description={
<p className="text-foreground-lighter text-sm">
Grant the minimum access this token needs. Everything defaults to None. Permissions
follow your role in the organizations and projects you're a member of — see{' '}
<InlineLink href={`${DOCS_URL}/guides/platform/access-control`}>
access control
</InlineLink>{' '}
for how roles work.
</p>
}
>
<PermissionPresetSelect selection={selection} onApplyPreset={onApplyPreset} />
</FormLayout>
{riskyPreset !== undefined && (
<Admonition
type="warning"
// Groups the warning with the header above it, rather than the list it sits on top of.
className="mb-4"
title={riskyPreset.label}
description={riskyPreset.description}
/>
)}
<Accordion type="multiple" value={openCategories} onValueChange={setOpenCategories}>
{PERMISSION_CATALOG_BY_CATEGORY.map((category, index) => {
const configuredCount = countConfiguredInCategory(selection, category.key)
return (
@@ -88,6 +107,6 @@ export const PermissionsAccordion = ({
)
})}
</Accordion>
</div>
</section>
)
}
@@ -256,6 +256,61 @@ describe('NewScopedTokenSheet', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
}, 10_000)
// Permission preset tests
const FULL_ACCESS_WARNING =
'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.'
const getPresetTrigger = async () => screen.findByRole('combobox', { name: 'Permission preset' })
const openPresetMenu = async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' }))
await screen.findByRole('dialog')
fireEvent.click(await getPresetTrigger())
}
test('applies a preset to every permission row', async () => {
renderSheet()
await openPresetMenu()
fireEvent.click(await screen.findByRole('option', { name: 'Read-only' }))
expect((await getPresetTrigger()).textContent).toContain('Read-only')
// The bulk change is announced rather than left to the user to notice
expect((await screen.findByRole('status')).textContent).toBe('All permissions set to read')
await expandPermissionCategory('Project')
const select = await screen.findByLabelText('Project Settings', { exact: false })
expect(select.textContent).toBe('Read')
})
test('warns about full access in the menu and inline once applied', async () => {
renderSheet()
await openPresetMenu()
const fullAccess = await screen.findByRole('option', { name: /^Full access/ })
const describedBy = fullAccess.getAttribute('aria-describedby')
expect(describedBy).not.toBeNull()
expect(document.getElementById(describedBy!)?.textContent).toBe(FULL_ACCESS_WARNING)
fireEvent.click(fullAccess)
// The warning survives the menu closing
await screen.findByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })
expect((await getPresetTrigger()).textContent).toContain('Full access')
})
test('falls back to Custom when a row diverges from the preset', async () => {
renderSheet()
await openPresetMenu()
fireEvent.click(await screen.findByRole('option', { name: /^Full access/ }))
await screen.findByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })
await expandPermissionCategory('Project')
fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false }))
fireEvent.click(await screen.findByRole('option', { name: 'None' }))
await waitFor(async () => expect((await getPresetTrigger()).textContent).toContain('Custom'))
expect(screen.queryByText(FULL_ACCESS_WARNING, { selector: '[role="alert"] *' })).toBeNull()
})
test('opens the experimental API dialog from the dropdown', async () => {
renderSheet()
await user.click(await screen.findByRole('button', { name: 'Choose token scope' }))