Files
Charis ec64135f9d perf(pg-meta): scope column privileges query to a single table (#48553)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Performance improvement

## What is the current behavior?

The column privileges page in Studio only ever renders one table, but
the underlying query still `aclexplode`s every column in the whole
schema and filters the result client-side.

## What is the new behavior?

Adds a scoped SQL path that prunes `pg_class`/`pg_namespace` to the
requested schema+table before exploding ACLs, gated behind the
`pgMetaScopedIntrospection` flag, with a plan-guard test asserting
`pg_class`/`pg_attribute` stay index-driven. Studio's query hook and
cache keys now thread the selected table through so column-privilege
invalidation and cold-load races are scoped correctly, and the page
fetches per-table instead of per-schema.

## Additional context

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

## Summary by CodeRabbit

* **Improvements**
* Column privileges are now scoped to the selected schema and table for
more accurate results.
* Changing schemas automatically updates the table selection and
refreshes the displayed privileges.
* Privilege updates now refresh only the relevant schema, table, and
column data.
* Loading states are handled more accurately when no table is selected.

* **Bug Fixes**
* Improved consistency between scoped and unscoped column privilege
results, including table-, column-, and grant-option privileges.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-31 15:26:37 -04:00

371 lines
10 KiB
TypeScript

import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import {
ALL_PRIVILEGE_TYPES,
COLUMN_PRIVILEGE_TYPES,
ColumnPrivilegeType,
} from './Privileges.constants'
import { grantColumnPrivileges } from '@/data/privileges/column-privileges-grant-mutation'
import type { ColumnPrivilege } from '@/data/privileges/column-privileges-query'
import {
ColumnPrivilegesRevoke,
revokeColumnPrivileges,
} from '@/data/privileges/column-privileges-revoke-mutation'
import { privilegeKeys } from '@/data/privileges/keys'
import {
grantTablePrivileges,
TablePrivilegesGrant,
} from '@/data/privileges/table-privileges-grant-mutation'
import type { PgTablePrivileges } from '@/data/privileges/table-privileges-query'
import {
revokeTablePrivileges,
TablePrivilegesRevoke,
} from '@/data/privileges/table-privileges-revoke-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export interface PrivilegeOperation {
object: 'table' | 'column'
type: 'grant' | 'revoke'
id: string | number
grantee: string
privilege_type: string
}
export function getDefaultTableCheckedStates(tablePrivilege: PgTablePrivileges) {
return Object.fromEntries(
ALL_PRIVILEGE_TYPES.map((privilege) => [
privilege,
tablePrivilege.privileges.find((p) => p.privilege_type === privilege) !== undefined,
])
)
}
export function getDefaultColumnCheckedStates(columnPrivileges: ColumnPrivilege[]) {
return Object.fromEntries(
columnPrivileges.map((column) => [
column.column_id,
Object.fromEntries(
COLUMN_PRIVILEGE_TYPES.map((privilege) => [
privilege,
column.privileges.find((p) => p.privilege_type === privilege) !== undefined,
])
),
])
)
}
interface UsePrivilegesStateOptions {
tableId: number
role: string
defaultTableCheckedStates: ReturnType<typeof getDefaultTableCheckedStates>
defaultColumnCheckedStates: ReturnType<typeof getDefaultColumnCheckedStates>
}
function addOrRemoveOperation(
operations: PrivilegeOperation[],
operation: PrivilegeOperation,
/** removes old operations and always adds the new one */
forceAdd = false
): PrivilegeOperation[] {
let state = [...operations]
const oppositeType = operation.type === 'grant' ? 'revoke' : 'grant'
const existing = state.find((op) => {
return (
op.object === operation.object &&
op.type === oppositeType &&
op.id === operation.id &&
op.grantee === operation.grantee &&
op.privilege_type === operation.privilege_type
)
})
if (existing !== undefined) {
state = state.filter((op) => op !== existing)
if (!forceAdd) {
return state
}
}
state.push(operation)
return state
}
export function usePrivilegesState({
defaultTableCheckedStates,
defaultColumnCheckedStates,
tableId,
role,
}: UsePrivilegesStateOptions) {
const [operations, setOperations] = useState<PrivilegeOperation[]>([])
const tableCheckedStates = operations.reduce((acc, op) => {
if (op.object === 'table' && op.id === tableId && op.grantee === role) {
return {
...acc,
[op.privilege_type]: op.type === 'grant',
}
}
return acc
}, defaultTableCheckedStates)
const columnCheckedStates = operations.reduce((acc, op) => {
let curr = acc
if (op.object === 'table' && op.grantee === role) {
curr = Object.fromEntries(
Object.entries(curr).map(([id, column]) => [
id,
Object.fromEntries(
Object.entries(column).map(([privilege, value]) => [
privilege,
op.privilege_type === privilege ? op.type === 'grant' : value,
])
),
])
)
}
if (op.object === 'column' && op.grantee === role) {
return {
...curr,
[op.id]: {
...curr[op.id],
[op.privilege_type]: op.type === 'grant',
},
}
}
return curr
}, defaultColumnCheckedStates)
function toggleTablePrivilege(privilegeType: string) {
const shouldGrant = !tableCheckedStates[privilegeType]
setOperations((prevState) => {
let state = [...prevState]
if (COLUMN_PRIVILEGE_TYPES.includes(privilegeType as ColumnPrivilegeType)) {
if (shouldGrant) {
// remove all operations for the columns since
// the table privilege will take precedence
state = state.filter(
(op) =>
!(
op.object === 'column' &&
op.grantee === role &&
op.privilege_type === privilegeType
)
)
}
}
state = addOrRemoveOperation(state, {
object: 'table',
type: shouldGrant ? 'grant' : 'revoke',
id: tableId,
grantee: role,
privilege_type: privilegeType,
})
return state
})
}
function toggleColumnPrivilege(columnId: string, privilegeType: string) {
const shouldGrant = !columnCheckedStates[columnId][privilegeType]
setOperations((prevState) => {
let state = [...prevState]
// if the user is revoking a column and the table is enabled
if (!shouldGrant && tableCheckedStates[privilegeType]) {
// also revoke the table privilege
state = addOrRemoveOperation(state, {
object: 'table',
type: 'revoke',
id: tableId,
grantee: role,
privilege_type: privilegeType,
})
// grant all other enabled columns
const operations = Object.entries(columnCheckedStates)
.filter(([id]) => id !== columnId)
.map(([id, column]) => ({
object: 'column' as const,
type: column[privilegeType] ? ('grant' as const) : ('revoke' as const),
id,
grantee: role,
privilege_type: privilegeType,
}))
operations.forEach((op) => {
state = addOrRemoveOperation(state, op)
})
}
if (shouldGrant) {
const areAllOtherColumnsEnabled = Object.entries(columnCheckedStates).every(
([id, column]) => id === columnId || column[privilegeType]
)
if (areAllOtherColumnsEnabled) {
// remove all operations for the columns since
// the table privilege will take precedence
state = state.filter(
(op) =>
!(
op.object === 'column' &&
op.grantee === role &&
op.privilege_type === privilegeType
)
)
// grant the table privilege
state = addOrRemoveOperation(state, {
object: 'table',
type: 'grant',
id: tableId,
grantee: role,
privilege_type: privilegeType,
})
return state
}
}
state = addOrRemoveOperation(state, {
object: 'column',
type: shouldGrant ? 'grant' : 'revoke',
id: columnId,
grantee: role,
privilege_type: privilegeType,
})
return state
})
}
const resetOperations = useCallback(() => {
setOperations([])
}, [])
return {
tableCheckedStates,
columnCheckedStates,
operations,
toggleTablePrivilege,
toggleColumnPrivilege,
resetOperations,
}
}
export function useApplyPrivilegeOperations({
schema,
table,
onSuccess,
}: {
schema: string
table?: string
onSuccess?: () => void
}) {
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const apply = useCallback(
async (operations: PrivilegeOperation[]) => {
if (!project) return console.error('No project selected')
setIsLoading(true)
const tableOperations = operations.filter((op) => op.object === 'table')
const columnOperations = operations.filter((op) => op.object === 'column')
const grantTableOperations = tableOperations
.filter((op) => op.type === 'grant')
.map((op) => ({
relationId: Number(op.id),
grantee: op.grantee,
privilegeType: op.privilege_type as TablePrivilegesGrant['privilegeType'],
}))
const revokeTableOperations = tableOperations
.filter((op) => op.type === 'revoke')
.map((op) => ({
relationId: Number(op.id),
grantee: op.grantee,
privilegeType: op.privilege_type as TablePrivilegesRevoke['privilegeType'],
}))
const grantColumnOperations = columnOperations
.filter((op) => op.type === 'grant')
.map((op) => ({
column_id: String(op.id),
grantee: op.grantee,
privilege_type: op.privilege_type as ColumnPrivilegesRevoke['privilege_type'],
}))
const revokeColumnOperations = columnOperations
.filter((op) => op.type === 'revoke')
.map((op) => ({
column_id: String(op.id),
grantee: op.grantee,
privilege_type: op.privilege_type as ColumnPrivilegesRevoke['privilege_type'],
}))
// annoyingly these can't be run all at once
// as postgres can't process them in parallel
if (revokeTableOperations.length > 0) {
await revokeTablePrivileges({
projectRef: project.ref,
connectionString: project.connectionString,
revokes: revokeTableOperations,
})
}
if (grantTableOperations.length > 0) {
await grantTablePrivileges({
projectRef: project.ref,
connectionString: project.connectionString,
grants: grantTableOperations,
})
}
if (revokeColumnOperations.length > 0) {
await revokeColumnPrivileges({
projectRef: project.ref,
connectionString: project.connectionString,
revokes: revokeColumnOperations,
})
}
if (grantColumnOperations.length > 0) {
await grantColumnPrivileges({
projectRef: project.ref,
connectionString: project.connectionString,
grants: grantColumnOperations,
})
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: privilegeKeys.tablePrivilegesList(project.ref, [schema]),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.columnPrivilegesList(project.ref, schema, table),
}),
])
setIsLoading(false)
onSuccess?.()
},
[onSuccess, project, queryClient, schema, table]
)
return { apply, isLoading }
}