[FE-2158] – feat(studio): Add exposed tables config to Postgrest settings (#43280)

Adds a feature flagged exposed tables config to postgrest settings:
<img width="1158" height="589" alt="Screenshot 2026-03-02 at 17 04 13"
src="https://github.com/user-attachments/assets/8fa8ab81-0bfa-4781-83bc-80ac52e180f8"
/>

To test:
- make sure the existing (feature flag off) settings work as expected
- make sure exposing and removing schemas works in the new mode
- make sure exposing and removing tables works in the new mode
- ideally try with a lot of tables to check search and infinite scroll
work as expected
- try exposing a schema without any tables (like `graphql_public`) and
make sure it doesn't get removed randomly when editing tables
- try with a table with custom permissions (for example `REVOKE SELECT
ON public.posts FROM anon;`) and make sure the user is informed with the
tooltip

---------

Co-authored-by: Nick Babadzhanian <33933459+pgnickb@users.noreply.github.com>
This commit is contained in:
Alaister YoungandNick Babadzhanian authored and GitHub committed 2026-03-06 20:51:37 +08:00
1 parent 4b994fcb54
commit 5894e37b40
12 files changed
+1201 -415

No files matched your search

@@ -0,0 +1,143 @@
import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
Button,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
import { pluralize } from '@/lib/helpers'
interface ExposedSchemaSelectorProps {
disabled?: boolean
selectedSchemas: string[]
onToggleSchema: (schema: string) => void
}
export const ExposedSchemaSelector = ({
disabled = false,
selectedSchemas,
onToggleSchema,
}: ExposedSchemaSelectorProps) => {
const [open, setOpen] = useState(false)
const { data: project } = useSelectedProjectQuery()
const {
data: allSchemas,
isPending,
isError,
isSuccess,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const schemas = useMemo(
() =>
(allSchemas ?? [])
.filter((s) => {
if (s.name === 'graphql_public') return true
return !INTERNAL_SCHEMAS.includes(s.name)
})
.sort((a, b) => a.name.localeCompare(b.name)),
[allSchemas]
)
const selectedSet = useMemo(() => new Set(selectedSchemas), [selectedSchemas])
const selectedCount = schemas.filter((s) => selectedSet.has(s.name)).length
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="small"
disabled={disabled}
type="default"
className="w-full [&>span]:w-full !pr-1 space-x-1"
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
>
<div className="w-full flex gap-1">
<p className="text-foreground-lighter">
{isSuccess
? `${selectedCount} of ${schemas.length} ${pluralize(schemas.length, 'schema')} exposed`
: 'Loading schemas...'}
</p>
</div>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 min-w-[200px] pointer-events-auto"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command_Shadcn_>
<CommandInput_Shadcn_ className="text-xs" placeholder="Find schema..." />
<CommandList_Shadcn_>
<CommandGroup_Shadcn_>
{isPending ? (
<>
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
<div className="px-2 py-1 w-4/5">
<ShimmeringLoader className="py-2" />
</div>
</>
) : isError ? (
<div className="flex items-center py-3 justify-center">
<p className="text-xs text-foreground-lighter">Failed to retrieve schemas</p>
</div>
) : (
<>
<CommandEmpty_Shadcn_>
<p className="text-xs text-center text-foreground-lighter py-3">
No schemas found
</p>
</CommandEmpty_Shadcn_>
<ScrollArea className={schemas.length > 7 ? 'h-[210px]' : ''}>
{schemas.map((schema) => {
const isExposed = selectedSet.has(schema.name)
return (
<CommandItem_Shadcn_
key={schema.id}
value={schema.name}
className="cursor-pointer w-full"
onSelect={() => {
onToggleSchema(schema.name)
}}
>
<div
className={cn('w-full flex items-center gap-x-2', !isExposed && 'ml-6')}
>
{isExposed && <Check size={16} className="text-brand shrink-0" />}
<span className="truncate">{schema.name}</span>
</div>
</CommandItem_Shadcn_>
)
})}
</ScrollArea>
</>
)}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -0,0 +1,305 @@
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
cn,
Command_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { exposedTableCountsQueryOptions } from '@/data/privileges/exposed-table-counts-query'
import { exposedTablesInfiniteQueryOptions } from '@/data/privileges/exposed-tables-infinite-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { pluralize } from '@/lib/helpers'
interface ExposedTableSelectorProps {
className?: string
disabled?: boolean
selectedSchemas: string[]
pendingAddTableIds: number[]
pendingRemoveTableIds: number[]
onTogglePendingAdd: (tableId: number) => void
onTogglePendingRemove: (tableId: number) => void
}
export const ExposedTableSelector = ({
className,
disabled = false,
selectedSchemas,
pendingAddTableIds,
pendingRemoveTableIds,
onTogglePendingAdd,
onTogglePendingRemove,
}: ExposedTableSelectorProps) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const { data: project } = useSelectedProjectQuery()
const scrollRootRef = useRef<HTMLDivElement | null>(null)
const [sentinelRef, entry] = useIntersectionObserver({
root: scrollRootRef.current,
threshold: 0,
rootMargin: '0px',
})
const { data: countsData, isPending: isCountsPending } = useQuery({
...exposedTableCountsQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
selectedSchemas,
}),
placeholderData: keepPreviousData,
})
const pendingCount = pendingAddTableIds.length + pendingRemoveTableIds.length
const totalCount = countsData?.total_count ?? 0
const grantsCount = countsData?.grants_count ?? 0
const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
...exposedTablesInfiniteQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
search: search.length === 0 ? undefined : debouncedSearch || undefined,
}),
placeholderData: search.length > 0 ? keepPreviousData : undefined,
})
const tables = useMemo(() => data?.pages.flatMap((page) => page.tables) ?? [], [data?.pages])
const pendingAddSet = useMemo(() => new Set(pendingAddTableIds), [pendingAddTableIds])
const pendingRemoveSet = useMemo(() => new Set(pendingRemoveTableIds), [pendingRemoveTableIds])
useEffect(() => {
if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
return (
<div className={className}>
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="small"
disabled={disabled}
type="default"
className="w-full [&>span]:w-full !pr-1 space-x-1"
iconRight={
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
}
>
<div className="w-full flex gap-1">
<p className="text-foreground-lighter">
{isCountsPending
? 'Loading tables...'
: `${grantsCount} of ${totalCount} tables exposed${
pendingCount > 0
? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
: ''
}`}
</p>
</div>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 min-w-[200px] pointer-events-auto"
side="bottom"
align="start"
sameWidthAsTrigger
>
<Command_Shadcn_ shouldFilter={false}>
<CommandInput_Shadcn_
className="text-xs"
placeholder="Find table..."
value={search}
onValueChange={setSearch}
/>
<CommandList_Shadcn_>
<CommandGroup_Shadcn_>
{isPending ? (
<>
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
<div className="px-2 py-1 w-4/5">
<ShimmeringLoader className="py-2" />
</div>
</>
) : isError ? (
<div className="flex items-center py-3 justify-center">
<p className="text-xs text-foreground-lighter">Failed to retrieve tables</p>
</div>
) : (
<>
{search.length > 0 && tables.length === 0 && (
<p className="text-xs text-center text-foreground-lighter py-3">
No tables found
</p>
)}
<ScrollArea
ref={scrollRootRef}
className={tables.length > 7 ? 'h-[210px]' : ''}
>
{tables.map((table) => {
const isSchemaExposed = selectedSchemas.includes(table.schema)
const hasPendingAdd = pendingAddSet.has(table.id)
const hasPendingRemove = pendingRemoveSet.has(table.id)
const isCustomTable = table.status === 'custom'
const isGranted = table.status === 'granted'
const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
const isExposed =
isSchemaExposed &&
(isCustomTable
? hasPendingAdd
: isGranted
? !hasPendingRemove
: hasPendingAdd)
const customGrantsTooltip = getCustomGrantsTooltip({
hasPendingAdd,
hasPendingRemove,
})
return (
<CommandItem_Shadcn_
key={table.id}
value={`${table.schema}.${table.name}-${table.id}`}
className={cn(
'w-full',
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 !cursor-not-allowed'
)}
onSelect={() => {
if (!isSchemaExposed) return
if (isCustomTable) {
if (hasPendingAdd) {
onTogglePendingAdd(table.id)
onTogglePendingRemove(table.id)
} else if (hasPendingRemove) {
onTogglePendingRemove(table.id)
onTogglePendingAdd(table.id)
} else {
onTogglePendingAdd(table.id)
}
return
}
if (isGranted) {
onTogglePendingRemove(table.id)
} else {
onTogglePendingAdd(table.id)
}
}}
>
<div className="w-full flex items-center gap-x-2">
<div className="w-4 shrink-0 flex items-center justify-center">
{isExposed && <Check size={16} className="text-brand shrink-0" />}
</div>
<span
className={cn(
'truncate',
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
isCustomNeutral && isSchemaExposed && 'text-warning'
)}
>
{`${table.schema}.${table.name}`}
</span>
<div className="ml-auto flex items-center gap-x-2">
{isCustomTable && (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'shrink-0 flex items-center justify-center hover:text-foreground-light',
isCustomNeutral && isSchemaExposed
? 'text-warning'
: 'text-foreground-muted'
)}
>
<CircleAlert size={14} />
</div>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-[320px] text-xs pointer-events-none"
>
{customGrantsTooltip}
</TooltipContent>
</Tooltip>
)}
{!isSchemaExposed && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-label="Schema not exposed"
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
>
<Info size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[320px] text-xs">
{`The schema "${table.schema}" must be exposed before enabling this table.`}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</CommandItem_Shadcn_>
)
})}
<div ref={sentinelRef} className="h-1 -mt-1" />
{hasNextPage && (
<div className="px-2 py-1">
<ShimmeringLoader className="py-2" />
</div>
)}
</ScrollArea>
</>
)}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</div>
)
}
const getCustomGrantsTooltip = ({
hasPendingAdd,
hasPendingRemove,
}: {
hasPendingAdd: boolean
hasPendingRemove: boolean
}) => {
if (hasPendingAdd) {
return 'This table has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.'
}
if (hasPendingRemove) {
return 'This table has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.'
}
return 'This table has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
}
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { indexOf } from 'lodash'
import { Lock } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -19,6 +19,7 @@ import {
Input_Shadcn_,
PrePostTab,
Skeleton,
useWatch_Shadcn_,
} from 'ui'
import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
@@ -32,16 +33,26 @@ import {
} from 'ui-patterns/multi-select'
import { z } from 'zod'
import { ExposedSchemaSelector } from './ExposedSchemaSelector'
import { HardenAPIModal } from './HardenAPIModal'
import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector'
import { FormActions } from '@/components/ui/Forms/FormActions'
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { privilegeKeys } from '@/data/privileges/keys'
import { useUpdateExposedTablesMutation } from '@/data/privileges/update-exposed-tables-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
import useLatest from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
import { noop } from '@/lib/void'
import type { ResponseError } from '@/types'
const formSchema = z.object({
// Fields for updatePostgrestConfig
dbSchema: z.array(z.string()),
dbExtraSearchPath: z.array(z.string()),
maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
@@ -51,11 +62,17 @@ const formSchema = z.object({
.max(1000, "Can't be more than 1000")
.optional()
.nullable(),
// Fields for expose toggles
tableIdsToAdd: z.array(z.number()),
tableIdsToRemove: z.array(z.number()),
})
export const PostgrestConfig = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
const [showModal, setShowModal] = useState(false)
@@ -63,6 +80,7 @@ export const PostgrestConfig = () => {
data: config,
isError,
isPending: isLoadingConfig,
isSuccess: isSuccessConfig,
} = useProjectPostgrestConfigQuery({ projectRef })
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
@@ -77,27 +95,36 @@ export const PostgrestConfig = () => {
connectionString: project?.connectionString,
})
const configDbSchemas = useMemo(
() => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
[config?.db_schema]
)
const isLoading = isLoadingConfig || isLoadingSchemas
const { mutate: updatePostgrestConfig, isPending: isUpdating } =
useProjectPostgrestConfigUpdateMutation({
onSuccess: () => {
toast.success('Successfully saved settings')
},
})
const schemas = useMemo(
() =>
allSchemas
.filter((x) => !INTERNAL_SCHEMAS.some((schema) => schema === x.name))
.map((x) => {
return {
id: x.id,
value: x.name,
name: x.name,
disabled: false,
}
}) ?? [],
[allSchemas]
)
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation()
const { mutateAsync: updateExposedTables } = useUpdateExposedTablesMutation()
const [isUpdating, setIsUpdating] = useState(false)
const formId = 'project-postgres-config'
const hiddenSchema = [
'auth',
'pgbouncer',
'hooks',
'extensions',
'vault',
'storage',
'realtime',
'pgsodium',
'pgsodium_masks',
]
const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } =
useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
@@ -105,17 +132,19 @@ export const PostgrestConfig = () => {
(extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null
const defaultValues = useMemo(() => {
const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : []
return {
dbSchema,
dbSchema: configDbSchemas,
maxRows: config?.max_rows,
// TODO: only display schemas that exist in the db
dbExtraSearchPath: (config?.db_extra_search_path ?? '')
.split(',')
.map((x) => x.trim())
.filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)),
.filter(Boolean),
dbPool: config?.db_pool,
tableIdsToAdd: [] as number[],
tableIdsToRemove: [] as number[],
}
}, [config, allSchemas])
}, [config, configDbSchemas])
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -123,43 +152,81 @@ export const PostgrestConfig = () => {
defaultValues,
})
const schemas =
allSchemas
.filter((x) => {
const find = indexOf(hiddenSchema, x.name)
if (find < 0) return x
})
.map((x) => {
return {
id: x.id,
value: x.name,
name: x.name,
disabled: false,
}
}) ?? []
const resetForm = useCallback(() => {
form.reset({ ...defaultValues })
}, [form, defaultValues])
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!projectRef) return console.error('Project ref is required') // is this needed ?
if (!projectRef) return console.error('Project ref is required')
updatePostgrestConfig({
projectRef,
dbSchema: values.dbSchema.join(', '),
maxRows: values.maxRows,
dbExtraSearchPath: values.dbExtraSearchPath.join(','),
dbPool: values.dbPool ? values.dbPool : null,
})
setIsUpdating(true)
try {
let dbSchema = values.dbSchema.join(',')
if (isApiGrantTogglesEnabled) {
await updateExposedTables({
projectRef,
connectionString: project?.connectionString,
tableIdsToAdd: values.tableIdsToAdd,
tableIdsToRemove: values.tableIdsToRemove,
})
}
await updatePostgrestConfig(
{
projectRef,
dbSchema,
maxRows: values.maxRows,
dbExtraSearchPath: values.dbExtraSearchPath.join(','),
dbPool: values.dbPool ? values.dbPool : null,
},
{ onError: noop }
)
await Promise.all([
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedTablesInfinite(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema),
}),
])
toast.success('Successfully saved settings')
form.reset({
dbSchema: dbSchema
.split(',')
.map((x) => x.trim())
.filter(Boolean),
maxRows: values.maxRows,
dbExtraSearchPath: values.dbExtraSearchPath,
dbPool: values.dbPool,
tableIdsToAdd: [],
tableIdsToRemove: [],
})
} catch (error) {
toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error')
} finally {
setIsUpdating(false)
}
}
const resetFormRef = useLatest(resetForm)
const isReady = isSuccessConfig && isSuccessSchemas
useEffect(() => {
if (config && isSuccessSchemas) {
resetForm()
if (isReady) {
resetFormRef.current()
}
}, [config, isSuccessSchemas, resetForm])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady])
const watchedDbSchema = useWatch_Shadcn_({ control: form.control, name: 'dbSchema' })
const watchedTableIdsToAdd = useWatch_Shadcn_({ control: form.control, name: 'tableIdsToAdd' })
const watchedTableIdsToRemove = useWatch_Shadcn_({
control: form.control,
name: 'tableIdsToRemove',
})
return (
<PageSection id="postgrest-config" className="first:pt-0">
<PageSectionContent>
@@ -176,88 +243,170 @@ export const PostgrestConfig = () => {
</CardContent>
) : (
<>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="dbSchema"
render={({ field }) => (
<FormItem_Shadcn_>
<FormItemLayout
label="Exposed schemas"
description="The schemas to expose in your API. Tables, views and stored procedures in
these schemas will get API endpoints."
layout="flex-row-reverse"
>
{isLoadingSchemas ? (
<div className="col-span-12 flex flex-col gap-2 lg:col-span-7">
<Skeleton className="w-full h-[38px]" />
</div>
) : (
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
disabled={!canUpdatePostgrestConfig}
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select schemas..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
/>
<MultiSelectorContent>
<MultiSelectorList>
{schemas.length <= 0 ? (
<MultiSelectorItem key="empty" value="no">
no
</MultiSelectorItem>
) : (
schemas.map((x) => (
<MultiSelectorItem key={x.id + '-' + x.name} value={x.name}>
{x.name}
</MultiSelectorItem>
))
)}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)}
</FormItemLayout>
{!field.value.includes('public') && field.value.length > 0 && (
<Admonition
type="default"
title="The public schema for this project is not exposed"
className="mt-2"
description={
<>
<p className="text-sm">
You will not be able to query tables and views in the{' '}
<code className="text-code-inline">public</code> schema via
supabase-js or HTTP clients.
</p>
{isGraphqlExtensionEnabled && (
<>
<p className="text-sm">
Tables in the{' '}
<code className="text-code-inline">public</code> schema are
still exposed over our GraphQL endpoints.
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${projectRef}/database/extensions`}>
Disable the pg_graphql extension
</Link>
</Button>
</>
)}
</>
}
/>
)}
</FormItem_Shadcn_>
{isApiGrantTogglesEnabled ? (
<CardContent className="space-y-6">
<FormItemLayout
isReactForm={false}
layout="flex-row-reverse"
label="Exposed schemas"
description="Select schemas to include in the Data API. Schemas must be included before tables can be exposed."
>
<ExposedSchemaSelector
selectedSchemas={watchedDbSchema}
disabled={!canUpdatePostgrestConfig}
onToggleSchema={(schema) => {
const current = form.getValues('dbSchema')
if (current.includes(schema)) {
form.setValue(
'dbSchema',
current.filter((x) => x !== schema),
{ shouldDirty: true }
)
} else {
form.setValue('dbSchema', [...current, schema], {
shouldDirty: true,
})
}
}}
/>
</FormItemLayout>
<FormItemLayout
isReactForm={false}
layout="flex-row-reverse"
label="Exposed tables"
description="Toggle Data API access for individual tables."
>
<ExposedTableSelector
selectedSchemas={watchedDbSchema}
pendingAddTableIds={watchedTableIdsToAdd}
pendingRemoveTableIds={watchedTableIdsToRemove}
onTogglePendingAdd={(tableId) => {
const current = form.getValues('tableIdsToAdd')
if (current.includes(tableId)) {
form.setValue(
'tableIdsToAdd',
current.filter((x) => x !== tableId),
{ shouldDirty: true }
)
} else {
form.setValue('tableIdsToAdd', [...current, tableId], {
shouldDirty: true,
})
}
}}
onTogglePendingRemove={(tableId) => {
const current = form.getValues('tableIdsToRemove')
if (current.includes(tableId)) {
form.setValue(
'tableIdsToRemove',
current.filter((x) => x !== tableId),
{ shouldDirty: true }
)
} else {
form.setValue('tableIdsToRemove', [...current, tableId], {
shouldDirty: true,
})
}
}}
/>
</FormItemLayout>
{watchedDbSchema.length === 0 && (
<Admonition
type="warning"
title="No schema is currently selected"
description="Saving with no selected schema or table will disable the Data API."
/>
)}
/>
</CardContent>
</CardContent>
) : (
<CardContent>
<FormField_Shadcn_
control={form.control}
name="dbSchema"
render={({ field }) => (
<FormItem_Shadcn_>
<FormItemLayout
label="Exposed schemas"
description="The schemas to expose in your API. Tables, views and stored procedures in
these schemas will get API endpoints."
layout="flex-row-reverse"
>
{isLoadingSchemas ? (
<div className="col-span-12 flex flex-col gap-2 lg:col-span-7">
<Skeleton className="w-full h-[38px]" />
</div>
) : (
<MultiSelector
onValuesChange={field.onChange}
values={field.value}
size="small"
disabled={!canUpdatePostgrestConfig}
>
<MultiSelectorTrigger
mode="inline-combobox"
label="Select schemas..."
badgeLimit="wrap"
showIcon={false}
deletableBadge
/>
<MultiSelectorContent>
<MultiSelectorList>
{schemas.length <= 0 ? (
<MultiSelectorItem key="empty" value="no">
no
</MultiSelectorItem>
) : (
schemas.map((x) => (
<MultiSelectorItem
key={x.id + '-' + x.name}
value={x.name}
>
{x.name}
</MultiSelectorItem>
))
)}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)}
</FormItemLayout>
{!field.value.includes('public') && field.value.length > 0 && (
<Admonition
type="default"
title="The public schema for this project is not exposed"
className="mt-2"
description={
<>
<p className="text-sm">
You will not be able to query tables and views in the{' '}
<code className="text-code-inline">public</code> schema via
supabase-js or HTTP clients.
</p>
{isGraphqlExtensionEnabled && (
<>
<p className="text-sm">
Tables in the{' '}
<code className="text-code-inline">public</code> schema
are still exposed over our GraphQL endpoints.
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${projectRef}/database/extensions`}>
Disable the pg_graphql extension
</Link>
</Button>
</>
)}
</>
}
/>
)}
</FormItem_Shadcn_>
)}
/>
</CardContent>
)}
<CardContent>
<FormField_Shadcn_
control={form.control}
@@ -3,7 +3,7 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useIsSchemaExposed } from 'hooks/misc/useIsSchemaExposed'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Settings } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import {
useEffect,
@@ -15,39 +15,22 @@ import {
type SetStateAction,
} from 'react'
import { usePreviousDistinct } from 'react-use'
import { Button, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Switch } from 'ui'
import { Button, Switch } from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
import {
API_ACCESS_ROLES,
API_PRIVILEGE_TYPES,
checkDataApiPrivilegesNonEmpty,
DEFAULT_DATA_API_PRIVILEGES,
EMPTY_DATA_API_PRIVILEGES,
isApiPrivilegeType,
type ApiAccessRole,
type ApiPrivilegesByRole,
} from '@/lib/data-api-types'
import type { DeepReadonly, Prettify } from '@/lib/type-helpers'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
const ROLE_LABELS: Record<ApiAccessRole, string> = {
anon: 'Anonymous (anon)',
authenticated: 'Authenticated',
}
namespace ApiAccessToggleProps {
type New = {
type: 'new'
@@ -255,10 +238,11 @@ export const useTableApiAccessHandlerWithHistory = (
}
}
type ApiAccessToggleProps = {
type ApiAccessToggleComponentProps = {
projectRef?: string
schemaName?: string
tableName?: string
isNewRecord: boolean
handler: TableApiAccessHandlerWithHistoryReturn
}
@@ -266,10 +250,9 @@ export const ApiAccessToggle = ({
projectRef,
schemaName,
tableName,
isNewRecord,
handler,
}: ApiAccessToggleProps): ReactNode => {
const [isPrivilegesPopoverOpen, setIsPrivilegesPopoverOpen] = useState(false)
}: ApiAccessToggleComponentProps): ReactNode => {
const isPending = handler.isPending
const isError = handler.isError
const isSchemaExposed = handler.data?.schemaExposed
@@ -290,104 +273,33 @@ export const ApiAccessToggle = ({
}
}
const handlePrivilegesChange = (role: ApiAccessRole) => (values: string[]) => {
if (!handler.isSuccess) return
if (!isSchemaExposed) return
if (!privileges) return
handler.data?.setPrivileges((oldPrivileges) => {
return {
...oldPrivileges,
[role]: values.filter(isApiPrivilegeType),
}
})
}
const totalAvailablePrivileges = API_ACCESS_ROLES.length * API_PRIVILEGE_TYPES.length
const totalSelectedPrivileges = Object.values(privileges ?? {}).reduce(
(sum, rolePrivileges) => sum + rolePrivileges.length,
0
)
const hasPartialPrivileges =
totalSelectedPrivileges > 0 && totalSelectedPrivileges < totalAvailablePrivileges
return (
<div className="space-y-3">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-sm text-foreground flex items-center gap-1.5">
Data API Access
<InfoTooltip side="top" className="max-w-80">
This controls which operations the <code className="text-code-inline">anon</code>{' '}
and <code className="text-code-inline whitespace-nowrap">authenticated</code> roles
can perform on this table via the Data API. Unselected privileges are revoked from
these roles.
</InfoTooltip>
</p>
<div>
<h5>Data API Access</h5>
<p className="text-sm text-foreground-lighter">
Allow this table to be queried via Supabase client libraries or the API directly
</p>
</div>
<div className="flex items-center gap-2">
<Popover_Shadcn_
open={isPrivilegesPopoverOpen}
onOpenChange={setIsPrivilegesPopoverOpen}
>
<PopoverTrigger_Shadcn_ asChild disabled={isDisabled || !hasNonEmptyPrivileges}>
<Button
type="text"
className="w-6 h-6 p-0 text-foreground-light"
aria-label="Configure API privileges"
>
<Settings strokeWidth={1.5} size={16} />
{hasPartialPrivileges && (
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-foreground shadow-sm" />
)}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="w-[420px] space-y-3">
{!isDisabled && (
<>
<p className="text-sm text-foreground">Adjust API privileges per role</p>
<div className="space-y-2">
{API_ACCESS_ROLES.map((role) => (
<div key={role} className="space-y-2">
<p className="text-sm text-foreground flex items-center gap-1.5">
{ROLE_LABELS[role]}
</p>
<MultiSelector
values={(privileges?.[role] as string[]) ?? []}
onValuesChange={handlePrivilegesChange(role)}
>
<MultiSelectorTrigger
label="Select privileges"
badgeLimit={4}
deletableBadge={true}
/>
<MultiSelectorContent>
<MultiSelectorList>
{API_PRIVILEGE_TYPES.map((privilege) => (
<MultiSelectorItem key={privilege} value={privilege}>
{privilege}
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</div>
))}
</div>
</>
)}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
{isNewRecord ? (
<Switch
checked={hasNonEmptyPrivileges}
onCheckedChange={handleMasterToggle}
disabled={isDisabled}
/>
</div>
) : (
<Button asChild type="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopener noreferrer"
href={`/project/${projectRef}/integrations/data_api/settings`}
>
Manage access
</Link>
</Button>
)}
</div>
</div>
@@ -398,7 +310,6 @@ export const ApiAccessToggle = ({
isPending={isPending}
isError={isError}
isSchemaExposed={isSchemaExposed}
hasNonEmptyPrivileges={!!privileges ? hasNonEmptyPrivileges : undefined}
/>
</div>
)
@@ -411,7 +322,6 @@ const SchemaExposureOptions = ({
isPending,
isError,
isSchemaExposed,
hasNonEmptyPrivileges,
}: {
projectRef?: string
schemaName?: string
@@ -419,7 +329,6 @@ const SchemaExposureOptions = ({
isPending: boolean
isError: boolean
isSchemaExposed?: boolean
hasNonEmptyPrivileges?: boolean
}): ReactNode => {
const { selectedDatabaseId } = useDatabaseSelectorStateSnapshot()
@@ -459,7 +368,7 @@ const SchemaExposureOptions = ({
<Admonition type="warning" title="An error occurred while fetching Data API settings." />
)}
{isSchemaExposed && apiUrl && hasNonEmptyPrivileges && (
{isSchemaExposed && apiUrl && (
<Input
copy
readOnly
@@ -560,6 +560,7 @@ export const TableEditor = ({
tableName={
isNewRecord || isDuplicating ? tableFields.name : tableFields.name || table?.name
}
isNewRecord={isNewRecord || isDuplicating}
handler={apiAccessToggleHandler}
/>
)}
+10 -9
View File
@@ -1,25 +1,24 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
import { useState } from 'react'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
import { useState } from 'react'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
Command_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
ScrollArea,
Skeleton,
} from 'ui'
@@ -29,7 +28,8 @@ interface SchemaSelectorProps {
disabled?: boolean
size?: 'tiny' | 'small'
showError?: boolean
selectedSchemaName: string
selectedSchemaName?: string
placeholderLabel?: string
supportSelectAll?: boolean
excludedSchemas?: string[]
onSelectSchema: (name: string) => void
@@ -43,6 +43,7 @@ export const SchemaSelector = ({
size = 'tiny',
showError = true,
selectedSchemaName,
placeholderLabel = 'Choose a schema...',
supportSelectAll = false,
excludedSchemas = [],
onSelectSchema,
@@ -122,7 +123,7 @@ export const SchemaSelector = ({
</div>
) : (
<div className="w-full flex gap-1">
<p className="text-foreground-lighter">Choose a schema...</p>
<p className="text-foreground-lighter">{placeholderLabel}</p>
</div>
)}
</Button>
@@ -158,7 +159,7 @@ export const SchemaSelector = ({
)}
</CommandItem_Shadcn_>
)}
{schemas?.map((schema) => (
{schemas.map((schema) => (
<CommandItem_Shadcn_
key={schema.id}
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
@@ -0,0 +1,62 @@
import { queryOptions } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
import { privilegeKeys } from './keys'
import { getExposedTableCountsSql } from './privileges.sql'
export type ExposedTableCountsVariables = {
projectRef?: string
connectionString?: string | null
selectedSchemas: string[]
}
export type ExposedTableCountsResponse = {
total_count: number
grants_count: number
}
export async function getExposedTableCounts(
{ projectRef, connectionString, selectedSchemas }: ExposedTableCountsVariables,
signal?: AbortSignal
): Promise<ExposedTableCountsResponse> {
if (!projectRef) throw new Error('projectRef is required')
if (!selectedSchemas) throw new Error('selectedSchemas is required')
const sql = getExposedTableCountsSql({ selectedSchemas })
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['exposed-table-counts', selectedSchemas],
},
signal
)
return result[0] as ExposedTableCountsResponse
}
export type ExposedTableCountsData = Awaited<ReturnType<typeof getExposedTableCounts>>
export type ExposedTableCountsError = ResponseError
export const exposedTableCountsQueryOptions = (
{ projectRef, connectionString, selectedSchemas }: ExposedTableCountsVariables,
{ enabled = true }: { enabled?: boolean } = {}
) => {
return queryOptions({
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
queryKey: privilegeKeys.exposedTableCounts(projectRef, selectedSchemas),
queryFn: ({ signal }) =>
getExposedTableCounts(
{
projectRef,
connectionString,
selectedSchemas,
},
signal
),
enabled: enabled && typeof projectRef !== 'undefined',
})
}
@@ -0,0 +1,91 @@
import { infiniteQueryOptions } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
import { privilegeKeys } from './keys'
import { getExposedTablesSql } from './privileges.sql'
export const EXPOSED_TABLES_PAGE_LIMIT = 50
export type ExposedTablesVariables = {
projectRef?: string
connectionString?: string | null
search?: string
}
export type ExposedTable = {
id: number
schema: string
name: string
status: 'granted' | 'revoked' | 'custom'
}
export type ExposedTablesResponse = {
total_count: number
tables: ExposedTable[]
}
export async function getExposedTables(
{
projectRef,
connectionString,
search,
page = 0,
limit = EXPOSED_TABLES_PAGE_LIMIT,
}: ExposedTablesVariables & { page?: number; limit?: number },
signal?: AbortSignal
): Promise<ExposedTablesResponse> {
if (!projectRef) throw new Error('projectRef is required')
const offset = page * limit
const sql = getExposedTablesSql({ search, offset, limit })
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['exposed-tables', page],
},
signal
)
return result[0] as ExposedTablesResponse
}
export type ExposedTablesData = Awaited<ReturnType<typeof getExposedTables>>
export type ExposedTablesError = ResponseError
export const exposedTablesInfiniteQueryOptions = (
{ projectRef, connectionString, search }: ExposedTablesVariables,
{ enabled = true }: { enabled?: boolean } = {}
) => {
return infiniteQueryOptions({
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
queryKey: privilegeKeys.exposedTablesInfinite(projectRef, search),
queryFn: ({ signal, pageParam }) =>
getExposedTables(
{
projectRef,
connectionString,
search,
page: pageParam,
},
signal
),
enabled: enabled && typeof projectRef !== 'undefined',
initialPageParam: 0,
getNextPageParam(lastPage, pages) {
const page = pages.length
const currentTotalCount = page * EXPOSED_TABLES_PAGE_LIMIT
const totalCount = lastPage.total_count ?? 0
if (currentTotalCount >= totalCount) {
return undefined
}
return page
},
})
}
+10
View File
@@ -3,4 +3,14 @@ export const privilegeKeys = {
['projects', projectRef, 'database', 'table-privileges'] as const,
columnPrivilegesList: (projectRef: string | undefined) =>
['projects', projectRef, 'database', 'column-privileges'] as const,
exposedTablesInfinite: (projectRef: string | undefined, search?: string) =>
[
'projects',
projectRef,
'privileges',
'exposed-tables-infinite',
...(search ? ([{ search }] as const) : []),
] as const,
exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const,
}
@@ -0,0 +1,162 @@
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
export const IGNORED_SCHEMAS = [...INTERNAL_SCHEMAS, 'pg_catalog']
const IGNORED_SCHEMAS_LIST = IGNORED_SCHEMAS.map((s) => `'${s}'`).join(', ')
/**
* Builds the shared `table_privileges` and `table_grants` CTEs used by
* both the exposed-tables list query and the counts-only query.
*
* Returns SQL text meant to follow `WITH` (no leading `WITH` keyword).
* Callers that append additional CTEs should add a comma after interpolation.
*/
function getTableGrantsCTEs({ search }: { search?: string } = {}) {
return /* SQL */ `
table_privileges as (
select
c.oid::int as id,
n.nspname as schema_name,
c.relname as name,
c.relkind as kind,
-- Anon Privileges
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'SELECT') as anon_select,
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'INSERT') as anon_insert,
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'UPDATE') as anon_update,
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'DELETE') as anon_delete,
-- Authenticated Privileges
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'SELECT') as auth_select,
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'INSERT') as auth_insert,
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'UPDATE') as auth_update,
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'DELETE') as auth_delete,
-- Service Role Privileges
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'SELECT') as srv_select,
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'INSERT') as srv_insert,
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'UPDATE') as srv_update,
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'DELETE') as srv_delete
from pg_class c
join pg_namespace n
on n.oid = c.relnamespace
left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl
on true
left join pg_roles pr
on pr.oid = acl.grantee
where c.relkind in ('r', 'p', 'v', 'm', 'f')
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
${search ? `and (n.nspname || '.' || c.relname) ilike '%${search}%'` : ''}
group by c.oid, n.nspname, c.relname, c.relkind
),
table_grants as (
select
id,
schema_name,
name,
kind,
case
-- 1. Strict Granted: All 3 roles possess ALL 4 privileges
when (
anon_select and anon_insert and anon_update and anon_delete and
auth_select and auth_insert and auth_update and auth_delete and
srv_select and srv_insert and srv_update and srv_delete
) then 'granted'
-- 2. Strict Revoked: NO role possesses ANY privilege
when not (
anon_select or anon_insert or anon_update or anon_delete or
auth_select or auth_insert or auth_update or auth_delete or
srv_select or srv_insert or srv_update or srv_delete
) then 'revoked'
-- 3. Custom: Anything in between
else 'custom'
end as status
from table_privileges
)
`
}
export function getExposedTablesSql({
search,
offset,
limit,
}: {
search?: string
offset: number
limit: number
}) {
return /* SQL */ `
with ${getTableGrantsCTEs({ search })}
select
(select count(*)::int from table_grants) as total_count,
coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', tg.id,
'schema', tg.schema_name,
'name', tg.name,
'status', tg.status
)
)
from (
select *
from table_grants
order by schema_name, name
offset ${offset}
limit ${limit}
) tg
),
'[]'::jsonb
) as tables;
`
}
export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) {
const schemasList =
selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''"
return /* SQL */ `
with ${getTableGrantsCTEs()}
select
count(*)::int as total_count,
(count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
from table_grants
`
}
export function getExposedSchemasSql() {
return /* SQL */ `
select coalesce(
(
select jsonb_agg(distinct schema_name order by schema_name)
from (
select n.nspname as schema_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl on true
where c.relkind in ('r', 'p', 'v', 'm', 'f')
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
group by c.oid, n.nspname
having
bool_or(
pg_catalog.pg_get_userbyid(acl.grantee) = 'anon'
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
)
and bool_or(
pg_catalog.pg_get_userbyid(acl.grantee) = 'authenticated'
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
)
and bool_or(
pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role'
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
)
) t
),
'[]'::jsonb
) as schemas;
`
}
@@ -0,0 +1,88 @@
import { useMutation } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import { toast } from 'sonner'
import type { UseCustomMutationOptions } from 'types'
import type { ConnectionVars } from '../common.types'
import { getExposedSchemasSql } from './privileges.sql'
export type UpdateExposedTablesVariables = ConnectionVars & {
tableIdsToAdd: number[]
tableIdsToRemove: number[]
}
const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
if (oids.length === 0) return ''
const privilegeClause =
action === 'grant'
? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role'
: 'revoke all on table %I.%I from anon, authenticated, service_role'
return /* SQL */ `
do $$
declare
relname name;
nspname name;
begin
for nspname, relname in
select n.nspname, c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.oid in (${oids.join(', ')})
loop
execute format('${privilegeClause}', relname, nspname);
end loop;
end $$;
`
}
export async function updateExposedTables({
projectRef,
connectionString,
tableIdsToAdd,
tableIdsToRemove,
}: UpdateExposedTablesVariables): Promise<string[]> {
if (!projectRef) throw new Error('projectRef is required')
const sqlParts: string[] = []
if (tableIdsToAdd.length > 0) {
sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant'))
}
if (tableIdsToRemove.length > 0) {
sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke'))
}
sqlParts.push(getExposedSchemasSql())
const { result } = await executeSql({
projectRef,
connectionString,
sql: sqlParts.join('\n'),
queryKey: ['update-exposed-tables'],
})
return (result[0] as { schemas: string[] }).schemas
}
type UpdateExposedTablesData = Awaited<ReturnType<typeof updateExposedTables>>
export const useUpdateExposedTablesMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<UpdateExposedTablesData, Error, UpdateExposedTablesVariables>,
'mutationFn'
> = {}) => {
return useMutation<UpdateExposedTablesData, Error, UpdateExposedTablesVariables>({
mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars),
onError(error: Error) {
toast.error(`Failed to update table access: ${error.message}`)
},
...(onError ? { onError } : {}),
...options,
})
}
+30 -165
View File
@@ -58,11 +58,10 @@ async function verifyTablePrivileges(
/**
* Locates the API access toggle switch for Data API Access.
* The switch is labeled by the nearby "Data API Access" text.
* Only present when creating or duplicating a table (not when editing).
*/
function getApiAccessToggle(page: Page) {
function getApiAccessSwitch(page: Page) {
const sidePanel = page.getByTestId('table-editor-side-panel')
// The switch is near the "Data API Access" label - get the section first, then find the switch
const dataApiSection = sidePanel
.locator('div')
.filter({ hasText: 'Data API Access' })
@@ -71,23 +70,12 @@ function getApiAccessToggle(page: Page) {
}
/**
* Locates the settings button for granular privilege settings.
* Locates the "Manage access" link shown when editing an existing table.
* Links out to the API settings page.
*/
function getPrivilegeSettingsButton(page: Page) {
function getManageAccessLink(page: Page) {
const sidePanel = page.getByTestId('table-editor-side-panel')
return sidePanel.getByRole('button', { name: 'Configure API privileges' })
}
/**
* Gets the privilege selector combobox for a specific role in the privileges popover.
* The popover must already be open.
*/
function getRolePrivilegeSelector(page: Page, roleLabel: 'Anonymous (anon)' | 'Authenticated') {
// The popover is a dialog with structure: paragraph (role label) followed by combobox
// We find the paragraph with the role text, then get the adjacent combobox
const popoverContent = page.locator('[data-radix-popper-content-wrapper]')
// Get the paragraph containing the role label, then navigate to the sibling combobox
return popoverContent.getByText(roleLabel, { exact: true }).locator('..').getByRole('combobox')
return sidePanel.getByRole('link', { name: 'Manage access' })
}
test.describe('API Access Toggle', () => {
@@ -107,8 +95,8 @@ test.describe('API Access Toggle', () => {
// Fill in table name
await page.getByTestId('table-name-input').fill(tableName)
// Find and click the API access toggle to turn it off
const toggle = getApiAccessToggle(page)
// Verify the toggle is checked by default
const toggle = getApiAccessSwitch(page)
await expect(toggle).toBeChecked()
// Create the table
@@ -155,8 +143,8 @@ test.describe('API Access Toggle', () => {
// Fill in table name
await page.getByTestId('table-name-input').fill(tableName)
// Find and click the API access toggle to turn it off
const toggle = getApiAccessToggle(page)
// Toggle API access off
const toggle = getApiAccessSwitch(page)
await expect(toggle).toBeChecked()
await toggle.click()
await expect(toggle, 'Toggle should be unchecked after clicking').not.toBeChecked()
@@ -195,7 +183,7 @@ test.describe('API Access Toggle', () => {
})
})
test('shows API access toggle when editing an existing table', async ({ page, ref }) => {
test('shows Manage access link when editing an existing table', async ({ page, ref }) => {
const tableName = `${TABLE_NAME_PREFIX}_edit`
// Create a table first
@@ -207,138 +195,40 @@ test.describe('API Access Toggle', () => {
await page.getByRole('button', { name: 'Save' }).click()
await createPromise
// Wait for success toast which indicates all operations are complete
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should appear after table creation'
).toBeVisible({ timeout: 15000 })
// Dismiss toast to prevent it from blocking subsequent interactions
await dismissToastsIfAny(page)
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
// Verify table was created
await expect(
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
'Table should be visible after creation'
).toBeVisible()
// Verify default full privileges were granted
await verifyTablePrivileges('public', tableName, {
anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
})
// Navigate back to table editor
let loadPromise = waitForTableToLoad(page, ref)
// Navigate back and open the edit panel
const loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
// Click on the table to view it
const navigationPromise = page.waitForURL(/\/editor\/\d+\?schema=public$/)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await navigationPromise
// Open edit table dialog via context menu
await openTableContextMenu(page, tableName)
await page.getByRole('menuitem', { name: 'Edit table' }).click()
// Verify the side panel is open
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
// Verify Data API Access section is visible
// Data API Access section is visible
await expect(
page.getByText('Data API Access'),
'Data API Access label should be visible in edit mode'
).toBeVisible()
// Verify the toggle is present
const toggle = getApiAccessToggle(page)
await expect(toggle, 'API Access toggle should be visible in edit mode').toBeVisible()
})
test('creates table with partial privileges and verifies correct grants', async ({
page,
ref,
}) => {
const tableName = `${TABLE_NAME_PREFIX}_partial_grants`
// Open new table dialog
await page.getByRole('button', { name: 'New table', exact: true }).click()
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
// Fill in table name
await page.getByTestId('table-name-input').fill(tableName)
// Open the privilege settings popover
const settingsButton = getPrivilegeSettingsButton(page)
await settingsButton.click()
await expect(page.getByText('Adjust API privileges per role')).toBeVisible()
// Modify anon privileges - leave only SELECT
const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)')
await anonSelector.click()
// Click DELETE to toggle it off
await page.getByRole('option', { name: 'DELETE' }).click()
// Click UPDATE to toggle it off
await page.getByRole('option', { name: 'UPDATE' }).click()
await page.getByRole('option', { name: 'INSERT' }).click()
// Close the dropdown by clicking the combobox again
await anonSelector.click()
// Wait for dropdown to close
await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
// Modify authenticated privileges - remove DELETE and UPDATE (leave SELECT + INSERT)
const authSelector = getRolePrivilegeSelector(page, 'Authenticated')
await authSelector.click()
// Remove all except SELECT
await page.getByRole('option', { name: 'DELETE' }).click()
await page.getByRole('option', { name: 'UPDATE' }).click()
// Close the dropdown by clicking the combobox again
await authSelector.click()
// Wait for dropdown to close
await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
// Close the popover by pressing Escape
await page.keyboard.press('Escape')
// Create the table
const createTablePromise = createApiResponseWaiter(
page,
'pg-meta',
ref,
'query?key=table-create'
)
await page.getByRole('button', { name: 'Save' }).click()
await createTablePromise
// Wait for success toast which indicates all operations (including privilege updates) are complete
// In edit mode the panel shows a "Manage access" link instead of a toggle switch
const manageAccessLink = getManageAccessLink(page)
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should appear after table creation'
).toBeVisible({ timeout: 15000 })
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
// Verify table was created
await expect(
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
'Table should be visible after creation'
manageAccessLink,
'Manage access link should be visible in edit mode'
).toBeVisible()
// Verify partial grants - anon: SELECT; authenticated: SELECT, INSERT
await verifyTablePrivileges('public', tableName, {
anon: ['SELECT'],
authenticated: ['SELECT', 'INSERT'],
})
})
test('preserves API grants when editing non-privilege table properties', async ({
@@ -347,78 +237,54 @@ test.describe('API Access Toggle', () => {
}) => {
const tableName = `${TABLE_NAME_PREFIX}_preserve_grants`
// Step 1: Create a table with partial privileges (only SELECT and INSERT for anon)
// Step 1: Create a table with API access on (default — full grants)
await page.getByRole('button', { name: 'New table', exact: true }).click()
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
await page.getByTestId('table-name-input').fill(tableName)
// Open privilege settings and set partial privileges
const settingsButton = getPrivilegeSettingsButton(page)
await settingsButton.click()
await expect(page.getByText('Adjust API privileges per role')).toBeVisible()
// Verify toggle is on by default
const toggle = getApiAccessSwitch(page)
await expect(toggle).toBeChecked()
// Modify anon privileges - keep only SELECT and INSERT
const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)')
await anonSelector.click()
await page.getByRole('option', { name: 'DELETE' }).click()
await page.getByRole('option', { name: 'UPDATE' }).click()
await anonSelector.click()
await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
// Keep authenticated with full privileges
await page.keyboard.press('Escape') // Close popover
// Create the table
let createPromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-create')
await page.getByRole('button', { name: 'Save' }).click()
await createPromise
// Wait for success toast which indicates all operations (including privilege updates) are complete
await expect(
page.getByText(`Table ${tableName} is good to go!`),
'Success toast should appear after table creation'
).toBeVisible({ timeout: 15000 })
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
await expect(
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
'Table should be created'
).toBeVisible()
// Verify initial privileges before edit
// Verify full privileges were granted
await verifyTablePrivileges('public', tableName, {
anon: ['SELECT', 'INSERT'],
anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
})
// Navigate back to table editor
// Step 2: Navigate back and edit only the description
let loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
// Step 2: Edit the table's description (without touching privileges)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await openTableContextMenu(page, tableName)
await page.getByRole('menuitem', { name: 'Edit table' }).click()
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
// Add a description without modifying privileges
const descriptionInput = page
.getByTestId('table-editor-side-panel')
.getByPlaceholder('Optional')
await descriptionInput.fill('Test description for grant preservation')
// Save the changes
const updatePromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-update')
await page.getByRole('button', { name: 'Save' }).click()
await updatePromise
// Wait for success toast which indicates all operations are complete
await expect(
page.getByText(`Successfully updated ${tableName}!`),
'Success toast should appear after table update'
@@ -426,18 +292,17 @@ test.describe('API Access Toggle', () => {
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
// Step 3: Verify the privileges remain unchanged after edit
// Step 3: Verify the full privileges are unchanged after the description edit
await verifyTablePrivileges('public', tableName, {
anon: ['SELECT', 'INSERT'],
anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
})
// Navigate back to table editor for cleanup
// Clean up
loadPromise = waitForTableToLoad(page, ref)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await loadPromise
// Clean up
await deleteTable(page, ref, tableName)
})
})