QueueSettings.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import { useParams } from 'common'
  2. import { isEqual } from 'lodash'
  3. import { HelpCircle, Settings } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useEffect, useState } from 'react'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Sheet,
  10. SheetContent,
  11. SheetDescription,
  12. SheetFooter,
  13. SheetHeader,
  14. SheetSection,
  15. SheetTitle,
  16. SheetTrigger,
  17. Switch,
  18. Table,
  19. TableBody,
  20. TableCell,
  21. TableHead,
  22. TableHeader,
  23. TableRow,
  24. Tooltip,
  25. TooltipContent,
  26. TooltipTrigger,
  27. } from 'ui'
  28. import { Admonition } from 'ui-patterns/admonition'
  29. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  30. import { pgmqArchiveTable, pgmqQueueTable } from '../Queues.utils'
  31. import { getQueueFunctionsMapping } from './Queue.utils'
  32. import AlertError from '@/components/ui/AlertError'
  33. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  34. import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query'
  35. import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
  36. import {
  37. TablePrivilegesGrant,
  38. useTablePrivilegesGrantMutation,
  39. } from '@/data/privileges/table-privileges-grant-mutation'
  40. import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query'
  41. import {
  42. TablePrivilegesRevoke,
  43. useTablePrivilegesRevokeMutation,
  44. } from '@/data/privileges/table-privileges-revoke-mutation'
  45. import { useTablesQuery } from '@/data/tables/tables-query'
  46. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  47. const ACTIONS = ['select', 'insert', 'update', 'delete']
  48. const ROLES = ['anon', 'authenticated', 'postgres', 'service_role']
  49. type Privileges = { select?: boolean; insert?: boolean; update?: boolean; delete?: boolean }
  50. interface QueueSettingsProps {}
  51. export const QueueSettings = ({}: QueueSettingsProps) => {
  52. const { childId: name } = useParams()
  53. const { data: project } = useSelectedProjectQuery()
  54. const [open, setOpen] = useState(false)
  55. const [isSaving, setIsSaving] = useState(false)
  56. const [privileges, setPrivileges] = useState<{ [key: string]: Privileges }>({})
  57. const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
  58. projectRef: project?.ref,
  59. connectionString: project?.connectionString,
  60. })
  61. const {
  62. data,
  63. error,
  64. isPending: isLoading,
  65. isSuccess,
  66. isError,
  67. } = useDatabaseRolesQuery({
  68. projectRef: project?.ref,
  69. connectionString: project?.connectionString,
  70. })
  71. const roles = (data ?? [])
  72. .filter((x) => ROLES.includes(x.name))
  73. .sort((a, b) => a.name.localeCompare(b.name))
  74. const { data: queueTables } = useTablesQuery({
  75. projectRef: project?.ref,
  76. connectionString: project?.connectionString,
  77. schema: 'pgmq',
  78. })
  79. const queueRelname = name ? pgmqQueueTable(name) : undefined
  80. const archiveRelname = name ? pgmqArchiveTable(name) : undefined
  81. const queueTable = queueTables?.find((x) => x.name === queueRelname)
  82. const archiveTable = queueTables?.find((x) => x.name === archiveRelname)
  83. const { data: allTablePrivileges, isSuccess: isSuccessPrivileges } = useTablePrivilegesQuery({
  84. projectRef: project?.ref,
  85. connectionString: project?.connectionString,
  86. })
  87. const queuePrivileges = allTablePrivileges?.find(
  88. (x) => x.schema === 'pgmq' && x.name === queueRelname
  89. )
  90. const { mutateAsync: grantPrivilege } = useTablePrivilegesGrantMutation()
  91. const { mutateAsync: revokePrivilege } = useTablePrivilegesRevokeMutation()
  92. const onTogglePrivilege = (role: string, action: string, value: boolean) => {
  93. const updatedPrivileges = { ...privileges, [role]: { ...privileges[role], [action]: value } }
  94. setPrivileges(updatedPrivileges)
  95. }
  96. const onSaveConfiguration = async () => {
  97. if (!project) return console.error('Project is required')
  98. if (!queueTable) return console.error('Unable to find queue table')
  99. if (!archiveTable) return console.error('Unable to find archive table')
  100. setIsSaving(true)
  101. const revoke: { role: string; action: string }[] = []
  102. const grant: { role: string; action: string }[] = []
  103. Object.entries(privileges).forEach(([role, p]) => {
  104. const originalRolePrivileges = queuePrivileges?.privileges.filter((x) => x.grantee === role)
  105. Object.entries(p).forEach(([action, value]) => {
  106. const originalValue = !!originalRolePrivileges?.find(
  107. (x) => x.privilege_type.toLowerCase() === action
  108. )
  109. if (value !== originalValue) {
  110. if (value) grant.push({ role, action })
  111. else revoke.push({ role, action })
  112. }
  113. })
  114. })
  115. const rolesBeingGrantedPerms = [...new Set(grant.map((x) => x.role))]
  116. const rolesBeingRevokedPerms = [...new Set(revoke.map((x) => x.role))]
  117. const rolesNoLongerHavingPerms = rolesBeingRevokedPerms.filter((x) => {
  118. const existingPrivileges = queuePrivileges?.privileges
  119. .filter((y) => x === y.grantee)
  120. .map((y) => y.privilege_type)
  121. const privilegesGettingRevoked = revoke
  122. .filter((y) => y.role === x)
  123. .map((y) => y.action.toUpperCase())
  124. const privilegesGettingGranted = grant.filter((y) => y.role === x)
  125. return (
  126. privilegesGettingGranted.length === 0 &&
  127. isEqual(existingPrivileges, privilegesGettingRevoked)
  128. )
  129. })
  130. try {
  131. await Promise.all([
  132. ...(revoke.length > 0
  133. ? [
  134. revokePrivilege({
  135. projectRef: project.ref,
  136. connectionString: project.connectionString,
  137. revokes: revoke.map((x) => ({
  138. grantee: x.role,
  139. privilegeType: x.action.toUpperCase(),
  140. relationId: queueTable.id,
  141. })) as TablePrivilegesRevoke[],
  142. }),
  143. ]
  144. : []),
  145. // Revoke select + insert on archive table only if role no longer has ANY perms on the queue table
  146. ...(rolesNoLongerHavingPerms.length > 0
  147. ? [
  148. revokePrivilege({
  149. projectRef: project.ref,
  150. connectionString: project.connectionString,
  151. revokes: [
  152. ...rolesNoLongerHavingPerms.map((x) => ({
  153. grantee: x,
  154. privilegeType: 'INSERT' as const,
  155. relationId: archiveTable.id,
  156. })),
  157. ...rolesNoLongerHavingPerms.map((x) => ({
  158. grantee: x,
  159. privilegeType: 'SELECT' as const,
  160. relationId: archiveTable.id,
  161. })),
  162. ],
  163. }),
  164. ]
  165. : []),
  166. ...(grant.length > 0
  167. ? [
  168. grantPrivilege({
  169. projectRef: project.ref,
  170. connectionString: project.connectionString,
  171. grants: grant.map((x) => ({
  172. grantee: x.role,
  173. privilegeType: x.action.toUpperCase(),
  174. relationId: queueTable.id,
  175. })) as TablePrivilegesGrant[],
  176. }),
  177. // Just grant select + insert on archive table as long as we're granting any perms to the queue table for the role
  178. grantPrivilege({
  179. projectRef: project.ref,
  180. connectionString: project.connectionString,
  181. grants: [
  182. ...rolesBeingGrantedPerms.map((x) => ({
  183. grantee: x,
  184. privilegeType: 'INSERT' as const,
  185. relationId: archiveTable.id,
  186. })),
  187. ...rolesBeingGrantedPerms.map((x) => ({
  188. grantee: x,
  189. privilegeType: 'SELECT' as const,
  190. relationId: archiveTable.id,
  191. })),
  192. ],
  193. }),
  194. ]
  195. : []),
  196. ])
  197. toast.success('Successfully updated permissions')
  198. setOpen(false)
  199. } catch (error: any) {
  200. toast.error(`Failed to update permissions: ${error.message}`)
  201. } finally {
  202. setIsSaving(false)
  203. }
  204. }
  205. useEffect(() => {
  206. if (open && isSuccessPrivileges && queuePrivileges) {
  207. const initialState = queuePrivileges.privileges.reduce((a, b) => {
  208. return {
  209. ...a,
  210. [b.grantee]: { ...(a as any)[b.grantee], [b.privilege_type.toLowerCase()]: true },
  211. }
  212. }, {})
  213. setPrivileges(initialState)
  214. }
  215. }, [open, isSuccessPrivileges])
  216. return (
  217. <Sheet open={open} onOpenChange={setOpen}>
  218. <SheetTrigger asChild>
  219. <ButtonTooltip
  220. type="text"
  221. className="px-1.5"
  222. icon={<Settings />}
  223. title="Settings"
  224. tooltip={{ content: { side: 'bottom', text: 'Queue settings' } }}
  225. />
  226. </SheetTrigger>
  227. <SheetContent size="lg" className="overflow-auto flex flex-col gap-y-0">
  228. <SheetHeader>
  229. <SheetTitle>Manage queue permissions on {name}</SheetTitle>
  230. <SheetDescription>
  231. Configure permissions for the following roles to grant access to the relevant actions on
  232. the queue.{' '}
  233. {isExposed && (
  234. <>
  235. These will also determine access to each function available from the{' '}
  236. <code className="text-code-inline">pgmq_public</code> schema.
  237. </>
  238. )}
  239. </SheetDescription>
  240. </SheetHeader>
  241. <SheetSection className="p-0 grow">
  242. {!isExposed ? (
  243. <Admonition
  244. type="default"
  245. className="rounded-none border-x-0 border-t-0"
  246. title="Queue permissions are only relevant if exposure through PostgREST has been enabled"
  247. description={
  248. <>
  249. You may opt to manage your queues via any Briven client libraries or PostgREST
  250. endpoints by enabling this in the{' '}
  251. <Link
  252. href={`/project/${project?.ref}/integrations/queues/settings`}
  253. className="underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground"
  254. >
  255. queues settings
  256. </Link>
  257. </>
  258. }
  259. />
  260. ) : (
  261. <Admonition
  262. type="default"
  263. className="rounded-none border-x-0 border-t-0"
  264. description="Only relevant roles for managing queues via client libraries or PostgREST are shown here."
  265. />
  266. )}
  267. <Table>
  268. <TableHeader className="[&_th]:h-8">
  269. <TableRow className="py-2">
  270. <TableHead>Role</TableHead>
  271. {ACTIONS.map((x) => {
  272. const relatedFunctions = getQueueFunctionsMapping(x)
  273. return (
  274. <TableHead key={x}>
  275. <Tooltip>
  276. <TooltipTrigger className="mx-auto flex items-center gap-x-1 capitalize text-foreground-light font-normal">
  277. {x}
  278. {isExposed && <HelpCircle size={14} strokeWidth={1.5} />}
  279. </TooltipTrigger>
  280. {isExposed && (
  281. <TooltipContent side="bottom" className="w-64 flex flex-col gap-y-1">
  282. <p>
  283. Required for{' '}
  284. {relatedFunctions.length === 6
  285. ? 'all'
  286. : `the following ${relatedFunctions.length}`}{' '}
  287. functions:
  288. </p>
  289. <div className="max-w-full flex flex-wrap gap-x-0.5 gap-y-1">
  290. {relatedFunctions.map((y) => (
  291. <code key={`${x}_${y}`}>{y}</code>
  292. ))}
  293. </div>
  294. </TooltipContent>
  295. )}
  296. </Tooltip>
  297. </TableHead>
  298. )
  299. })}
  300. </TableRow>
  301. </TableHeader>
  302. <TableBody className="[&_td]:py-2">
  303. {isLoading && (
  304. <>
  305. <TableRow>
  306. <TableCell colSpan={5}>
  307. <ShimmeringLoader />
  308. </TableCell>
  309. </TableRow>
  310. <TableRow>
  311. <TableCell colSpan={4}>
  312. <ShimmeringLoader />
  313. </TableCell>
  314. </TableRow>
  315. <TableRow>
  316. <TableCell colSpan={3}>
  317. <ShimmeringLoader />
  318. </TableCell>
  319. </TableRow>
  320. </>
  321. )}
  322. {isError && (
  323. <TableRow>
  324. <TableCell colSpan={5}>
  325. <AlertError subject="Failed to retrieve roles" error={error} />
  326. </TableCell>
  327. </TableRow>
  328. )}
  329. {isSuccess &&
  330. (roles ?? []).map((role) => {
  331. return (
  332. <TableRow key={role.id}>
  333. <TableCell>{role.name}</TableCell>
  334. {ACTIONS.map((x) => (
  335. <TableCell key={x} className="text-center">
  336. <Switch
  337. checked={
  338. (privileges[role.name] as Privileges)?.[x as keyof Privileges] ??
  339. false
  340. }
  341. onCheckedChange={(value) => onTogglePrivilege(role.name, x, value)}
  342. />
  343. </TableCell>
  344. ))}
  345. </TableRow>
  346. )
  347. })}
  348. </TableBody>
  349. </Table>
  350. </SheetSection>
  351. <SheetFooter>
  352. <Button type="default" disabled={isSaving} onClick={() => setOpen(false)}>
  353. Cancel
  354. </Button>
  355. <Button type="primary" loading={isSaving} onClick={onSaveConfiguration}>
  356. Save changes
  357. </Button>
  358. </SheetFooter>
  359. </SheetContent>
  360. </Sheet>
  361. )
  362. }