PostgrestConfig.tsx592 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useQuery, useQueryClient } from '@tanstack/react-query'
4import { useParams } from 'common'
5import { Lock } from 'lucide-react'
6import { useCallback, useEffect, useMemo, useState } from 'react'
7import { useForm } from 'react-hook-form'
8import { toast } from 'sonner'
9import {
10 Button,
11 Card,
12 CardContent,
13 CardFooter,
14 Form,
15 FormControl,
16 FormField,
17 FormInputGroupInput,
18 FormItem,
19 InputGroup,
20 InputGroupAddon,
21 InputGroupText,
22 Skeleton,
23 Switch,
24 useWatch,
25} from 'ui'
26import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
27import { Admonition } from 'ui-patterns/admonition'
28import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
29import {
30 MultiSelector,
31 MultiSelectorContent,
32 MultiSelectorItem,
33 MultiSelectorList,
34 MultiSelectorTrigger,
35} from 'ui-patterns/multi-select'
36import { z } from 'zod'
37
38import { ExposedSchemaSelector } from './ExposedSchemaSelector'
39import { HardenAPIModal } from './HardenAPIModal'
40import { ExposedFunctionSelector } from '@/components/interfaces/Settings/API/ExposedFunctionSelector'
41import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector'
42import { FormActions } from '@/components/ui/Forms/FormActions'
43import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
44import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
45import { useSchemasQuery } from '@/data/database/schemas-query'
46import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
47import { privilegeKeys } from '@/data/privileges/keys'
48import { useUpdateDefaultPrivilegesMutation } from '@/data/privileges/update-default-privileges-mutation'
49import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation'
50import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
51import useLatest from '@/hooks/misc/useLatest'
52import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
53import { IS_PLATFORM } from '@/lib/constants'
54import { noop } from '@/lib/void'
55import type { ResponseError } from '@/types'
56
57const formSchema = z.object({
58 // Fields for updatePostgrestConfig
59 dbSchema: z.array(z.string()),
60 dbExtraSearchPath: z.array(z.string()),
61 maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
62 dbPool: z
63 .number()
64 .min(0, 'Must be more than 0')
65 .max(1000, "Can't be more than 1000")
66 .optional()
67 .nullable(),
68
69 // Default privileges toggle
70 defaultPrivilegesGranted: z.boolean(),
71
72 // Fields for expose toggles
73 tableIdsToAdd: z.array(z.number()),
74 tableIdsToRemove: z.array(z.number()),
75 functionNamesToAdd: z.array(z.string()),
76 functionNamesToRemove: z.array(z.string()),
77})
78
79export const PostgrestConfig = () => {
80 const { ref: projectRef } = useParams()
81 const { data: project } = useSelectedProjectQuery()
82 const queryClient = useQueryClient()
83
84 const [showModal, setShowModal] = useState(false)
85
86 const {
87 data: config,
88 isError,
89 isPending: isLoadingConfig,
90 isSuccess: isSuccessConfig,
91 } = useProjectPostgrestConfigQuery({ projectRef })
92 const {
93 data: allSchemas = [],
94 isPending: isLoadingSchemas,
95 isSuccess: isSuccessSchemas,
96 } = useSchemasQuery({
97 projectRef: project?.ref,
98 connectionString: project?.connectionString,
99 })
100
101 const {
102 data: defaultPrivilegesGranted,
103 isPending: isLoadingDefaultPrivileges,
104 isSuccess: isSuccessDefaultPrivileges,
105 } = useQuery(
106 defaultPrivilegesQueryOptions({
107 projectRef: project?.ref,
108 connectionString: project?.connectionString,
109 })
110 )
111
112 const configDbSchemas = useMemo(
113 () => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
114 [config?.db_schema]
115 )
116
117 const isLoading = isLoadingConfig || isLoadingSchemas || isLoadingDefaultPrivileges
118
119 const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
120 onError: noop,
121 })
122 const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation({ onError: noop })
123 const { mutateAsync: updateDefaultPrivileges } = useUpdateDefaultPrivilegesMutation({
124 onError: noop,
125 })
126
127 const [isUpdating, setIsUpdating] = useState(false)
128
129 const formId = 'project-postgres-config'
130
131 const { can: canUpdatePostgrestConfigPermission, isSuccess: isPermissionsLoaded } =
132 useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
133 const canUpdatePostgrestConfig = IS_PLATFORM && canUpdatePostgrestConfigPermission
134
135 const defaultValues = useMemo(() => {
136 return {
137 dbSchema: configDbSchemas,
138 maxRows: config?.max_rows,
139 // TODO: only display schemas that exist in the db
140 dbExtraSearchPath: (config?.db_extra_search_path ?? '')
141 .split(',')
142 .map((x) => x.trim())
143 .filter(Boolean),
144 dbPool: config?.db_pool,
145 defaultPrivilegesGranted: defaultPrivilegesGranted ?? true,
146 tableIdsToAdd: [] as number[],
147 tableIdsToRemove: [] as number[],
148 functionNamesToAdd: [] as string[],
149 functionNamesToRemove: [] as string[],
150 }
151 }, [config, configDbSchemas, defaultPrivilegesGranted])
152
153 const form = useForm<z.infer<typeof formSchema>>({
154 resolver: zodResolver(formSchema as any),
155 mode: 'onChange',
156 defaultValues,
157 })
158
159 const resetForm = useCallback(() => {
160 form.reset({ ...defaultValues })
161 }, [form, defaultValues])
162
163 const onSubmit = async (values: z.infer<typeof formSchema>) => {
164 if (!projectRef) return console.error('Project ref is required')
165
166 setIsUpdating(true)
167
168 try {
169 let dbSchema = values.dbSchema.join(',')
170
171 await updateExposedEntities({
172 projectRef,
173 connectionString: project?.connectionString,
174 tableIdsToAdd: values.tableIdsToAdd,
175 tableIdsToRemove: values.tableIdsToRemove,
176 functionNamesToAdd: values.functionNamesToAdd,
177 functionNamesToRemove: values.functionNamesToRemove,
178 })
179
180 if (values.defaultPrivilegesGranted !== defaultPrivilegesGranted) {
181 await updateDefaultPrivileges({
182 projectRef,
183 connectionString: project?.connectionString,
184 granted: values.defaultPrivilegesGranted,
185 })
186 }
187
188 await updatePostgrestConfig(
189 {
190 projectRef,
191 dbSchema,
192 maxRows: values.maxRows,
193 dbExtraSearchPath: values.dbExtraSearchPath.join(','),
194 dbPool: values.dbPool ? values.dbPool : null,
195 },
196 { onError: noop }
197 )
198
199 await Promise.all([
200 queryClient.invalidateQueries({
201 queryKey: privilegeKeys.exposedTablesInfinite(projectRef),
202 }),
203 queryClient.invalidateQueries({
204 queryKey: privilegeKeys.exposedTableCounts(projectRef),
205 }),
206 queryClient.invalidateQueries({
207 queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef),
208 }),
209 queryClient.invalidateQueries({
210 queryKey: privilegeKeys.exposedFunctionCounts(projectRef),
211 }),
212 queryClient.invalidateQueries({
213 queryKey: privilegeKeys.defaultPrivileges(projectRef),
214 }),
215 ])
216
217 toast.success('Successfully saved settings')
218 form.reset({
219 dbSchema: dbSchema
220 .split(',')
221 .map((x) => x.trim())
222 .filter(Boolean),
223 maxRows: values.maxRows,
224 dbExtraSearchPath: values.dbExtraSearchPath,
225 dbPool: values.dbPool,
226 defaultPrivilegesGranted: values.defaultPrivilegesGranted,
227 tableIdsToAdd: [],
228 tableIdsToRemove: [],
229 functionNamesToAdd: [],
230 functionNamesToRemove: [],
231 })
232 } catch (error) {
233 toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error')
234 } finally {
235 setIsUpdating(false)
236 }
237 }
238
239 const resetFormRef = useLatest(resetForm)
240 const isReady = isSuccessConfig && isSuccessSchemas && isSuccessDefaultPrivileges
241 useEffect(() => {
242 if (isReady) {
243 resetFormRef.current()
244 }
245 // eslint-disable-next-line react-hooks/exhaustive-deps
246 }, [isReady])
247
248 const watchedDbSchema = useWatch({ control: form.control, name: 'dbSchema' })
249 const watchedTableIdsToAdd = useWatch({ control: form.control, name: 'tableIdsToAdd' })
250 const watchedTableIdsToRemove = useWatch({
251 control: form.control,
252 name: 'tableIdsToRemove',
253 })
254 const watchedFunctionNamesToAdd = useWatch({
255 control: form.control,
256 name: 'functionNamesToAdd',
257 })
258 const watchedFunctionNamesToRemove = useWatch({
259 control: form.control,
260 name: 'functionNamesToRemove',
261 })
262
263 const missingExposedSchema = useMemo(
264 () => watchedDbSchema.filter((schema) => !allSchemas.some((s) => s.name === schema)),
265 [allSchemas, watchedDbSchema]
266 )
267
268 return (
269 <PageSection id="postgrest-config" className="first:pt-0">
270 <PageSectionContent>
271 <Card className="mb-4">
272 <Form {...form}>
273 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
274 {isLoading ? (
275 <CardContent>
276 <GenericSkeletonLoader />
277 </CardContent>
278 ) : isError ? (
279 <CardContent>
280 <Admonition type="destructive" description="Failed to retrieve API settings." />
281 </CardContent>
282 ) : (
283 <>
284 <CardContent className="space-y-6">
285 <FormItemLayout
286 isReactForm={false}
287 layout="flex-row-reverse"
288 label="Exposed schemas"
289 description="Select schemas to include in the Data API. Schemas must be included before tables can be exposed."
290 error={
291 missingExposedSchema.length > 0 ? 'Some exposed schemas are missing' : null
292 }
293 >
294 <ExposedSchemaSelector
295 selectedSchemas={watchedDbSchema}
296 disabled={!canUpdatePostgrestConfig}
297 onToggleSchema={(schema) => {
298 const current = form.getValues('dbSchema')
299 if (current.includes(schema)) {
300 form.setValue(
301 'dbSchema',
302 current.filter((x) => x !== schema),
303 { shouldDirty: true }
304 )
305 } else {
306 form.setValue('dbSchema', [...current, schema], {
307 shouldDirty: true,
308 })
309 }
310 }}
311 />
312 </FormItemLayout>
313
314 <FormItemLayout
315 isReactForm={false}
316 layout="flex-row-reverse"
317 label="Exposed tables"
318 description="Toggle Data API access for individual tables."
319 >
320 <ExposedTableSelector
321 selectedSchemas={watchedDbSchema}
322 pendingAddTableIds={watchedTableIdsToAdd}
323 pendingRemoveTableIds={watchedTableIdsToRemove}
324 onTogglePendingAdd={(tableId) => {
325 const current = form.getValues('tableIdsToAdd')
326 if (current.includes(tableId)) {
327 form.setValue(
328 'tableIdsToAdd',
329 current.filter((x) => x !== tableId),
330 { shouldDirty: true }
331 )
332 } else {
333 form.setValue('tableIdsToAdd', [...current, tableId], {
334 shouldDirty: true,
335 })
336 }
337 }}
338 onTogglePendingRemove={(tableId) => {
339 const current = form.getValues('tableIdsToRemove')
340 if (current.includes(tableId)) {
341 form.setValue(
342 'tableIdsToRemove',
343 current.filter((x) => x !== tableId),
344 { shouldDirty: true }
345 )
346 } else {
347 form.setValue('tableIdsToRemove', [...current, tableId], {
348 shouldDirty: true,
349 })
350 }
351 }}
352 />
353 </FormItemLayout>
354
355 <FormItemLayout
356 isReactForm={false}
357 layout="flex-row-reverse"
358 label="Exposed functions"
359 description="Toggle Data API access for individual functions."
360 >
361 <ExposedFunctionSelector
362 selectedSchemas={watchedDbSchema}
363 pendingAddFunctionNames={watchedFunctionNamesToAdd}
364 pendingRemoveFunctionNames={watchedFunctionNamesToRemove}
365 onTogglePendingAdd={(functionName) => {
366 const current = form.getValues('functionNamesToAdd')
367 if (current.includes(functionName)) {
368 form.setValue(
369 'functionNamesToAdd',
370 current.filter((x) => x !== functionName),
371 { shouldDirty: true }
372 )
373 } else {
374 form.setValue('functionNamesToAdd', [...current, functionName], {
375 shouldDirty: true,
376 })
377 }
378 }}
379 onTogglePendingRemove={(functionName) => {
380 const current = form.getValues('functionNamesToRemove')
381 if (current.includes(functionName)) {
382 form.setValue(
383 'functionNamesToRemove',
384 current.filter((x) => x !== functionName),
385 { shouldDirty: true }
386 )
387 } else {
388 form.setValue('functionNamesToRemove', [...current, functionName], {
389 shouldDirty: true,
390 })
391 }
392 }}
393 />
394 </FormItemLayout>
395
396 {watchedDbSchema.includes('public') && (
397 <FormField
398 control={form.control}
399 name="defaultPrivilegesGranted"
400 render={({ field }) => (
401 <FormItem>
402 <FormItemLayout
403 layout="flex-row-reverse"
404 label="Automatically expose new tables"
405 description="Grants privileges to Data API roles by default, exposing new tables. We recommend disabling this to control access manually."
406 >
407 <FormControl>
408 <div>
409 <Switch
410 size="large"
411 disabled={!canUpdatePostgrestConfig}
412 checked={field.value}
413 onCheckedChange={field.onChange}
414 />
415 </div>
416 </FormControl>
417 </FormItemLayout>
418 </FormItem>
419 )}
420 />
421 )}
422
423 {watchedDbSchema.length === 0 && (
424 <Admonition
425 type="warning"
426 title="No schema is currently selected"
427 description="Saving with no selected schema or table will disable the Data API."
428 />
429 )}
430 </CardContent>
431 <CardContent>
432 <FormField
433 control={form.control}
434 name="dbExtraSearchPath"
435 render={({ field }) => (
436 <FormItem>
437 <FormItemLayout
438 layout="flex-row-reverse"
439 label="Extra search path"
440 description="Extra schemas to add to the search path of every request."
441 >
442 {isLoadingSchemas ? (
443 <div className="col-span-12 flex flex-col gap-2 lg:col-span-7">
444 <Skeleton className="w-full h-[38px]" />
445 </div>
446 ) : (
447 <MultiSelector
448 onValuesChange={field.onChange}
449 values={field.value}
450 size="small"
451 disabled={!canUpdatePostgrestConfig}
452 >
453 <MultiSelectorTrigger
454 mode="inline-combobox"
455 label="Select schemas..."
456 badgeLimit="wrap"
457 showIcon={false}
458 deletableBadge
459 />
460 <MultiSelectorContent>
461 <MultiSelectorList>
462 {allSchemas.length <= 0 ? (
463 <MultiSelectorItem key="empty" value="no">
464 no
465 </MultiSelectorItem>
466 ) : (
467 allSchemas.map((x) => (
468 <MultiSelectorItem key={x.id + '-' + x.name} value={x.name}>
469 {x.name}
470 </MultiSelectorItem>
471 ))
472 )}
473 </MultiSelectorList>
474 </MultiSelectorContent>
475 </MultiSelector>
476 )}
477 </FormItemLayout>
478 </FormItem>
479 )}
480 />
481 </CardContent>
482 <CardContent>
483 <FormField
484 control={form.control}
485 name="maxRows"
486 render={({ field }) => (
487 <FormItem>
488 <FormItemLayout
489 layout="flex-row-reverse"
490 label="Max rows"
491 description="The maximum number of rows returned from a view, table, or function. Limits payload size for accidental or malicious requests."
492 >
493 <FormControl>
494 <InputGroup>
495 <FormInputGroupInput
496 size="small"
497 disabled={!canUpdatePostgrestConfig}
498 {...field}
499 type="number"
500 onChange={(e) => field.onChange(Number(e.target.value))}
501 />
502 <InputGroupAddon align="inline-end">
503 <InputGroupText>rows</InputGroupText>
504 </InputGroupAddon>
505 </InputGroup>
506 </FormControl>
507 </FormItemLayout>
508 </FormItem>
509 )}
510 />
511 </CardContent>
512 <CardContent>
513 <FormField
514 control={form.control}
515 name="dbPool"
516 render={({ field }) => (
517 <FormItem>
518 <FormItemLayout
519 layout="flex-row-reverse"
520 label="Pool size"
521 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."
522 >
523 <FormControl>
524 <InputGroup>
525 <FormInputGroupInput
526 size="small"
527 disabled={!canUpdatePostgrestConfig}
528 {...field}
529 type="number"
530 placeholder="Configured automatically"
531 onChange={(e) =>
532 field.onChange(
533 e.target.value === '' ? null : Number(e.target.value)
534 )
535 }
536 value={field.value === null ? '' : field.value}
537 />
538 <InputGroupAddon align="inline-end">
539 <InputGroupText>connections</InputGroupText>
540 </InputGroupAddon>
541 </InputGroup>
542 </FormControl>
543 </FormItemLayout>
544 </FormItem>
545 )}
546 />
547 </CardContent>
548 </>
549 )}
550 </form>
551 </Form>
552 {IS_PLATFORM && (
553 <CardFooter className="border-t">
554 <FormActions
555 form={formId}
556 isSubmitting={isUpdating}
557 hasChanges={form.formState.isDirty}
558 handleReset={resetForm}
559 disabled={!canUpdatePostgrestConfig}
560 helper={
561 isPermissionsLoaded && !canUpdatePostgrestConfigPermission
562 ? "You need additional permissions to update your project's API settings"
563 : undefined
564 }
565 />
566 </CardFooter>
567 )}
568 </Card>
569 {IS_PLATFORM && (
570 <Card className="mb-4">
571 <CardContent>
572 <FormItemLayout
573 isReactForm={false}
574 layout="flex-row-reverse"
575 label="Harden Data API"
576 description="Expose a custom schema instead of the public schema"
577 >
578 <div className="flex gap-2 items-center justify-end">
579 <Button type="default" icon={<Lock />} onClick={() => setShowModal(true)}>
580 Harden Data API
581 </Button>
582 </div>
583 </FormItemLayout>
584 </CardContent>
585 </Card>
586 )}
587 </PageSectionContent>
588
589 {IS_PLATFORM && <HardenAPIModal visible={showModal} onClose={() => setShowModal(false)} />}
590 </PageSection>
591 )
592}