Files
supabase/apps/studio/data/privileges/table-api-access-query.ts
Alaister YoungandAlaister Young d272c15d8d [FE-2792] feat(studio): unify table exposure check on RLS policies page (#45041)
Fixes the RLS policies page showing self-contradictory or wrong
admonitions for tables with partial grants. Classifies each table using
the same `granted / custom / revoked` semantics used by the Data API
settings page so the two views agree on what counts as "exposed".

**Changed:**
- `PolicyTableRow` now uses `useTableApiAccessQuery` (shared cache with
the Table Editor sidebar) instead of a bespoke
`tables-roles-access-query`
- Boolean soup collapsed into a single `TableDataApiStatus`
discriminated union (`schema-not-exposed | no-grants | custom-grants |
publicly-readable | locked-by-rls | secured`) via a pure helper
- Admonition copy for `no-grants` and `locked-by-rls` updated; a table
with no policies but full grants now reads "No data will be returned via
the Data API as no RLS policies exist on this table." instead of the
earlier self-contradictory "can be accessed but no data will be
returned"
- `table-api-access-query.ts` now exposes a `grantStatus: 'granted' |
'custom'` on `access` entries — `granted` = all 3 API roles × all 4 CRUD
privileges (matches `getTableGrantsCTEs` in pg-meta)

**Added:**
- New `custom-grants` admonition: "This table has custom Data API
permissions — access may be restricted for some roles or operations."
- Unit tests for `getTableDataApiStatus`, `getTableAdmonitionMessage`,
and `isFullyGranted`

**Removed:**
- `data/tables/tables-roles-access-query.ts` and the `rolesAccess` key —
no more callers

## To test

On a project with the `public` schema exposed, for each scenario check
the admonition shown on `/project/{ref}/auth/policies`:

1. Table with full standard grants, RLS on, no policies → "No data will
be returned via the Data API as no RLS policies exist on this table."
2. Table with full standard grants, RLS off → yellow warning "can be
accessed by anyone"
3. Table with partial grants (e.g. only `GRANT SELECT ON t TO anon`) →
new "custom Data API permissions" admonition regardless of RLS state
4. Table with no anon/authenticated/service_role grants → "cannot be
accessed via the Data API"
5. Schema not in the exposed list → "schema not exposed" admonition with
link

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

* **Tests**
* Added unit tests covering table Data API/RLS status classification and
API grant validation.

* **Refactor**
* Introduced a unified per-table API/RLS status model and reusable
utilities to derive display status and admonitions.
* Simplified UI logic to drive access indicators and warnings from the
new status.

* **Chores**
  * Removed legacy role-based access query and its related keying logic.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-04-20 22:35:51 +08:00

246 lines
6.6 KiB
TypeScript

import { useMemo } from 'react'
import {
useTablePrivilegesQuery,
type TablePrivilegesData,
type TablePrivilegesError,
} from './table-privileges-query'
import type { ConnectionVars } from '@/data/common.types'
import { useIsSchemaExposed } from '@/hooks/misc/useIsSchemaExposed'
import {
API_ACCESS_ROLES,
API_PRIVILEGE_TYPES,
isApiAccessRole,
isApiPrivilegeType,
type ApiPrivilegesByRole,
} from '@/lib/data-api-types'
import type { Prettify } from '@/lib/type-helpers'
import type { UseCustomQueryOptions } from '@/types'
// The contents of this array are never used, so any will allow
// it to be used anywhere an array of any type is required.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const STABLE_EMPTY_ARRAY: any[] = []
const STABLE_EMPTY_OBJECT = {}
const getApiPrivilegesByRole = (
privileges: TablePrivilegesData[number]['privileges']
): ApiPrivilegesByRole => {
const privilegesByRole: ApiPrivilegesByRole = {
anon: [],
authenticated: [],
service_role: [],
}
privileges.forEach((privilege) => {
const { grantee, privilege_type } = privilege
if (isApiAccessRole(grantee) && isApiPrivilegeType(privilege_type)) {
privilegesByRole[grantee].push(privilege_type)
}
})
return privilegesByRole
}
const mapPrivilegesByTableName = (
privileges: TablePrivilegesData | undefined,
schemaName: string,
tableNames: Set<string>
): Record<string, ApiPrivilegesByRole> => {
if (!privileges) return {}
const result: Record<string, ApiPrivilegesByRole> = {}
privileges.forEach((entry) => {
if (entry.schema !== schemaName) return
if (!tableNames.has(entry.name)) return
result[entry.name] = getApiPrivilegesByRole(entry.privileges)
})
return result
}
export type UseTableApiAccessQueryParams = Prettify<
ConnectionVars & {
schemaName: string
tableNames: string[]
}
>
export type DataApiAccessType = 'none' | 'exposed-schema-no-grants' | 'access'
/**
* Mirrors the "granted | custom | revoked" classification used by the Data API
* settings page (see getTableGrantsCTEs in packages/pg-meta privileges.ts).
* - `granted`: all 3 API roles (anon/authenticated/service_role) have all 4
* CRUD privileges — the standard Data API exposure.
* - `custom`: at least one grant exists but it's not the full standard set.
* - `revoked`: no API role has any privilege (mapped to `exposed-schema-no-grants`).
*/
export type TableGrantStatus = 'granted' | 'custom'
export type TableApiAccessData =
| {
apiAccessType: 'access'
grantStatus: TableGrantStatus
privileges: ApiPrivilegesByRole
}
| {
apiAccessType: 'none' | 'exposed-schema-no-grants'
}
/**
* Matches the "granted" branch of getTableGrantsCTEs in packages/pg-meta's
* privileges.ts — all 3 API roles must have all 4 CRUD privileges.
*/
export const isFullyGranted = (privileges: ApiPrivilegesByRole): boolean =>
API_ACCESS_ROLES.every((role) =>
API_PRIVILEGE_TYPES.every((priv) => privileges[role].includes(priv))
)
export type TableApiAccessMap = Prettify<Record<string, TableApiAccessData>>
export type UseTableApiAccessQueryReturn =
| {
data: TableApiAccessMap
status: 'success'
isSuccess: true
isPending: false
isError: false
}
| {
data: undefined
status: 'pending'
isSuccess: false
isPending: true
isError: false
}
| {
data: undefined
status: 'error'
isSuccess: false
isPending: false
isError: true
}
export const useTableApiAccessQuery = (
{
projectRef,
connectionString,
schemaName,
tableNames = STABLE_EMPTY_ARRAY,
}: UseTableApiAccessQueryParams,
{
enabled = true,
...options
}: { enabled?: boolean } & Omit<
UseCustomQueryOptions<TablePrivilegesData, TablePrivilegesError>,
'enabled'
> = {}
): UseTableApiAccessQueryReturn => {
const uniqueTableNames = useMemo(() => {
return new Set(
tableNames.filter((tableName) => typeof tableName === 'string' && tableName.length > 0)
)
}, [tableNames])
const hasTables = uniqueTableNames.size > 0
const schemaExposureStatus = useIsSchemaExposed({ projectRef, schemaName }, { enabled })
const isSchemaExposed = schemaExposureStatus.isSuccess && schemaExposureStatus.data === true
const enablePrivilegesQuery = enabled && hasTables
const privilegeStatus = useTablePrivilegesQuery(
{ projectRef, connectionString },
{ enabled: enablePrivilegesQuery, ...options }
)
const result: UseTableApiAccessQueryReturn = useMemo(() => {
const isPending =
!enabled ||
schemaExposureStatus.status === 'pending' ||
(enablePrivilegesQuery && privilegeStatus.isPending)
if (isPending) {
return {
data: undefined,
status: 'pending',
isSuccess: false,
isPending: true,
isError: false,
}
}
const isError =
schemaExposureStatus.status === 'error' || (enablePrivilegesQuery && privilegeStatus.isError)
if (isError) {
return {
data: undefined,
status: 'error',
isSuccess: false,
isPending: false,
isError: true,
}
}
if (!hasTables) {
return {
data: STABLE_EMPTY_OBJECT,
status: 'success',
isSuccess: true,
isPending: false,
isError: false,
}
}
const resultData: TableApiAccessMap = {}
const tablePrivilegesByName = isSchemaExposed
? mapPrivilegesByTableName(privilegeStatus.data, schemaName, uniqueTableNames)
: {}
uniqueTableNames.forEach((tableName) => {
if (!isSchemaExposed) {
resultData[tableName] = { apiAccessType: 'none' }
return
}
const tablePrivileges = tablePrivilegesByName[tableName] ?? {
anon: [],
authenticated: [],
service_role: [],
}
const hasApiPrivileges =
tablePrivileges.anon.length > 0 ||
tablePrivileges.authenticated.length > 0 ||
tablePrivileges.service_role.length > 0
resultData[tableName] = hasApiPrivileges
? {
apiAccessType: 'access',
grantStatus: isFullyGranted(tablePrivileges) ? 'granted' : 'custom',
privileges: tablePrivileges,
}
: { apiAccessType: 'exposed-schema-no-grants' }
})
return {
data: resultData,
status: 'success',
isSuccess: true,
isPending: false,
isError: false,
}
}, [
enabled,
enablePrivilegesQuery,
hasTables,
schemaExposureStatus.status,
isSchemaExposed,
privilegeStatus.isPending,
privilegeStatus.isError,
privilegeStatus.data,
schemaName,
uniqueTableNames,
])
return result
}