mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
## Problem Exposed schemas (from the Data API) that have been deleted manually (for instance, using the SQL editor) are still present in the Data API setting, crashing Postgrest clients. However, the Data API setting does not show them anymore preventing users from fixing the issue. ## Solution - Detect missing schemas and show users an error message - Make missing schema visually distinct - Allow users to unselect them and update the Data API settings ## How to test - Create a new schema - Verify that it is exposed (expose it if necessary) in the Data API settings - Delete the schema using the SQL editor - Verify that you can unselect it from the Data API settings ## Screenshots <img width="470" height="243" alt="image" src="https://github.com/user-attachments/assets/2a1f7dc5-c9a9-4779-9f26-1d3e0f66fb8f" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Dropdown for exposed API schemas now shows selected schemas that no longer exist and labels them "This schema does not exist", allowing users to toggle them. * "Exposed schemas" field now shows an inline validation/error message when selected schemas are unavailable to help resolve configuration issues. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46169?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
593 lines
24 KiB
TypeScript
593 lines
24 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useParams } from 'common'
|
|
import { Lock } from 'lucide-react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardFooter,
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormInputGroupInput,
|
|
FormItem,
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupText,
|
|
Skeleton,
|
|
Switch,
|
|
useWatch,
|
|
} from 'ui'
|
|
import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
|
|
import { Admonition } from 'ui-patterns/admonition'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import {
|
|
MultiSelector,
|
|
MultiSelectorContent,
|
|
MultiSelectorItem,
|
|
MultiSelectorList,
|
|
MultiSelectorTrigger,
|
|
} from 'ui-patterns/multi-select'
|
|
import { z } from 'zod'
|
|
|
|
import { ExposedSchemaSelector } from './ExposedSchemaSelector'
|
|
import { HardenAPIModal } from './HardenAPIModal'
|
|
import { ExposedFunctionSelector } from '@/components/interfaces/Settings/API/ExposedFunctionSelector'
|
|
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 { useSchemasQuery } from '@/data/database/schemas-query'
|
|
import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
|
|
import { privilegeKeys } from '@/data/privileges/keys'
|
|
import { useUpdateDefaultPrivilegesMutation } from '@/data/privileges/update-default-privileges-mutation'
|
|
import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import useLatest from '@/hooks/misc/useLatest'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { IS_PLATFORM } from '@/lib/constants'
|
|
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"),
|
|
dbPool: z
|
|
.number()
|
|
.min(0, 'Must be more than 0')
|
|
.max(1000, "Can't be more than 1000")
|
|
.optional()
|
|
.nullable(),
|
|
|
|
// Default privileges toggle
|
|
defaultPrivilegesGranted: z.boolean(),
|
|
|
|
// Fields for expose toggles
|
|
tableIdsToAdd: z.array(z.number()),
|
|
tableIdsToRemove: z.array(z.number()),
|
|
functionNamesToAdd: z.array(z.string()),
|
|
functionNamesToRemove: z.array(z.string()),
|
|
})
|
|
|
|
export const PostgrestConfig = () => {
|
|
const { ref: projectRef } = useParams()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const queryClient = useQueryClient()
|
|
|
|
const [showModal, setShowModal] = useState(false)
|
|
|
|
const {
|
|
data: config,
|
|
isError,
|
|
isPending: isLoadingConfig,
|
|
isSuccess: isSuccessConfig,
|
|
} = useProjectPostgrestConfigQuery({ projectRef })
|
|
const {
|
|
data: allSchemas = [],
|
|
isPending: isLoadingSchemas,
|
|
isSuccess: isSuccessSchemas,
|
|
} = useSchemasQuery({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
|
|
const {
|
|
data: defaultPrivilegesGranted,
|
|
isPending: isLoadingDefaultPrivileges,
|
|
isSuccess: isSuccessDefaultPrivileges,
|
|
} = useQuery(
|
|
defaultPrivilegesQueryOptions({
|
|
projectRef: project?.ref,
|
|
connectionString: project?.connectionString,
|
|
})
|
|
)
|
|
|
|
const configDbSchemas = useMemo(
|
|
() => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
|
|
[config?.db_schema]
|
|
)
|
|
|
|
const isLoading = isLoadingConfig || isLoadingSchemas || isLoadingDefaultPrivileges
|
|
|
|
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
|
|
onError: noop,
|
|
})
|
|
const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation({ onError: noop })
|
|
const { mutateAsync: updateDefaultPrivileges } = useUpdateDefaultPrivilegesMutation({
|
|
onError: noop,
|
|
})
|
|
|
|
const [isUpdating, setIsUpdating] = useState(false)
|
|
|
|
const formId = 'project-postgres-config'
|
|
|
|
const { can: canUpdatePostgrestConfigPermission, isSuccess: isPermissionsLoaded } =
|
|
useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
|
|
const canUpdatePostgrestConfig = IS_PLATFORM && canUpdatePostgrestConfigPermission
|
|
|
|
const defaultValues = useMemo(() => {
|
|
return {
|
|
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(Boolean),
|
|
dbPool: config?.db_pool,
|
|
defaultPrivilegesGranted: defaultPrivilegesGranted ?? true,
|
|
tableIdsToAdd: [] as number[],
|
|
tableIdsToRemove: [] as number[],
|
|
functionNamesToAdd: [] as string[],
|
|
functionNamesToRemove: [] as string[],
|
|
}
|
|
}, [config, configDbSchemas, defaultPrivilegesGranted])
|
|
|
|
const form = useForm<z.infer<typeof formSchema>>({
|
|
resolver: zodResolver(formSchema),
|
|
mode: 'onChange',
|
|
defaultValues,
|
|
})
|
|
|
|
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')
|
|
|
|
setIsUpdating(true)
|
|
|
|
try {
|
|
let dbSchema = values.dbSchema.join(',')
|
|
|
|
await updateExposedEntities({
|
|
projectRef,
|
|
connectionString: project?.connectionString,
|
|
tableIdsToAdd: values.tableIdsToAdd,
|
|
tableIdsToRemove: values.tableIdsToRemove,
|
|
functionNamesToAdd: values.functionNamesToAdd,
|
|
functionNamesToRemove: values.functionNamesToRemove,
|
|
})
|
|
|
|
if (values.defaultPrivilegesGranted !== defaultPrivilegesGranted) {
|
|
await updateDefaultPrivileges({
|
|
projectRef,
|
|
connectionString: project?.connectionString,
|
|
granted: values.defaultPrivilegesGranted,
|
|
})
|
|
}
|
|
|
|
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),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: privilegeKeys.exposedFunctionCounts(projectRef),
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
queryKey: privilegeKeys.defaultPrivileges(projectRef),
|
|
}),
|
|
])
|
|
|
|
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,
|
|
defaultPrivilegesGranted: values.defaultPrivilegesGranted,
|
|
tableIdsToAdd: [],
|
|
tableIdsToRemove: [],
|
|
functionNamesToAdd: [],
|
|
functionNamesToRemove: [],
|
|
})
|
|
} 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 && isSuccessDefaultPrivileges
|
|
useEffect(() => {
|
|
if (isReady) {
|
|
resetFormRef.current()
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isReady])
|
|
|
|
const watchedDbSchema = useWatch({ control: form.control, name: 'dbSchema' })
|
|
const watchedTableIdsToAdd = useWatch({ control: form.control, name: 'tableIdsToAdd' })
|
|
const watchedTableIdsToRemove = useWatch({
|
|
control: form.control,
|
|
name: 'tableIdsToRemove',
|
|
})
|
|
const watchedFunctionNamesToAdd = useWatch({
|
|
control: form.control,
|
|
name: 'functionNamesToAdd',
|
|
})
|
|
const watchedFunctionNamesToRemove = useWatch({
|
|
control: form.control,
|
|
name: 'functionNamesToRemove',
|
|
})
|
|
|
|
const missingExposedSchema = useMemo(
|
|
() => watchedDbSchema.filter((schema) => !allSchemas.some((s) => s.name === schema)),
|
|
[allSchemas, watchedDbSchema]
|
|
)
|
|
|
|
return (
|
|
<PageSection id="postgrest-config" className="first:pt-0">
|
|
<PageSectionContent>
|
|
<Card className="mb-4">
|
|
<Form {...form}>
|
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
|
{isLoading ? (
|
|
<CardContent>
|
|
<GenericSkeletonLoader />
|
|
</CardContent>
|
|
) : isError ? (
|
|
<CardContent>
|
|
<Admonition type="destructive" description="Failed to retrieve API settings." />
|
|
</CardContent>
|
|
) : (
|
|
<>
|
|
<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."
|
|
error={
|
|
missingExposedSchema.length > 0 ? 'Some exposed schemas are missing' : null
|
|
}
|
|
>
|
|
<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>
|
|
|
|
<FormItemLayout
|
|
isReactForm={false}
|
|
layout="flex-row-reverse"
|
|
label="Exposed functions"
|
|
description="Toggle Data API access for individual functions."
|
|
>
|
|
<ExposedFunctionSelector
|
|
selectedSchemas={watchedDbSchema}
|
|
pendingAddFunctionNames={watchedFunctionNamesToAdd}
|
|
pendingRemoveFunctionNames={watchedFunctionNamesToRemove}
|
|
onTogglePendingAdd={(functionName) => {
|
|
const current = form.getValues('functionNamesToAdd')
|
|
if (current.includes(functionName)) {
|
|
form.setValue(
|
|
'functionNamesToAdd',
|
|
current.filter((x) => x !== functionName),
|
|
{ shouldDirty: true }
|
|
)
|
|
} else {
|
|
form.setValue('functionNamesToAdd', [...current, functionName], {
|
|
shouldDirty: true,
|
|
})
|
|
}
|
|
}}
|
|
onTogglePendingRemove={(functionName) => {
|
|
const current = form.getValues('functionNamesToRemove')
|
|
if (current.includes(functionName)) {
|
|
form.setValue(
|
|
'functionNamesToRemove',
|
|
current.filter((x) => x !== functionName),
|
|
{ shouldDirty: true }
|
|
)
|
|
} else {
|
|
form.setValue('functionNamesToRemove', [...current, functionName], {
|
|
shouldDirty: true,
|
|
})
|
|
}
|
|
}}
|
|
/>
|
|
</FormItemLayout>
|
|
|
|
{watchedDbSchema.includes('public') && (
|
|
<FormField
|
|
control={form.control}
|
|
name="defaultPrivilegesGranted"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Automatically expose new tables"
|
|
description="Grants privileges to Data API roles by default, exposing new tables. We recommend disabling this to control access manually."
|
|
>
|
|
<FormControl>
|
|
<div>
|
|
<Switch
|
|
size="large"
|
|
disabled={!canUpdatePostgrestConfig}
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</div>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
<FormField
|
|
control={form.control}
|
|
name="dbExtraSearchPath"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Extra search path"
|
|
description="Extra schemas to add to the search path of every request."
|
|
>
|
|
{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>
|
|
{allSchemas.length <= 0 ? (
|
|
<MultiSelectorItem key="empty" value="no">
|
|
no
|
|
</MultiSelectorItem>
|
|
) : (
|
|
allSchemas.map((x) => (
|
|
<MultiSelectorItem key={x.id + '-' + x.name} value={x.name}>
|
|
{x.name}
|
|
</MultiSelectorItem>
|
|
))
|
|
)}
|
|
</MultiSelectorList>
|
|
</MultiSelectorContent>
|
|
</MultiSelector>
|
|
)}
|
|
</FormItemLayout>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
<CardContent>
|
|
<FormField
|
|
control={form.control}
|
|
name="maxRows"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Max rows"
|
|
description="The maximum number of rows returned from a view, table, or function. Limits payload size for accidental or malicious requests."
|
|
>
|
|
<FormControl>
|
|
<InputGroup>
|
|
<FormInputGroupInput
|
|
size="small"
|
|
disabled={!canUpdatePostgrestConfig}
|
|
{...field}
|
|
type="number"
|
|
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
/>
|
|
<InputGroupAddon align="inline-end">
|
|
<InputGroupText>rows</InputGroupText>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
<CardContent>
|
|
<FormField
|
|
control={form.control}
|
|
name="dbPool"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormItemLayout
|
|
layout="flex-row-reverse"
|
|
label="Pool size"
|
|
description="Number of maximum connections to keep open in the Data API server's database pool. Unset to let it be configured automatically based on compute size."
|
|
>
|
|
<FormControl>
|
|
<InputGroup>
|
|
<FormInputGroupInput
|
|
size="small"
|
|
disabled={!canUpdatePostgrestConfig}
|
|
{...field}
|
|
type="number"
|
|
placeholder="Configured automatically"
|
|
onChange={(e) =>
|
|
field.onChange(
|
|
e.target.value === '' ? null : Number(e.target.value)
|
|
)
|
|
}
|
|
value={field.value === null ? '' : field.value}
|
|
/>
|
|
<InputGroupAddon align="inline-end">
|
|
<InputGroupText>connections</InputGroupText>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</FormControl>
|
|
</FormItemLayout>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</>
|
|
)}
|
|
</form>
|
|
</Form>
|
|
{IS_PLATFORM && (
|
|
<CardFooter className="border-t">
|
|
<FormActions
|
|
form={formId}
|
|
isSubmitting={isUpdating}
|
|
hasChanges={form.formState.isDirty}
|
|
handleReset={resetForm}
|
|
disabled={!canUpdatePostgrestConfig}
|
|
helper={
|
|
isPermissionsLoaded && !canUpdatePostgrestConfigPermission
|
|
? "You need additional permissions to update your project's API settings"
|
|
: undefined
|
|
}
|
|
/>
|
|
</CardFooter>
|
|
)}
|
|
</Card>
|
|
{IS_PLATFORM && (
|
|
<Card className="mb-4">
|
|
<CardContent>
|
|
<FormItemLayout
|
|
isReactForm={false}
|
|
layout="flex-row-reverse"
|
|
label="Harden Data API"
|
|
description="Expose a custom schema instead of the public schema"
|
|
>
|
|
<div className="flex gap-2 items-center justify-end">
|
|
<Button type="default" icon={<Lock />} onClick={() => setShowModal(true)}>
|
|
Harden Data API
|
|
</Button>
|
|
</div>
|
|
</FormItemLayout>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</PageSectionContent>
|
|
|
|
{IS_PLATFORM && <HardenAPIModal visible={showModal} onClose={() => setShowModal(false)} />}
|
|
</PageSection>
|
|
)
|
|
}
|