import { zodResolver } from '@hookform/resolvers/zod' import { safeSql } from '@supabase/pg-meta' 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 { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector, MultiSelectorContent, MultiSelectorItem, MultiSelectorList, MultiSelectorTrigger, } from 'ui-patterns/multi-select' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { z } from 'zod' import { ExposedSchemaSelector, internalSchemasCannotExpose } from './ExposedSchemaSelector' import { HardenAPIModal } from './HardenAPIModal' import { ExposedFunctionSelector } from '@/components/interfaces/Settings/API/ExposedFunctionSelector' import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector' import CopyButton from '@/components/ui/CopyButton' 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 { authenticatorRoleConfigQueryOptions } from '@/data/database/authenticator-role-config-query' 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 { useExecuteSqlMutation } from '@/data/sql/execute-sql-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 resetAuthenticatorDbSchemasSql = safeSql`alter role authenticator reset pgrst.db_schemas` const formSchema = z.object({ // Fields for updatePostgrestConfig dbSchema: z.array(z.string()), dbExtraSearchPath: z.array(z.string()), maxRows: z .union([ z.literal(''), z.coerce.number().int().min(1).max(1000000, "Can't be more than 1,000,000"), ]) .refine((value) => value !== '', 'Max rows is required') .transform((value) => Number(value)), dbPool: z.coerce .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 [showResetOverrideConfirm, setShowResetOverrideConfirm] = 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 { data: authenticatorDbSchemasOverride, refetch: refetchAuthenticatorRoleConfig } = useQuery( authenticatorRoleConfigQueryOptions({ 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 { mutate: resetAuthenticatorOverride, isPending: isResettingAuthenticatorOverride } = useExecuteSqlMutation({ onSuccess: async () => { toast.success('Reset the authenticator role override') setShowResetOverrideConfirm(false) await refetchAuthenticatorRoleConfig() }, onError: (error) => { toast.error(`Failed to reset the authenticator role override: ${error.message}`) }, }) const [isUpdating, setIsUpdating] = useState(false) const formId = 'project-postgres-config' const { can: canUpdatePostgrestConfigPermission, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest') // PostgREST config (exposed schemas, extra search path, max rows, pool size) is persisted via // the platform API, which isn't available self-hosted (the values come from env vars there). const canUpdatePostgrestConfig = IS_PLATFORM && canUpdatePostgrestConfigPermission // Entity exposure (tables, functions, default privileges) is applied with SQL GRANT/REVOKE // directly against the database, so it works both on the platform and self-hosted. const canUpdateExposedEntities = 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>({ resolver: zodResolver(formSchema), mode: 'onChange', defaultValues, }) const resetForm = useCallback(() => { form.reset({ ...defaultValues }) }, [form, defaultValues]) const onSubmit = async (values: z.infer) => { 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, }) } // The PostgREST config endpoint isn't available self-hosted, so only persist these fields on // the platform. Entity exposure above is applied via SQL and works in both environments. if (canUpdatePostgrestConfig) { 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] ) const protectedSchemasExposed = useMemo( () => watchedDbSchema.filter((schema) => internalSchemasCannotExpose.has(schema)), [watchedDbSchema] ) const isAuthenticatorRoleOverridingSchemas = useMemo(() => { if (!authenticatorDbSchemasOverride) return false // Compared against the persisted config, not the live form selection, so the warning stays // visible until the save actually succeeds rather than disappearing the moment the selector // is edited to match. const saved = new Set(configDbSchemas) const overridden = new Set(authenticatorDbSchemasOverride) return saved.size !== overridden.size || [...saved].some((schema) => !overridden.has(schema)) }, [authenticatorDbSchemasOverride, configDbSchemas]) return (
{isLoading ? ( ) : isError ? ( ) : ( <> { 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, }) } }} /> {protectedSchemasExposed.length > 0 ? (

{protectedSchemasExposed.length} protected schema {protectedSchemasExposed.length > 1 ? 's' : ''} is currently exposed and should be removed

) : missingExposedSchema.length > 0 ? (

{missingExposedSchema.length} exposed schema {missingExposedSchema.length > 1 ? 's' : ''} does not exist — safe to remove

) : null}
{isAuthenticatorRoleOverridingSchemas && ( The authenticator role has pgrst.db_schemas{' '} set to{' '} {authenticatorDbSchemasOverride?.map((schema, i) => ( {i > 0 && ', '} {schema} ))} , which overrides this setting. See the{' '} PGRST106 troubleshooting guide {' '} to check or reset it. } actions={ } /> )} { 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, }) } }} /> { 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, }) } }} /> {watchedDbSchema.includes('public') && ( (
)} /> )} {watchedDbSchema.length === 0 && ( )}
( {isLoadingSchemas ? (
) : ( {allSchemas.length <= 0 ? ( no ) : ( allSchemas.map((x) => ( {x.name} )) )} )}
)} />
( rows )} /> ( field.onChange( e.target.value === '' ? null : Number(e.target.value) ) } value={field.value === null ? '' : field.value} /> connections )} /> )}
{IS_PLATFORM && (
)}
{IS_PLATFORM && setShowModal(false)} />} setShowResetOverrideConfirm(false)} onConfirm={() => { if (!projectRef) return resetAuthenticatorOverride({ projectRef, connectionString: project?.connectionString, sql: resetAuthenticatorDbSchemasSql, }) }} >

This clears the pgrst.db_schemas setting on the authenticator{' '} role, so the Data API falls back to the schemas selected in the Dashboard above.

The following statement will be run:

            {resetAuthenticatorDbSchemasSql}
          
) }