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 -->
This commit is contained in:
Charis authored and GitHub committed 2026-07-31 15:26:37 -04:00
1 parent d47747477d
commit ec64135f9d
8 files changed
+410 -52

No files matched your search

@@ -265,7 +265,15 @@ export function usePrivilegesState({
}
}
export function useApplyPrivilegeOperations(callback?: () => void) {
export function useApplyPrivilegeOperations({
schema,
table,
onSuccess,
}: {
schema: string
table?: string
onSuccess?: () => void
}) {
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
@@ -343,17 +351,19 @@ export function useApplyPrivilegeOperations(callback?: () => void) {
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: privilegeKeys.tablePrivilegesList(project.ref) }),
queryClient.invalidateQueries({
queryKey: privilegeKeys.columnPrivilegesList(project.ref),
queryKey: privilegeKeys.tablePrivilegesList(project.ref, [schema]),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.columnPrivilegesList(project.ref, schema, table),
}),
])
setIsLoading(false)
callback?.()
onSuccess?.()
},
[callback, project, queryClient]
[onSuccess, project, queryClient, schema, table]
)
return { apply, isLoading }
@@ -5,19 +5,24 @@ import { z } from 'zod'
import { executeSql } from '../sql/execute-sql-mutation'
import { privilegeKeys } from './keys'
import type { components } from '@/data/api'
import { isScopedIntrospection, scopedIntrospectionReady } from '@/data/scoped-introspection'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type ColumnPrivilegesVariables = {
projectRef?: string
connectionString?: string | null
/**
* [Joshen] Specifically requiring schema to prevent a heavy query on the DB
* The only UI using this is the column privileges UI atm, so opting to be strict here
* Ideally we'd be able to also filter based on table to be even more prudent, but
* leaving out for now as it needs update to pg-meta + might not be worth the over-optimization
* given this UI isn't a primary tool
* Required to keep this off the whole-catalog path: without it the query
* aclexplodes every relation in the database across every one of its columns.
*/
schema: string
/**
* Restricts to a single relation. The UI only ever renders one table at a
* time, so this keeps the query scoped to what's on screen -- without it the
* whole schema's columns are fetched and all but one table discarded. The
* query stays disabled until a table is selected.
*/
table?: string
}
export type ColumnPrivilege = components['schemas']['PostgresColumnPrivileges']
@@ -26,13 +31,19 @@ const pgMetaColumnPrivilegesList = pgMeta.columnPrivileges.list()
type ColumnPrivilegesData = z.infer<typeof pgMetaColumnPrivilegesList.zod>
export async function getColumnPrivileges(
{ projectRef, connectionString, schema }: ColumnPrivilegesVariables,
{ projectRef, connectionString, schema, table }: ColumnPrivilegesVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const sql = pgMeta.columnPrivileges.list({ includedSchemas: [schema] }).sql
const queryKey = ['column-privileges', schema]
// Cold-load race guard -- see the module comment on scoped-introspection.ts.
await scopedIntrospectionReady()
const sql = pgMeta.columnPrivileges.list({
includedSchemas: [schema],
relationName: table,
scoped: isScopedIntrospection(),
}).sql
const queryKey = ['column-privileges', schema, table]
const { result } = await executeSql({ projectRef, connectionString, sql, queryKey }, signal)
return result as ColumnPrivilegesData
}
@@ -46,11 +57,15 @@ export const useColumnPrivilegesQuery = <TData = ColumnPrivilegesData>(
...options
}: UseCustomQueryOptions<ColumnPrivilegesData, ColumnPrivilegesError, TData> = {}
) => {
const { projectRef, schema } = vars
const { projectRef, schema, table } = vars
return useQuery<ColumnPrivilegesData, ColumnPrivilegesError, TData>({
queryKey: privilegeKeys.columnPrivilegesList(projectRef, schema),
queryKey: privilegeKeys.columnPrivilegesList(projectRef, schema, table),
queryFn: ({ signal }) => getColumnPrivileges(vars, signal),
enabled: enabled && typeof projectRef !== 'undefined' && typeof schema !== 'undefined',
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
typeof schema !== 'undefined' &&
typeof table !== 'undefined',
...options,
})
}
+9 -2
View File
@@ -1,8 +1,15 @@
export const privilegeKeys = {
tablePrivilegesList: (projectRef: string | undefined, includedSchemas?: string[]) =>
['projects', projectRef, 'database', 'table-privileges', includedSchemas].filter(Boolean),
columnPrivilegesList: (projectRef: string | undefined, schema?: string | undefined) =>
['projects', projectRef, 'database', 'column-privileges', schema].filter(Boolean),
columnPrivilegesList: (
projectRef: string | undefined,
schema?: string | undefined,
table?: string | undefined
) => {
const base = ['projects', projectRef, 'database', 'column-privileges'].filter(Boolean)
if (table === undefined) return schema === undefined ? base : [...base, schema]
return [...base, schema ?? null, table]
},
exposedTablesInfinite: (projectRef: string | undefined, search?: string) =>
[
'projects',
@@ -55,26 +55,26 @@ const PrivilegesPage: NextPageWithLayout = () => {
} = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
})
// Default to (or fall back to) the first table of the selected schema. The
// fallback matters because the schema can also change via the URL, which
// leaves `selectedTable` pointing at a table from the previous schema.
useEffect(() => {
if (!isSuccessTables) return
const tables = tableList
.filter((table) => table.schema === selectedSchema)
.map((table) => table.name)
if (tables[0] && selectedTable === undefined) {
setSelectedTable(tables[0])
const tableNames = tableList.map((table) => table.name)
if (selectedTable === undefined || !tableNames.includes(selectedTable)) {
setSelectedTable(tableNames[0])
}
}, [isSuccessTables, tableList, selectedSchema, selectedTable])
}, [isSuccessTables, tableList, selectedTable])
const { data: allRoles, isPending: isLoadingRoles } = useDatabaseRolesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const tables = tableList
?.filter((table) => table.schema === selectedSchema)
.map((table) => table.name)
const tables = tableList?.map((table) => table.name)
const {
data: allTablePrivileges,
@@ -104,38 +104,34 @@ const PrivilegesPage: NextPageWithLayout = () => {
const {
data: allColumnPrivileges,
isPending: isLoadingColumnPrivileges,
isPending: isPendingColumnPrivileges,
isError: isErrorColumnPrivileges,
error: errorColumnPrivileges,
} = useColumnPrivilegesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: selectedSchema,
table: selectedTable,
})
// The query is disabled until a table is selected, and a disabled query stays
// `isPending` forever -- so don't report it as loading in that state, or a
// schema with no tables never gets past the skeleton.
const isLoadingColumnPrivileges = selectedTable !== undefined && isPendingColumnPrivileges
const columnPrivileges = useMemo(
() =>
allColumnPrivileges
?.filter(
(privilege) =>
privilege.relation_schema === selectedSchema &&
privilege.relation_name === selectedTable
)
.map((privilege) => ({
...privilege,
privileges: privilege.privileges.filter(
(privilege) => privilege.grantee === selectedRole
),
})) ?? [],
[allColumnPrivileges, selectedRole, selectedSchema, selectedTable]
allColumnPrivileges?.map((privilege) => ({
...privilege,
privileges: privilege.privileges.filter((privilege) => privilege.grantee === selectedRole),
})) ?? [],
[allColumnPrivileges, selectedRole]
)
const rolesList = allRoles?.filter((role: PgRole) => EDITABLE_ROLES.includes(role.name)) ?? []
const roles = rolesList.map((role: PgRole) => role.name)
const table = tableList?.find(
(table) => table.schema === selectedSchema && table.name === selectedTable
)
const table = tableList?.find((table) => table.name === selectedTable)
const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
const {
@@ -170,9 +166,10 @@ const PrivilegesPage: NextPageWithLayout = () => {
}
}
const newTable = tableList?.find((table) => table.schema === schema)?.name
setSelectedSchema(schema)
setSelectedTable(newTable)
// The table list is scoped to the schema, so the incoming schema's tables
// aren't loaded yet -- the effect above picks the first one once they are.
setSelectedTable(undefined)
}
const handleChangeTable = (table: string) => {
@@ -192,15 +189,17 @@ const PrivilegesPage: NextPageWithLayout = () => {
}
const { apply: applyColumnPrivileges, isLoading: isApplyingChanges } =
useApplyPrivilegeOperations(
useCallback(() => {
useApplyPrivilegeOperations({
schema: selectedSchema,
table: selectedTable,
onSuccess: useCallback(() => {
toast.success(
`Successfully updated privileges on ${selectedSchema}.${selectedTable} for ${selectedRole}`,
{ duration: 6000 }
)
resetOperations()
}, [resetOperations, selectedRole, selectedSchema, selectedTable])
)
}, [resetOperations, selectedRole, selectedSchema, selectedTable]),
})
function applyChanges() {
applyColumnPrivileges(operations)
@@ -10,7 +10,7 @@ import {
safeSql,
type SafeSqlFragment,
} from './pg-format'
import { COLUMN_PRIVILEGES_SQL } from './sql/column-privileges'
import { COLUMN_PRIVILEGES_SQL, getScopedColumnPrivilegesSql } from './sql/column-privileges'
const pgColumnPrivilegeGrant = z.object({
grantor: z.string(),
@@ -50,19 +50,60 @@ function list({
includedSchemas,
excludedSchemas,
columnIds,
relationName,
limit,
offset,
scoped = false,
}: {
includeSystemSchemas?: boolean
includedSchemas?: string[]
excludedSchemas?: string[]
columnIds?: string[]
/** Restricts to a single relation by name. Pair with `includedSchemas`. */
relationName?: string
limit?: number
offset?: number
scoped?: boolean
} = {}): {
sql: SafeSqlFragment
zod: typeof pgColumnPrivilegesArrayZod
} {
// Scoped path: the base query prunes pg_class to the requested relations
// before exploding ACLs across their columns, instead of exploding the whole
// catalog and filtering the aggregate.
if (scoped) {
const base = getScopedColumnPrivilegesSql({
includeSystemSchemas,
includedSchemas,
excludedSchemas,
relationName,
// `columnIds` are "<attrelid>.<attnum>" pairs. Their relation half narrows
// the base scan; the exact attnum half stays an outer predicate below.
relationIds: columnIds?.length
? [...new Set(columnIds.map((columnId) => columnId.split('.')[0]))]
: undefined,
})
let sql = safeSql`
with column_privileges as (${base})
select *
from column_privileges
`
if (columnIds?.length) {
sql = safeSql`${sql} where column_id in (${joinSqlFragments(columnIds.map(literal), ',')})`
}
if (limit) {
sql = safeSql`${sql} limit ${literal(limit)}`
}
if (offset) {
sql = safeSql`${sql} offset ${literal(offset)}`
}
return {
sql,
zod: pgColumnPrivilegesArrayZod,
}
}
let sql = safeSql`
with column_privileges as (${COLUMN_PRIVILEGES_SQL})
select *
@@ -80,6 +121,10 @@ function list({
conditions.push(safeSql`relation_schema ${filter}`)
}
if (relationName) {
conditions.push(safeSql`relation_name = ${literal(relationName)}`)
}
if (columnIds?.length) {
conditions.push(safeSql`column_id in (${joinSqlFragments(columnIds.map(literal), ',')})`)
}
+149 -1
View File
@@ -1,6 +1,12 @@
import { safeSql } from '../pg-format'
import { DEFAULT_SYSTEM_SCHEMAS } from '../constants'
import { filterByList } from '../helpers'
import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../pg-format'
export const COLUMN_PRIVILEGES_SQL = /* SQL */ safeSql`
-- FROZEN legacy path: served while the pgMetaScopedIntrospection flag is off.
-- Do not edit -- it must keep matching production behavior until the flag
-- cleanup deletes it. getScopedColumnPrivilegesSql is the replacement.
--
-- Lists each column's privileges in the form of:
--
-- [
@@ -147,3 +153,145 @@ group by column_id,
x.relname,
x.attname
`
export type ColumnPrivilegesScope = {
/** Defaults to false, which excludes {@link DEFAULT_SYSTEM_SCHEMAS}. */
includeSystemSchemas?: boolean
includedSchemas?: Array<string>
excludedSchemas?: Array<string>
/** Restricts to a single relation by name. Pair with `includedSchemas`. */
relationName?: string
/** Restricts to specific relations by oid. */
relationIds?: Array<string>
}
/**
* Scoped variant of {@link COLUMN_PRIVILEGES_SQL}: prunes pg_class/pg_namespace
* FIRST (in the `rel` CTE) so the scope predicate can drive an index scan,
* instead of aclexploding the whole catalog and filtering the aggregated result.
*/
export const getScopedColumnPrivilegesSql = ({
includeSystemSchemas = false,
includedSchemas,
excludedSchemas,
relationName,
relationIds,
}: ColumnPrivilegesScope = {}): SafeSqlFragment => {
const conditions: Array<SafeSqlFragment> = []
const schemaFilter = filterByList(
includedSchemas,
excludedSchemas,
!includeSystemSchemas ? DEFAULT_SYSTEM_SCHEMAS : undefined
)
if (schemaFilter) {
conditions.push(safeSql`and nc.nspname ${schemaFilter}`)
}
if (relationName) {
conditions.push(safeSql`and c.relname = ${literal(relationName)}`)
}
if (relationIds?.length) {
conditions.push(safeSql`and c.oid in (${joinSqlFragments(relationIds.map(literal), ',')})`)
}
const scopeFilter = joinSqlFragments(conditions, '\n')
return safeSql`
with rel as (
select
c.oid,
c.relname,
c.relowner,
c.relacl,
nc.nspname
from pg_class c
join pg_namespace nc
on nc.oid = c.relnamespace
where c.relkind = any (array['r', 'v', 'm', 'f', 'p'])
${scopeFilter}
),
roles as (
select
r.oid,
r.rolname,
pg_has_role(r.oid, 'USAGE') as is_member
from pg_authid r
),
grantees as (
select oid, rolname, is_member, false as is_public from roles
union all
select (0)::oid as oid, 'PUBLIC', false, true
),
priv as (
-- Table-level ACLs apply to every live column of the relation.
select
a.attrelid,
a.attnum,
a.attname,
r.relname,
r.nspname,
p.grantor,
p.grantee,
p.privilege_type as prtype,
p.is_grantable as grantable
from rel r
cross join lateral aclexplode(coalesce(r.relacl, acldefault('r', r.relowner))) p
join pg_attribute a
on a.attrelid = r.oid
and a.attnum > 0
and not a.attisdropped
where p.privilege_type = any (array['INSERT', 'SELECT', 'UPDATE', 'REFERENCES'])
union
-- Column-level ACLs.
select
a.attrelid,
a.attnum,
a.attname,
r.relname,
r.nspname,
p.grantor,
p.grantee,
p.privilege_type,
p.is_grantable
from rel r
join pg_attribute a
on a.attrelid = r.oid
and a.attnum > 0
and not a.attisdropped
cross join lateral aclexplode(coalesce(a.attacl, acldefault('c', r.relowner))) p
where a.attacl is not null
and p.privilege_type = any (array['INSERT', 'SELECT', 'UPDATE', 'REFERENCES'])
)
select
(p.attrelid || '.' || p.attnum) as column_id,
p.nspname as relation_schema,
p.relname as relation_name,
p.attname as column_name,
coalesce(
jsonb_agg(
jsonb_build_object(
'grantor', grantor.rolname,
'grantee', grantee.rolname,
'privilege_type', p.prtype,
'is_grantable', p.grantable
)
),
'[]'
) as privileges
from priv p
join roles grantor
on grantor.oid = p.grantor
join grantees grantee
on grantee.oid = p.grantee
where grantor.is_member
or grantee.is_member
or grantee.is_public
group by
p.attrelid,
p.attnum,
p.nspname,
p.relname,
p.attname
`
}
@@ -355,3 +355,80 @@ withTestDatabase(
)
}
)
withTestDatabase('scoped list matches the legacy path row-for-row', async ({ executeQuery }) => {
await executeQuery(`
drop role if exists col_grantee_a;
drop role if exists col_grantee_b;
create role col_grantee_a;
create role col_grantee_b;
create table public.col_priv_demo (id int primary key, data text, extra text);
grant select, insert on public.col_priv_demo to col_grantee_a;
grant update (data) on public.col_priv_demo to col_grantee_b with grant option;
grant references (extra) on public.col_priv_demo to col_grantee_a;
grant select on public.col_priv_demo to public;
create view public.col_priv_view as select id, data from public.col_priv_demo;
grant select (id) on public.col_priv_view to col_grantee_b;
`)
// Neither path orders rows or the privileges array (see the note on the first
// test in this file), and the two plans emit them in different orders, so
// compare on normalized copies.
const normalize = <T extends { column_id: string; privileges: Array<unknown> }>(rows: Array<T>) =>
rows
.map((row) => ({
...row,
privileges: [...row.privileges].sort((a, b) =>
JSON.stringify(a).localeCompare(JSON.stringify(b))
),
}))
.sort((a, b) => a.column_id.localeCompare(b.column_id))
for (const options of [
{},
{ includedSchemas: ['public'] },
{ excludedSchemas: ['public'] },
{ includedSchemas: ['public'], relationName: 'col_priv_demo' },
{ includedSchemas: ['public'], relationName: 'col_priv_view' },
{ includedSchemas: ['public'], relationName: 'does_not_exist' },
{ includeSystemSchemas: true, includedSchemas: ['public'] },
]) {
const legacy = pgMeta.columnPrivileges.list(options)
const scoped = pgMeta.columnPrivileges.list({ ...options, scoped: true })
const legacyRes = legacy.zod.parse(await executeQuery(legacy.sql))
const scopedRes = scoped.zod.parse(await executeQuery(scoped.sql))
expect(normalize(scopedRes), `list options: ${JSON.stringify(options)}`).toEqual(
normalize(legacyRes)
)
// Row counts must match exactly, independent of the normalization above.
expect(scopedRes.length, `row count for options: ${JSON.stringify(options)}`).toBe(
legacyRes.length
)
}
// The scoped path must surface the PUBLIC grantee, and the column-level grants
// that only the second UNION arm can produce.
const { sql, zod } = pgMeta.columnPrivileges.list({
includedSchemas: ['public'],
relationName: 'col_priv_demo',
scoped: true,
})
const rows = zod.parse(await executeQuery(sql))
const dataColumn = rows.find((r) => r.column_name === 'data')!
expect(rows.map((r) => r.column_name).sort()).toEqual(['data', 'extra', 'id'])
expect(dataColumn.privileges.some((p) => p.grantee === 'PUBLIC')).toBe(true)
expect(
dataColumn.privileges.some(
(p) => p.grantee === 'col_grantee_b' && p.privilege_type === 'UPDATE' && p.is_grantable
)
).toBe(true)
// columnIds: the scoped path prunes pg_class by relation oid and keeps the
// exact attnum predicate outside, so it must still match legacy exactly.
const columnIds = rows.map((r) => r.column_id).slice(0, 2)
const legacyByIds = pgMeta.columnPrivileges.list({ columnIds })
const scopedByIds = pgMeta.columnPrivileges.list({ columnIds, scoped: true })
expect(normalize(scopedByIds.zod.parse(await executeQuery(scopedByIds.sql)))).toEqual(
normalize(legacyByIds.zod.parse(await executeQuery(legacyByIds.sql)))
)
})
@@ -13,6 +13,7 @@ import {
getTablesPaginatedSql,
getViewDefinitionSql,
} from '../../../src'
import columnPrivileges from '../../../src/pg-meta-column-privileges'
import tablePrivileges from '../../../src/pg-meta-table-privileges'
import * as tables from '../../../src/pg-meta-tables'
import * as types from '../../../src/pg-meta-types'
@@ -374,6 +375,62 @@ test('tablePrivileges.retrieve: scoped plan stays scoped for a single relation',
assertPlanWithinBudget(result, TABLE_PRIVILEGES_BUDGET)
}, 60_000)
// ── columnPrivileges.list (sql/column-privileges.ts) — per-table ─────────────
// The Studio hot path: the column-level privileges page renders exactly one
// table, so the query is scoped to schema+relname. Scoped prunes pg_class in the
// `rel` CTE before the aclexplode laterals / UNION / GROUP BY, so pg_class is
// reached via its (relname, relnamespace) index and pg_attribute via
// pg_attribute_relid_attnum_index.
//
// Only pg_authid is seq-scanned, once: the `roles` CTE is referenced by both the
// grantor join and the `grantees` UNION, so it materializes and pg_has_role() is
// evaluated once per role instead of twice per privilege row. (Table privileges
// needs two scans here because it joins the pg_roles view twice.)
const COLUMN_PRIVILEGES_TABLE_SCOPED_BUDGET = {
allowedSeqScans: {
pg_authid: {
max: 1,
reason:
'the `roles` CTE materializes once and feeds both the grantor join and the grantee-with-PUBLIC union; pg_authid scales with role count, not schema size',
},
},
}
test('columnPrivileges.list: scoped plan stays scoped for a single table', async () => {
const result = await explainAnalyze(
db,
columnPrivileges.list({
includedSchemas: ['stress'],
relationName: 't_1000',
scoped: true,
}).sql
)
assertPlanWithinBudget(result, COLUMN_PRIVILEGES_TABLE_SCOPED_BUDGET)
}, 60_000)
// ── columnPrivileges.list (sql/column-privileges.ts) — per-schema listing ────
// Studio always passes a relation (above); a schema-wide call is still supported
// for pg-meta consumers. Here one filtered pg_class seq scan is the right plan
// rather than a regression: the whole `stress` schema is most of the catalog.
const COLUMN_PRIVILEGES_SCHEMA_SCOPED_BUDGET = {
allowedSeqScans: {
...COLUMN_PRIVILEGES_TABLE_SCOPED_BUDGET.allowedSeqScans,
pg_class: {
max: 1,
reason:
'schema-wide listing selects nearly every relation in the schema, so one filtered pg_class scan beats per-relation index lookups; pg_attribute stays index-driven',
},
},
}
test('columnPrivileges.list: scoped plan stays scoped for a schema', async () => {
const result = await explainAnalyze(
db,
columnPrivileges.list({ includedSchemas: ['stress'], scoped: true }).sql
)
assertPlanWithinBudget(result, COLUMN_PRIVILEGES_SCHEMA_SCOPED_BUDGET)
}, 60_000)
// ── tables.retrieve (pg-meta-tables.ts / sql/tables.ts) — single table ───────
// Scoped pushes the target OID (a literal, or an initplan scalar subquery
// resolved via pg_class's (relname, relnamespace) index) into the base scan and