Files
Joshen LimandAli Waseem 4691372093 Joshen/fe 3610 projectneedssecuring to avoid fetching all table privileges (#46929)
## Context

The dashboard has an RQ hook that fetches all table privileges in the
database `useTablePrivilegesQuery`
[here](https://github.com/supabase/supabase/blob/master/apps/studio/data/privileges/table-privileges-query.ts#L21)
which can potentially be a resource heavy query on the database,
especially if the database has a large number of relations.

A recent UI that was added `ProjectNeedsSecuring` uses that query, and
has become a common entry point for all projects as it's rendered when
the user lands on the project's home page, and the project has tables
with RLS issues, in which case if the project has a large number of
tables, the database will face run into resource issues, resulting in
statement timeouts.

## Changes involved

Opting to pass in `includedSchemas` parameter wherever we're calling
`useTablePrivilegesQuery`, which includes:
- `ProjectNeedsSecuring`
- `QueueSettings`
- `column-privileges`
In which we'll hence only fetch the table privileges for the provided
schemas only (rather than the whole DB)

Also did a similar fix for `useColumnPrivilegesQuery` as well as it
likely runs into the same problem

## To test
- [ ] Verify that those 3 UIs are still working as expected (should not
have any visual changes)
- [ ] Verify in the network tab that table / column privileges are now
filtered to the schema provided, rather than fetching for all schemas in
the DB

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved permission-save failure messaging by generating clearer toast
errors from unexpected failures.
* Prevented privilege-related UI from loading until required
configuration is successfully retrieved.
* **Performance**
* Faster, more targeted privilege loading by scoping both table and
column privilege queries to the selected/relevant schema(s), reducing
unnecessary client-side filtering.
* Switched privilege retrieval to schema-aware database metadata queries
for more efficient results.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-06-15 08:10:34 -06:00

386 lines
14 KiB
TypeScript

import { useParams } from 'common'
import { isEqual } from 'lodash'
import { HelpCircle, Settings } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { pgmqArchiveTable, pgmqQueueTable } from '../Queues.utils'
import { getQueueFunctionsMapping } from './Queue.utils'
import AlertError from '@/components/ui/AlertError'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query'
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
import {
TablePrivilegesGrant,
useTablePrivilegesGrantMutation,
} from '@/data/privileges/table-privileges-grant-mutation'
import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query'
import {
TablePrivilegesRevoke,
useTablePrivilegesRevokeMutation,
} from '@/data/privileges/table-privileges-revoke-mutation'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { getErrorMessage } from '@/lib/get-error-message'
const ACTIONS = ['select', 'insert', 'update', 'delete']
const ROLES = ['anon', 'authenticated', 'postgres', 'service_role']
type Privileges = { select?: boolean; insert?: boolean; update?: boolean; delete?: boolean }
interface QueueSettingsProps {}
export const QueueSettings = ({}: QueueSettingsProps) => {
const { childId: name } = useParams()
const { data: project } = useSelectedProjectQuery()
const [open, setOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [privileges, setPrivileges] = useState<{ [key: string]: Privileges }>({})
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data,
error,
isPending: isLoading,
isSuccess,
isError,
} = useDatabaseRolesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const roles = (data ?? [])
.filter((x) => ROLES.includes(x.name))
.sort((a, b) => a.name.localeCompare(b.name))
const { data: queueTables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'pgmq',
})
const queueRelname = name ? pgmqQueueTable(name) : undefined
const archiveRelname = name ? pgmqArchiveTable(name) : undefined
const queueTable = queueTables?.find((x) => x.name === queueRelname)
const archiveTable = queueTables?.find((x) => x.name === archiveRelname)
const { data: allTablePrivileges, isSuccess: isSuccessPrivileges } = useTablePrivilegesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
includedSchemas: ['pgmq'],
})
const queuePrivileges = allTablePrivileges?.find((x) => x.name === queueRelname)
const { mutateAsync: grantPrivilege } = useTablePrivilegesGrantMutation()
const { mutateAsync: revokePrivilege } = useTablePrivilegesRevokeMutation()
const onTogglePrivilege = (role: string, action: string, value: boolean) => {
const updatedPrivileges = { ...privileges, [role]: { ...privileges[role], [action]: value } }
setPrivileges(updatedPrivileges)
}
const onSaveConfiguration = async () => {
if (!project) return console.error('Project is required')
if (!queueTable) return console.error('Unable to find queue table')
if (!archiveTable) return console.error('Unable to find archive table')
setIsSaving(true)
const revoke: { role: string; action: string }[] = []
const grant: { role: string; action: string }[] = []
Object.entries(privileges).forEach(([role, p]) => {
const originalRolePrivileges = queuePrivileges?.privileges.filter((x) => x.grantee === role)
Object.entries(p).forEach(([action, value]) => {
const originalValue = !!originalRolePrivileges?.find(
(x) => x.privilege_type.toLowerCase() === action
)
if (value !== originalValue) {
if (value) grant.push({ role, action })
else revoke.push({ role, action })
}
})
})
const rolesBeingGrantedPerms = [...new Set(grant.map((x) => x.role))]
const rolesBeingRevokedPerms = [...new Set(revoke.map((x) => x.role))]
const rolesNoLongerHavingPerms = rolesBeingRevokedPerms.filter((x) => {
const existingPrivileges = queuePrivileges?.privileges
.filter((y) => x === y.grantee)
.map((y) => y.privilege_type)
const privilegesGettingRevoked = revoke
.filter((y) => y.role === x)
.map((y) => y.action.toUpperCase())
const privilegesGettingGranted = grant.filter((y) => y.role === x)
return (
privilegesGettingGranted.length === 0 &&
isEqual(existingPrivileges, privilegesGettingRevoked)
)
})
try {
await Promise.all([
...(revoke.length > 0
? [
revokePrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
revokes: revoke.map((x) => ({
grantee: x.role,
privilegeType: x.action.toUpperCase(),
relationId: queueTable.id,
})) as TablePrivilegesRevoke[],
}),
]
: []),
// Revoke select + insert on archive table only if role no longer has ANY perms on the queue table
...(rolesNoLongerHavingPerms.length > 0
? [
revokePrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
revokes: [
...rolesNoLongerHavingPerms.map((x) => ({
grantee: x,
privilegeType: 'INSERT' as const,
relationId: archiveTable.id,
})),
...rolesNoLongerHavingPerms.map((x) => ({
grantee: x,
privilegeType: 'SELECT' as const,
relationId: archiveTable.id,
})),
],
}),
]
: []),
...(grant.length > 0
? [
grantPrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
grants: grant.map((x) => ({
grantee: x.role,
privilegeType: x.action.toUpperCase(),
relationId: queueTable.id,
})) as TablePrivilegesGrant[],
}),
// Just grant select + insert on archive table as long as we're granting any perms to the queue table for the role
grantPrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
grants: [
...rolesBeingGrantedPerms.map((x) => ({
grantee: x,
privilegeType: 'INSERT' as const,
relationId: archiveTable.id,
})),
...rolesBeingGrantedPerms.map((x) => ({
grantee: x,
privilegeType: 'SELECT' as const,
relationId: archiveTable.id,
})),
],
}),
]
: []),
])
toast.success('Successfully updated permissions')
setOpen(false)
} catch (error: unknown) {
toast.error(`Failed to update permissions: ${getErrorMessage(error)}`)
} finally {
setIsSaving(false)
}
}
useEffect(() => {
if (open && isSuccessPrivileges && queuePrivileges) {
const initialState = queuePrivileges.privileges.reduce<{ [key: string]: Privileges }>(
(a, b) => {
return {
...a,
[b.grantee]: { ...a[b.grantee], [b.privilege_type.toLowerCase()]: true },
}
},
{}
)
setPrivileges(initialState)
}
}, [open, isSuccessPrivileges])
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<ButtonTooltip
type="text"
className="px-1.5"
icon={<Settings />}
title="Settings"
tooltip={{ content: { side: 'bottom', text: 'Queue settings' } }}
/>
</SheetTrigger>
<SheetContent size="lg" className="overflow-auto flex flex-col gap-y-0">
<SheetHeader>
<SheetTitle>Manage queue permissions on {name}</SheetTitle>
<SheetDescription>
Configure permissions for the following roles to grant access to the relevant actions on
the queue.{' '}
{isExposed && (
<>
These will also determine access to each function available from the{' '}
<code className="text-code-inline">pgmq_public</code> schema.
</>
)}
</SheetDescription>
</SheetHeader>
<SheetSection className="p-0 grow">
{!isExposed ? (
<Admonition
type="default"
className="rounded-none border-x-0 border-t-0"
title="Queue permissions are only relevant if exposure through PostgREST has been enabled"
description={
<>
You may opt to manage your queues via any Supabase client libraries or PostgREST
endpoints by enabling this in the{' '}
<Link
href={`/project/${project?.ref}/integrations/queues/settings`}
className="underline transition underline-offset-2 decoration-foreground-lighter hover:decoration-foreground"
>
queues settings
</Link>
</>
}
/>
) : (
<Admonition
type="default"
className="rounded-none border-x-0 border-t-0"
description="Only relevant roles for managing queues via client libraries or PostgREST are shown here."
/>
)}
<Table>
<TableHeader className="[&_th]:h-8">
<TableRow className="py-2">
<TableHead>Role</TableHead>
{ACTIONS.map((x) => {
const relatedFunctions = getQueueFunctionsMapping(x)
return (
<TableHead key={x}>
<Tooltip>
<TooltipTrigger className="mx-auto flex items-center gap-x-1 capitalize text-foreground-light font-normal">
{x}
{isExposed && <HelpCircle size={14} strokeWidth={1.5} />}
</TooltipTrigger>
{isExposed && (
<TooltipContent side="bottom" className="w-64 flex flex-col gap-y-1">
<p>
Required for{' '}
{relatedFunctions.length === 6
? 'all'
: `the following ${relatedFunctions.length}`}{' '}
functions:
</p>
<div className="max-w-full flex flex-wrap gap-x-0.5 gap-y-1">
{relatedFunctions.map((y) => (
<code key={`${x}_${y}`}>{y}</code>
))}
</div>
</TooltipContent>
)}
</Tooltip>
</TableHead>
)
})}
</TableRow>
</TableHeader>
<TableBody className="[&_td]:py-2">
{isLoading && (
<>
<TableRow>
<TableCell colSpan={5}>
<ShimmeringLoader />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={4}>
<ShimmeringLoader />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={3}>
<ShimmeringLoader />
</TableCell>
</TableRow>
</>
)}
{isError && (
<TableRow>
<TableCell colSpan={5}>
<AlertError subject="Failed to retrieve roles" error={error} />
</TableCell>
</TableRow>
)}
{isSuccess &&
(roles ?? []).map((role) => {
return (
<TableRow key={role.id}>
<TableCell>{role.name}</TableCell>
{ACTIONS.map((x) => (
<TableCell key={x} className="text-center">
<Switch
checked={
(privileges[role.name] as Privileges)?.[x as keyof Privileges] ??
false
}
onCheckedChange={(value) => onTogglePrivilege(role.name, x, value)}
/>
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</SheetSection>
<SheetFooter>
<Button type="default" disabled={isSaving} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="primary" loading={isSaving} onClick={onSaveConfiguration}>
Save changes
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}