mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 14:19:11 +03:00
## 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>
386 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|