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 = ({
- 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{' '}
-