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