Files
supabase/apps/studio/data/privileges/table-api-access-query.ts
T
Saxon FletcherandCharis Lam 0dab4d93fe Enable or disable Data API access per table (#41218)
* feat(studio): add mutation to update table access privileges

Adds a mutation to update table grants for `anon` and `authenticated`
roles.

* feat(studio): add data api toggles

Add toggles to the Table Editor, allowing fine-grained grants to the
anon and authenticated roles.

* fix(studio): don't show rls policies warning if table not exposed

RLS policies warning was showing in Table Editor side panel even if
table was not exposed due to no grants to anon/authenticated.

* fixup! feat(studio): add data api toggles

* fixup! feat(studio): add data api toggles

* fix(studio): revalidate rls lints when table grants are toggled

---------

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
2026-01-07 12:12:15 -05:00

212 lines
5.5 KiB
TypeScript

import { useMemo } from 'react'
import type { ConnectionVars } from 'data/common.types'
import { useIsSchemaExposed } from 'hooks/misc/useIsSchemaExposed'
import { isApiAccessRole, isApiPrivilegeType, type ApiPrivilegesByRole } from 'lib/data-api-types'
import type { Prettify } from 'lib/type-helpers'
import type { UseCustomQueryOptions } from 'types'
import {
useTablePrivilegesQuery,
type TablePrivilegesData,
type TablePrivilegesError,
} from './table-privileges-query'
// 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: [],
}
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'
export type TableApiAccessData =
| {
apiAccessType: 'access'
privileges: ApiPrivilegesByRole
}
| {
apiAccessType: 'none' | 'exposed-schema-no-grants'
}
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: [] }
const hasAnonOrAuthenticatedPrivileges =
tablePrivileges.anon.length > 0 || tablePrivileges.authenticated.length > 0
resultData[tableName] = hasAnonOrAuthenticatedPrivileges
? {
apiAccessType: 'access',
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
}