mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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))
|
||||
)
|
||||
+8
@@ -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 && (
|
||||
|
||||
+73
@@ -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>
|
||||
)
|
||||
}
|
||||
+38
-19
@@ -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>
|
||||
)
|
||||
}
|
||||
+55
@@ -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' }))
|
||||
|
||||
Reference in new issue
Block a user