PostgrestConfig.tsx 24 KB

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