From 5894e37b40080a7788e12725e5bdb91f1fe6afc2 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 6 Mar 2026 21:51:37 +0900 Subject: [PATCH] =?UTF-8?q?[FE-2158]=20=E2=80=93=20feat(studio):=20Add=20e?= =?UTF-8?q?xposed=20tables=20config=20to=20Postgrest=20settings=20(#43280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a feature flagged exposed tables config to postgrest settings: Screenshot 2026-03-02 at 17 04 13 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> --- .../Settings/API/ExposedSchemaSelector.tsx | 143 ++++++ .../Settings/API/ExposedTableSelector.tsx | 305 +++++++++++++ .../Settings/API/PostgrestConfig.tsx | 407 ++++++++++++------ .../TableEditor/ApiAccessToggle.tsx | 133 +----- .../TableEditor/TableEditor.tsx | 1 + apps/studio/components/ui/SchemaSelector.tsx | 19 +- .../privileges/exposed-table-counts-query.ts | 62 +++ .../exposed-tables-infinite-query.ts | 91 ++++ apps/studio/data/privileges/keys.ts | 10 + apps/studio/data/privileges/privileges.sql.ts | 162 +++++++ .../update-exposed-tables-mutation.ts | 88 ++++ e2e/studio/features/api-access-toggle.spec.ts | 195 ++------- 12 files changed, 1201 insertions(+), 415 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx create mode 100644 apps/studio/data/privileges/exposed-table-counts-query.ts create mode 100644 apps/studio/data/privileges/exposed-tables-infinite-query.ts create mode 100644 apps/studio/data/privileges/privileges.sql.ts create mode 100644 apps/studio/data/privileges/update-exposed-tables-mutation.ts diff --git a/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx new file mode 100644 index 00000000000..5b990eb7dc2 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx @@ -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 ( + + + + + + + + + + {isPending ? ( + <> +
+ +
+
+ +
+ + ) : isError ? ( +
+

Failed to retrieve schemas

+
+ ) : ( + <> + +

+ No schemas found +

+
+ 7 ? 'h-[210px]' : ''}> + {schemas.map((schema) => { + const isExposed = selectedSet.has(schema.name) + + return ( + { + onToggleSchema(schema.name) + }} + > +
+ {isExposed && } + {schema.name} +
+
+ ) + })} +
+ + )} +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx new file mode 100644 index 00000000000..18371b96ad7 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx @@ -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(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 ( +
+ + + + + + + + + + {isPending ? ( + <> +
+ +
+
+ +
+ + ) : isError ? ( +
+

Failed to retrieve tables

+
+ ) : ( + <> + {search.length > 0 && tables.length === 0 && ( +

+ No tables found +

+ )} + 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 ( + { + 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) + } + }} + > +
+
+ {isExposed && } +
+ + {`${table.schema}.${table.name}`} + + +
+ {isCustomTable && ( + + +
+ +
+
+ + {customGrantsTooltip} + +
+ )} + {!isSchemaExposed && ( + + + + + + {`The schema "${table.schema}" must be exposed before enabling this table.`} + + + )} +
+
+
+ ) + })} +
+ {hasNextPage && ( +
+ +
+ )} + + + )} + + + + + +
+ ) +} + +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.' +} diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index 5a1da6e6309..24df32ed986 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -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>({ 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) => { - 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 ( @@ -176,88 +243,170 @@ export const PostgrestConfig = () => { ) : ( <> - - ( - - - {isLoadingSchemas ? ( -
- -
- ) : ( - - - - - {schemas.length <= 0 ? ( - - no - - ) : ( - schemas.map((x) => ( - - {x.name} - - )) - )} - - - - )} -
- {!field.value.includes('public') && field.value.length > 0 && ( - -

- You will not be able to query tables and views in the{' '} - public schema via - supabase-js or HTTP clients. -

- {isGraphqlExtensionEnabled && ( - <> -

- Tables in the{' '} - public schema are - still exposed over our GraphQL endpoints. -

- - - )} - - } - /> - )} -
+ {isApiGrantTogglesEnabled ? ( + + + { + 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, + }) + } + }} + /> + + + + { + 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, + }) + } + }} + /> + + + {watchedDbSchema.length === 0 && ( + )} - /> - +
+ ) : ( + + ( + + + {isLoadingSchemas ? ( +
+ +
+ ) : ( + + + + + {schemas.length <= 0 ? ( + + no + + ) : ( + schemas.map((x) => ( + + {x.name} + + )) + )} + + + + )} +
+ {!field.value.includes('public') && field.value.length > 0 && ( + +

+ You will not be able to query tables and views in the{' '} + public schema via + supabase-js or HTTP clients. +

+ {isGraphqlExtensionEnabled && ( + <> +

+ Tables in the{' '} + public schema + are still exposed over our GraphQL endpoints. +

+ + + )} + + } + /> + )} +
+ )} + /> +
+ )} = { - 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 (
-
-

- Data API Access - - This controls which operations the anon{' '} - and authenticated roles - can perform on this table via the Data API. Unselected privileges are revoked from - these roles. - -

+
+
Data API Access

Allow this table to be queried via Supabase client libraries or the API directly

-
- - - - - - {!isDisabled && ( - <> -

Adjust API privileges per role

-
- {API_ACCESS_ROLES.map((role) => ( -
-

- {ROLE_LABELS[role]} -

- - - - - {API_PRIVILEGE_TYPES.map((privilege) => ( - - {privilege} - - ))} - - - -
- ))} -
- - )} -
-
+ {isNewRecord ? ( -
+ ) : ( + + )}
@@ -398,7 +310,6 @@ export const ApiAccessToggle = ({ isPending={isPending} isError={isError} isSchemaExposed={isSchemaExposed} - hasNonEmptyPrivileges={!!privileges ? hasNonEmptyPrivileges : undefined} />
) @@ -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 = ({ )} - {isSchemaExposed && apiUrl && hasNonEmptyPrivileges && ( + {isSchemaExposed && apiUrl && ( )} diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index d6871bc5b85..0859b4f5dc8 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -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 = ({
) : (
-

Choose a schema...

+

{placeholderLabel}

)} @@ -158,7 +159,7 @@ export const SchemaSelector = ({ )} )} - {schemas?.map((schema) => ( + {schemas.map((schema) => ( { + 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> +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', + }) +} diff --git a/apps/studio/data/privileges/exposed-tables-infinite-query.ts b/apps/studio/data/privileges/exposed-tables-infinite-query.ts new file mode 100644 index 00000000000..9b4fd1eebec --- /dev/null +++ b/apps/studio/data/privileges/exposed-tables-infinite-query.ts @@ -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 { + 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> +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 + }, + }) +} diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 9e92f4ed95c..328e39c325f 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -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, } diff --git a/apps/studio/data/privileges/privileges.sql.ts b/apps/studio/data/privileges/privileges.sql.ts new file mode 100644 index 00000000000..2f72c9699f2 --- /dev/null +++ b/apps/studio/data/privileges/privileges.sql.ts @@ -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; + ` +} diff --git a/apps/studio/data/privileges/update-exposed-tables-mutation.ts b/apps/studio/data/privileges/update-exposed-tables-mutation.ts new file mode 100644 index 00000000000..47b576d0209 --- /dev/null +++ b/apps/studio/data/privileges/update-exposed-tables-mutation.ts @@ -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 { + 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> + +export const useUpdateExposedTablesMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars), + onError(error: Error) { + toast.error(`Failed to update table access: ${error.message}`) + }, + ...(onError ? { onError } : {}), + ...options, + }) +} diff --git a/e2e/studio/features/api-access-toggle.spec.ts b/e2e/studio/features/api-access-toggle.spec.ts index 85269eb6c2f..a61d38463be 100644 --- a/e2e/studio/features/api-access-toggle.spec.ts +++ b/e2e/studio/features/api-access-toggle.spec.ts @@ -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) }) })