From 31497ba127655be687c8aafccf86d6d3a2366c2f Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:12:37 +0100 Subject: [PATCH] feat(studio): add permission presets to scoped pat creation form (#49381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? This adds a quick presets selector to scoped pat permissions. No access, read-only and full access. ## 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. --- .../AccessTokens/AccessToken.presets.test.ts | 117 ++++++++++++++++++ .../AccessTokens/AccessToken.presets.ts | 111 +++++++++++++++++ .../Scoped/Form/NewScopedTokenForm.tsx | 8 ++ .../Scoped/Form/PermissionPresetSelect.tsx | 73 +++++++++++ .../Scoped/Form/PermissionsAccordion.tsx | 57 ++++++--- .../Scoped/NewScopedTokenSheet.test.tsx | 55 ++++++++ 6 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts create mode 100644 apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts new file mode 100644 index 00000000000..c54138e3c0e --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts new file mode 100644 index 00000000000..12c4550a20e --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessToken.presets.ts @@ -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)) + ) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx index 7c18d08c332..eed6f0cae90 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.tsx @@ -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 = ({ {showMissingPermissionsWarning && ( diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx new file mode 100644 index 00000000000..49368f524a0 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionPresetSelect.tsx @@ -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. +
+ {/* An empty value leaves every option unchecked, which is how "Custom" reads: a state the + selection can land in, never one you can pick. */} + + {/* The menu unmounts on select, so the announcement lives outside it to survive the close. */} + + {announcement} + +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx index 23112f36d87..b96ba33e95e 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/PermissionsAccordion.tsx @@ -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([]) + 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 ( -
-
-

Permissions

-

- 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{' '} - - access control - {' '} - for how roles work. -

-
- - + + 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{' '} + + access control + {' '} + for how roles work. +

+ } > + +
+ + {riskyPreset !== undefined && ( + + )} + + {PERMISSION_CATALOG_BY_CATEGORY.map((category, index) => { const configuredCount = countConfiguredInCategory(selection, category.key) return ( @@ -88,6 +107,6 @@ export const PermissionsAccordion = ({ ) })} -
+ ) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 550295bac60..9811078e177 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -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' }))