[SEC-385] feat(studio): Add default privileges for new entities toggle (#43583)

Adds a new toggle in:
<img width="1161" height="356" alt="Screenshot 2026-03-10 at 17 17 06"
src="https://github.com/user-attachments/assets/b09ac1aa-a8f5-4fb4-8771-f113b140eac8"
/>

Other changes:
- form submissions with no table/function changes were failing because
an empty string got passed to executeSql. Added an early return when
there's nothing to execute

To test:
- Ensure the form is still in working order
- Create some tables and functions with the toggle on add off and make
sure your selected default applies
This commit is contained in:
Alaister Young authored and GitHub committed 2026-03-12 09:38:03 +00:00
1 parent d6d419a12e
commit 9652e8c4d3
9 files changed
+307 -23

No files matched your search

@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { Lock } from 'lucide-react'
import Link from 'next/link'
@@ -19,6 +19,7 @@ import {
Input_Shadcn_,
PrePostTab,
Skeleton,
Switch,
useWatch_Shadcn_,
} from 'ui'
import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
@@ -42,7 +43,9 @@ import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-
import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
import { privilegeKeys } from '@/data/privileges/keys'
import { useUpdateDefaultPrivilegesMutation } from '@/data/privileges/update-default-privileges-mutation'
import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
@@ -64,6 +67,9 @@ const formSchema = z.object({
.optional()
.nullable(),
// Default privileges toggle
defaultPrivilegesGranted: z.boolean(),
// Fields for expose toggles
tableIdsToAdd: z.array(z.number()),
tableIdsToRemove: z.array(z.number()),
@@ -98,12 +104,23 @@ export const PostgrestConfig = () => {
connectionString: project?.connectionString,
})
const {
data: defaultPrivilegesGranted,
isPending: isLoadingDefaultPrivileges,
isSuccess: isSuccessDefaultPrivileges,
} = useQuery(
defaultPrivilegesQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
)
const configDbSchemas = useMemo(
() => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
[config?.db_schema]
)
const isLoading = isLoadingConfig || isLoadingSchemas
const isLoading = isLoadingConfig || isLoadingSchemas || isLoadingDefaultPrivileges
const schemas = useMemo(
() =>
@@ -120,9 +137,13 @@ export const PostgrestConfig = () => {
[allSchemas]
)
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation()
const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation()
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
onError: noop,
})
const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation({ onError: noop })
const { mutateAsync: updateDefaultPrivileges } = useUpdateDefaultPrivilegesMutation({
onError: noop,
})
const [isUpdating, setIsUpdating] = useState(false)
@@ -144,12 +165,13 @@ export const PostgrestConfig = () => {
.map((x) => x.trim())
.filter(Boolean),
dbPool: config?.db_pool,
defaultPrivilegesGranted: defaultPrivilegesGranted ?? true,
tableIdsToAdd: [] as number[],
tableIdsToRemove: [] as number[],
functionNamesToAdd: [] as string[],
functionNamesToRemove: [] as string[],
}
}, [config, configDbSchemas])
}, [config, configDbSchemas, defaultPrivilegesGranted])
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -178,6 +200,14 @@ export const PostgrestConfig = () => {
functionNamesToAdd: values.functionNamesToAdd,
functionNamesToRemove: values.functionNamesToRemove,
})
if (values.defaultPrivilegesGranted !== defaultPrivilegesGranted) {
await updateDefaultPrivileges({
projectRef,
connectionString: project?.connectionString,
granted: values.defaultPrivilegesGranted,
})
}
}
await updatePostgrestConfig(
@@ -196,13 +226,16 @@ export const PostgrestConfig = () => {
queryKey: privilegeKeys.exposedTablesInfinite(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema),
queryKey: privilegeKeys.exposedTableCounts(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedFunctionCounts(projectRef, watchedDbSchema),
queryKey: privilegeKeys.exposedFunctionCounts(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.defaultPrivileges(projectRef),
}),
])
@@ -215,6 +248,7 @@ export const PostgrestConfig = () => {
maxRows: values.maxRows,
dbExtraSearchPath: values.dbExtraSearchPath,
dbPool: values.dbPool,
defaultPrivilegesGranted: values.defaultPrivilegesGranted,
tableIdsToAdd: [],
tableIdsToRemove: [],
functionNamesToAdd: [],
@@ -228,7 +262,7 @@ export const PostgrestConfig = () => {
}
const resetFormRef = useLatest(resetForm)
const isReady = isSuccessConfig && isSuccessSchemas
const isReady = isSuccessConfig && isSuccessSchemas && isSuccessDefaultPrivileges
useEffect(() => {
if (isReady) {
resetFormRef.current()
@@ -250,6 +284,7 @@ export const PostgrestConfig = () => {
control: form.control,
name: 'functionNamesToRemove',
})
return (
<PageSection id="postgrest-config" className="first:pt-0">
<PageSectionContent>
@@ -376,6 +411,40 @@ export const PostgrestConfig = () => {
/>
</FormItemLayout>
{watchedDbSchema.includes('public') && (
<FormField_Shadcn_
control={form.control}
name="defaultPrivilegesGranted"
render={({ field }) => (
<FormItem_Shadcn_>
<FormItemLayout
layout="flex-row-reverse"
label="Default privileges for new entities"
description={
<>
When enabled, new tables and functions in the{' '}
<code>public</code> schema are automatically accessible via the
Data API. We recommend disabling this and manually granting
access to each new entity.
</>
}
>
<FormControl_Shadcn_>
<div>
<Switch
size="large"
disabled={!canUpdatePostgrestConfig}
checked={field.value}
onCheckedChange={field.onChange}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
</FormItem_Shadcn_>
)}
/>
)}
{watchedDbSchema.length === 0 && (
<Admonition
type="warning"
@@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useIsSchemaExposed } from 'hooks/misc/useIsSchemaExposed'
@@ -20,6 +21,7 @@ import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
import {
@@ -110,6 +112,18 @@ const useTableApiAccessHandler = (
: isDuplicate
? params.templateTableName
: params.tableName
const isNewTableQuery = isNewTable && enabled
const defaultPrivilegesQuery = useQuery(
defaultPrivilegesQueryOptions(
{
projectRef: project?.ref,
connectionString: project?.connectionString ?? undefined,
schema: currentTableSchema,
},
{ enabled: isNewTableQuery }
)
)
const canResolvePrivilegeParams = Boolean(
shouldReadExistingGrants &&
project?.ref &&
@@ -131,20 +145,34 @@ const useTableApiAccessHandler = (
? apiAccessStatus.data?.[permissionsTemplateTable]
: undefined
const defaultPrivilegesEnabled = defaultPrivilegesQuery.data ?? true
const defaultPrivilegesForNewTable = defaultPrivilegesEnabled
? DEFAULT_DATA_API_PRIVILEGES
: EMPTY_DATA_API_PRIVILEGES
const [privileges, setPrivileges] = useState<DeepReadonly<ApiPrivilegesByRole>>(
DEFAULT_DATA_API_PRIVILEGES
defaultPrivilegesForNewTable
)
const hasLoadedInitialData = useRef(false)
const resetState = useStaticEffectEvent(() => {
hasLoadedInitialData.current = !shouldReadExistingGrants
setPrivileges(DEFAULT_DATA_API_PRIVILEGES)
setPrivileges(defaultPrivilegesForNewTable)
})
useEffect(() => {
resetState()
}, [params.type, selectedSchema, permissionsTemplateSchema, permissionsTemplateTable, resetState])
const syncDefaultPrivileges = useStaticEffectEvent(() => {
if (!isNewTable) return
if (!defaultPrivilegesQuery.isSuccess) return
setPrivileges(defaultPrivilegesForNewTable)
})
useEffect(() => {
syncDefaultPrivileges()
}, [defaultPrivilegesQuery.status, syncDefaultPrivileges])
const syncApiPrivileges = useStaticEffectEvent(() => {
if (hasLoadedInitialData.current) return
if (!apiAccessStatus.isSuccess) return
@@ -168,6 +196,7 @@ const useTableApiAccessHandler = (
const isPending =
!enabled ||
schemaExposure.status === 'pending' ||
(isNewTable && defaultPrivilegesQuery.isPending) ||
(shouldReadExistingGrants && apiAccessStatus.isPending)
if (isPending) {
return { isError: false, isPending: true, isSuccess: false, data: undefined }
@@ -0,0 +1,58 @@
import { queryOptions } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
import { privilegeKeys } from './keys'
import { getDefaultPrivilegesStateSql } from './privileges.sql'
export type DefaultPrivilegesVariables = {
projectRef?: string
connectionString?: string | null
schema?: string
}
export async function getDefaultPrivilegesState(
{ projectRef, connectionString, schema }: DefaultPrivilegesVariables,
signal?: AbortSignal
): Promise<boolean> {
if (!projectRef) throw new Error('projectRef is required')
const sql = getDefaultPrivilegesStateSql({ schema })
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['default-privileges-state'],
},
signal
)
const grantCount = (result[0] as { grant_count: number }).grant_count
return grantCount === 3
}
export type DefaultPrivilegesData = Awaited<ReturnType<typeof getDefaultPrivilegesState>>
export type DefaultPrivilegesError = ResponseError
export const defaultPrivilegesQueryOptions = (
{ projectRef, connectionString, schema }: DefaultPrivilegesVariables,
{ enabled = true }: { enabled?: boolean } = {}
) => {
return queryOptions({
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
queryKey: privilegeKeys.defaultPrivileges(projectRef, schema),
queryFn: ({ signal }) =>
getDefaultPrivilegesState(
{
projectRef,
connectionString,
schema,
},
signal
),
enabled: enabled && typeof projectRef !== 'undefined',
})
}
+24 -4
View File
@@ -11,8 +11,14 @@ export const privilegeKeys = {
'exposed-tables-infinite',
...(search ? ([{ search }] as const) : []),
] as const,
exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const,
exposedTableCounts: (projectRef: string | undefined, selectedSchemas?: string[]) =>
[
'projects',
projectRef,
'privileges',
'exposed-table-counts',
...(selectedSchemas ? ([selectedSchemas] as const) : []),
] as const,
exposedFunctionsInfinite: (projectRef: string | undefined, search?: string) =>
[
'projects',
@@ -21,6 +27,20 @@ export const privilegeKeys = {
'exposed-functions-infinite',
...(search ? ([{ search }] as const) : []),
] as const,
exposedFunctionCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
['projects', projectRef, 'privileges', 'exposed-function-counts', ...selectedSchemas] as const,
exposedFunctionCounts: (projectRef: string | undefined, selectedSchemas?: string[]) =>
[
'projects',
projectRef,
'privileges',
'exposed-function-counts',
...(selectedSchemas ? ([selectedSchemas] as const) : []),
] as const,
defaultPrivileges: (projectRef: string | undefined, schema?: string) =>
[
'projects',
projectRef,
'privileges',
'default-privileges',
...(schema ? [schema] : []),
] as const,
}
@@ -221,6 +221,58 @@ export function getExposedFunctionCountsSql({ selectedSchemas }: { selectedSchem
`
}
export function getDefaultPrivilegesStateSql({ schema = 'public' }: { schema?: string } = {}) {
return /* SQL */ `
select
count(*)::int as grant_count
from pg_default_acl d
join pg_namespace n on n.oid = d.defaclnamespace
join pg_roles r on r.oid = d.defaclrole
where n.nspname = '${schema}'
and r.rolname = 'postgres'
and d.defaclobjtype in ('r', 'f', 'S')
and exists (
select 1
from aclexplode(d.defaclacl) acl
join pg_roles gr on gr.oid = acl.grantee
where gr.rolname in ('anon', 'authenticated', 'service_role')
)
`
}
export function buildDefaultPrivilegesSql(action: 'grant' | 'revoke') {
const roles = ['anon', 'authenticated', 'service_role']
const statements: string[] = []
for (const role of roles) {
if (action === 'grant') {
statements.push(
`alter default privileges for role postgres in schema public grant select, insert, update, delete on tables to ${role}`,
`alter default privileges for role postgres in schema public grant execute on functions to ${role}`,
`alter default privileges for role postgres in schema public grant usage, select on sequences to ${role}`
)
} else {
statements.push(
`alter default privileges for role postgres in schema public revoke select, insert, update, delete on tables from ${role}`,
`alter default privileges for role postgres in schema public revoke execute on functions from ${role}`,
`alter default privileges for role postgres in schema public revoke usage, select on sequences from ${role}`
)
}
}
if (action === 'revoke') {
statements.push(
`alter default privileges for role postgres in schema public revoke execute on functions from public`
)
} else {
statements.push(
`alter default privileges for role postgres in schema public grant execute on functions to public`
)
}
return statements.join(';\n') + ';'
}
export const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
if (oids.length === 0) return ''
@@ -1,10 +1,10 @@
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 { useMemo } from 'react'
import type { UseCustomQueryOptions } from 'types'
import {
useTablePrivilegesQuery,
type TablePrivilegesData,
@@ -23,6 +23,7 @@ const getApiPrivilegesByRole = (
const privilegesByRole: ApiPrivilegesByRole = {
anon: [],
authenticated: [],
service_role: [],
}
privileges.forEach((privilege) => {
@@ -175,11 +176,17 @@ export const useTableApiAccessQuery = (
return
}
const tablePrivileges = tablePrivilegesByName[tableName] ?? { anon: [], authenticated: [] }
const hasAnonOrAuthenticatedPrivileges =
tablePrivileges.anon.length > 0 || tablePrivileges.authenticated.length > 0
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] = hasAnonOrAuthenticatedPrivileges
resultData[tableName] = hasApiPrivileges
? {
apiAccessType: 'access',
privileges: tablePrivileges,
@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import { toast } from 'sonner'
import type { UseCustomMutationOptions } from 'types'
import type { ConnectionVars } from '../common.types'
import { buildDefaultPrivilegesSql } from './privileges.sql'
export type UpdateDefaultPrivilegesVariables = ConnectionVars & {
granted: boolean
}
export async function updateDefaultPrivileges({
projectRef,
connectionString,
granted,
}: UpdateDefaultPrivilegesVariables): Promise<void> {
if (!projectRef) throw new Error('projectRef is required')
const sql = buildDefaultPrivilegesSql(granted ? 'grant' : 'revoke')
await executeSql({
projectRef,
connectionString,
sql,
queryKey: ['update-default-privileges'],
})
}
type UpdateDefaultPrivilegesData = Awaited<ReturnType<typeof updateDefaultPrivileges>>
export const useUpdateDefaultPrivilegesMutation = ({
onError,
...options
}: Omit<
UseCustomMutationOptions<UpdateDefaultPrivilegesData, Error, UpdateDefaultPrivilegesVariables>,
'mutationFn'
> = {}) => {
return useMutation<UpdateDefaultPrivilegesData, Error, UpdateDefaultPrivilegesVariables>({
mutationFn: (vars: UpdateDefaultPrivilegesVariables) => updateDefaultPrivileges(vars),
onError(error: Error) {
toast.error(`Failed to update default privileges: ${error.message}`)
},
...(onError ? { onError } : {}),
...options,
})
}
@@ -54,7 +54,6 @@ export async function updateExposedEntities({
type UpdateExposedEntitiesData = Awaited<ReturnType<typeof updateExposedEntities>>
export const useUpdateExposedEntitiesMutation = ({
onSuccess,
onError,
...options
}: Omit<
+4 -1
View File
@@ -1,7 +1,8 @@
import type { TablePrivilegesGrant } from 'data/privileges/table-privileges-grant-mutation'
import type { DeepReadonly } from './type-helpers'
export const API_ACCESS_ROLES = ['anon', 'authenticated'] as const
export const API_ACCESS_ROLES = ['anon', 'authenticated', 'service_role'] as const
export type ApiAccessRole = (typeof API_ACCESS_ROLES)[number]
export const isApiAccessRole = (value: string): value is ApiAccessRole => {
@@ -28,11 +29,13 @@ export type ApiPrivilegesByRole = Record<ApiAccessRole, ApiPrivilegeType[]>
export const DEFAULT_DATA_API_PRIVILEGES: DeepReadonly<ApiPrivilegesByRole> = {
anon: [...API_PRIVILEGE_TYPES],
authenticated: [...API_PRIVILEGE_TYPES],
service_role: [...API_PRIVILEGE_TYPES],
}
export const EMPTY_DATA_API_PRIVILEGES: DeepReadonly<ApiPrivilegesByRole> = {
anon: [],
authenticated: [],
service_role: [],
}
export const checkDataApiPrivilegesNonEmpty = (