mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
**Changes:**
- Add function exposure controls to Data API settings
- Adds a per-function GRANT/REVOKE UI to the Data API settings page,
alongside the existing table selector.
- Empty state: shows "No tables/functions available" instead of "0 of 0
X exposed" when there's nothing to show
- Renamed update-exposed-tables-mutation →
update-exposed-entities-mutation to handle both tables and functions in
one save
**Decisions of note:**
- Functions are grouped by schema.name across overloads — toggling one
entry grants/revokes `EXECUTE` on all overloads
- Only normal and window functions are shown (`prokind in ('f', 'w')`) —
triggers and aggregates are excluded
---
**To test:**
- Table and schema selectors still works end-to-end
- Toggling a function and saving updates the count label and applies the
grant (check via SQL Editor: select * from
information_schema.role_routine_grants where routine_schema = 'public')
- Functions in unexposed schemas are dimmed and unclickable
- With no functions in the selected schemas, the selector label reads
"No functions available" and the dropdown shows the same
285 lines
9.0 KiB
TypeScript
285 lines
9.0 KiB
TypeScript
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
|
|
|
export const IGNORED_SCHEMAS = [...INTERNAL_SCHEMAS, 'pg_catalog']
|
|
|
|
const IGNORED_SCHEMAS_LIST = IGNORED_SCHEMAS.map((s) => `'${s}'`).join(', ')
|
|
|
|
/**
|
|
* Builds the shared `table_privileges` and `table_grants` CTEs used by
|
|
* both the exposed-tables list query and the counts-only query.
|
|
*
|
|
* Returns SQL text meant to follow `WITH` (no leading `WITH` keyword).
|
|
* Callers that append additional CTEs should add a comma after interpolation.
|
|
*/
|
|
function getTableGrantsCTEs({ search }: { search?: string } = {}) {
|
|
return /* SQL */ `
|
|
table_privileges as (
|
|
select
|
|
c.oid::int as id,
|
|
n.nspname as schema_name,
|
|
c.relname as name,
|
|
c.relkind as kind,
|
|
|
|
-- Anon Privileges
|
|
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'SELECT') as anon_select,
|
|
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'INSERT') as anon_insert,
|
|
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'UPDATE') as anon_update,
|
|
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'DELETE') as anon_delete,
|
|
|
|
-- Authenticated Privileges
|
|
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'SELECT') as auth_select,
|
|
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'INSERT') as auth_insert,
|
|
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'UPDATE') as auth_update,
|
|
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'DELETE') as auth_delete,
|
|
|
|
-- Service Role Privileges
|
|
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'SELECT') as srv_select,
|
|
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'INSERT') as srv_insert,
|
|
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'UPDATE') as srv_update,
|
|
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'DELETE') as srv_delete
|
|
|
|
from pg_class c
|
|
join pg_namespace n
|
|
on n.oid = c.relnamespace
|
|
left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl
|
|
on true
|
|
left join pg_roles pr
|
|
on pr.oid = acl.grantee
|
|
where c.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
|
|
${search ? `and (n.nspname || '.' || c.relname) ilike '%${search}%'` : ''}
|
|
group by c.oid, n.nspname, c.relname, c.relkind
|
|
),
|
|
table_grants as (
|
|
select
|
|
id,
|
|
schema_name,
|
|
name,
|
|
kind,
|
|
case
|
|
-- 1. Strict Granted: All 3 roles possess ALL 4 privileges
|
|
when (
|
|
anon_select and anon_insert and anon_update and anon_delete and
|
|
auth_select and auth_insert and auth_update and auth_delete and
|
|
srv_select and srv_insert and srv_update and srv_delete
|
|
) then 'granted'
|
|
|
|
-- 2. Strict Revoked: NO role possesses ANY privilege
|
|
when not (
|
|
anon_select or anon_insert or anon_update or anon_delete or
|
|
auth_select or auth_insert or auth_update or auth_delete or
|
|
srv_select or srv_insert or srv_update or srv_delete
|
|
) then 'revoked'
|
|
|
|
-- 3. Custom: Anything in between
|
|
else 'custom'
|
|
end as status
|
|
from table_privileges
|
|
)
|
|
`
|
|
}
|
|
|
|
export function getExposedTablesSql({
|
|
search,
|
|
offset,
|
|
limit,
|
|
}: {
|
|
search?: string
|
|
offset: number
|
|
limit: number
|
|
}) {
|
|
return /* SQL */ `
|
|
with ${getTableGrantsCTEs({ search })}
|
|
select
|
|
(select count(*)::int from table_grants) as total_count,
|
|
coalesce(
|
|
(
|
|
select jsonb_agg(
|
|
jsonb_build_object(
|
|
'id', tg.id,
|
|
'schema', tg.schema_name,
|
|
'name', tg.name,
|
|
'status', tg.status
|
|
)
|
|
)
|
|
from (
|
|
select *
|
|
from table_grants
|
|
order by schema_name, name
|
|
offset ${offset}
|
|
limit ${limit}
|
|
) tg
|
|
),
|
|
'[]'::jsonb
|
|
) as tables;
|
|
`
|
|
}
|
|
|
|
export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) {
|
|
const schemasList =
|
|
selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''"
|
|
|
|
return /* SQL */ `
|
|
with ${getTableGrantsCTEs()}
|
|
select
|
|
count(*)::int as total_count,
|
|
(count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
|
|
from table_grants
|
|
`
|
|
}
|
|
|
|
/**
|
|
* Builds the shared `function_privileges` and `function_grants` CTEs used by
|
|
* both the exposed-functions list query and the counts-only query.
|
|
*
|
|
* Returns SQL text meant to follow `WITH` (no leading `WITH` keyword).
|
|
* Callers that append additional CTEs should add a comma after interpolation.
|
|
*/
|
|
function getFunctionGrantsCTEs({ search }: { search?: string } = {}) {
|
|
return /* SQL */ `
|
|
function_privileges as (
|
|
select
|
|
n.nspname as schema_name,
|
|
p.proname as name,
|
|
|
|
-- Aggregate EXECUTE across all overloads + all 3 roles
|
|
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'EXECUTE') as anon_execute,
|
|
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'EXECUTE') as auth_execute,
|
|
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'EXECUTE') as srv_execute
|
|
|
|
from pg_proc p
|
|
join pg_namespace n
|
|
on n.oid = p.pronamespace
|
|
left join lateral aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) as acl
|
|
on true
|
|
left join pg_roles pr
|
|
on pr.oid = acl.grantee
|
|
where p.prokind in ('f', 'w')
|
|
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
|
|
${search ? `and (n.nspname || '.' || p.proname) ilike '%${search}%'` : ''}
|
|
group by n.nspname, p.proname
|
|
),
|
|
function_grants as (
|
|
select
|
|
schema_name,
|
|
name,
|
|
case
|
|
when anon_execute and auth_execute and srv_execute then 'granted'
|
|
when not (anon_execute or auth_execute or srv_execute) then 'revoked'
|
|
else 'custom'
|
|
end as status
|
|
from function_privileges
|
|
)
|
|
`
|
|
}
|
|
|
|
export function getExposedFunctionsSql({
|
|
search,
|
|
offset,
|
|
limit,
|
|
}: {
|
|
search?: string
|
|
offset: number
|
|
limit: number
|
|
}) {
|
|
return /* SQL */ `
|
|
with ${getFunctionGrantsCTEs({ search })}
|
|
select
|
|
(select count(*)::int from function_grants) as total_count,
|
|
coalesce(
|
|
(
|
|
select jsonb_agg(
|
|
jsonb_build_object(
|
|
'schema', fg.schema_name,
|
|
'name', fg.name,
|
|
'status', fg.status
|
|
)
|
|
)
|
|
from (
|
|
select *
|
|
from function_grants
|
|
order by schema_name, name
|
|
offset ${offset}
|
|
limit ${limit}
|
|
) fg
|
|
),
|
|
'[]'::jsonb
|
|
) as functions;
|
|
`
|
|
}
|
|
|
|
export function getExposedFunctionCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) {
|
|
const schemasList =
|
|
selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''"
|
|
|
|
return /* SQL */ `
|
|
with ${getFunctionGrantsCTEs()}
|
|
select
|
|
count(*)::int as total_count,
|
|
(count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
|
|
from function_grants
|
|
`
|
|
}
|
|
|
|
export const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
|
|
if (oids.length === 0) return ''
|
|
|
|
const privilegeClause =
|
|
action === 'grant'
|
|
? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role'
|
|
: 'revoke all on table %I.%I from anon, authenticated, service_role'
|
|
|
|
return /* SQL */ `
|
|
do $$
|
|
declare
|
|
nspname name;
|
|
relname name;
|
|
begin
|
|
for nspname, relname in
|
|
select n.nspname, c.relname
|
|
from pg_class c
|
|
join pg_namespace n on n.oid = c.relnamespace
|
|
where c.oid in (${oids.join(', ')})
|
|
loop
|
|
execute format('${privilegeClause}', nspname, relname);
|
|
end loop;
|
|
end $$;
|
|
`
|
|
}
|
|
|
|
export const buildFunctionPrivilegesSql = (schemaNames: string[], action: 'grant' | 'revoke') => {
|
|
if (schemaNames.length === 0) return ''
|
|
|
|
const tuples = schemaNames
|
|
.map((sn) => {
|
|
const dotIdx = sn.indexOf('.')
|
|
const schema = sn.slice(0, dotIdx)
|
|
const name = sn.slice(dotIdx + 1)
|
|
return `('${schema}','${name}')`
|
|
})
|
|
.join(', ')
|
|
|
|
const privilegeClause =
|
|
action === 'grant'
|
|
? 'grant execute on function %I.%I(%s) to anon, authenticated, service_role'
|
|
: 'revoke all on function %I.%I(%s) from anon, authenticated, service_role'
|
|
|
|
return /* SQL */ `
|
|
do $$
|
|
declare
|
|
nspname name;
|
|
proname name;
|
|
arg_types text;
|
|
begin
|
|
for nspname, proname, arg_types in
|
|
select n.nspname, p.proname, pg_get_function_identity_arguments(p.oid)
|
|
from pg_proc p
|
|
join pg_namespace n on n.oid = p.pronamespace
|
|
where (n.nspname, p.proname) in (${tuples})
|
|
loop
|
|
execute format('${privilegeClause}', nspname, proname, arg_types);
|
|
end loop;
|
|
end $$;
|
|
`
|
|
}
|