diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index 6be39205375..5eb62bc31c4 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -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>({ 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 ( @@ -376,6 +411,40 @@ export const PostgrestConfig = () => { /> + {watchedDbSchema.includes('public') && ( + ( + + + When enabled, new tables and functions in the{' '} + public schema are automatically accessible via the + Data API. We recommend disabling this and manually granting + access to each new entity. + + } + > + +
+ +
+
+
+
+ )} + /> + )} + {watchedDbSchema.length === 0 && ( >( - 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 } diff --git a/apps/studio/data/privileges/default-privileges-query.ts b/apps/studio/data/privileges/default-privileges-query.ts new file mode 100644 index 00000000000..fe4046ce5b5 --- /dev/null +++ b/apps/studio/data/privileges/default-privileges-query.ts @@ -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 { + 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> +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', + }) +} diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 95f1b3519b1..873cb16e380 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -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, } diff --git a/apps/studio/data/privileges/privileges.sql.ts b/apps/studio/data/privileges/privileges.sql.ts index 46e08d894ff..50c0bd736c5 100644 --- a/apps/studio/data/privileges/privileges.sql.ts +++ b/apps/studio/data/privileges/privileges.sql.ts @@ -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 '' diff --git a/apps/studio/data/privileges/table-api-access-query.ts b/apps/studio/data/privileges/table-api-access-query.ts index 585cebb608b..ee7e9a03770 100644 --- a/apps/studio/data/privileges/table-api-access-query.ts +++ b/apps/studio/data/privileges/table-api-access-query.ts @@ -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, diff --git a/apps/studio/data/privileges/update-default-privileges-mutation.ts b/apps/studio/data/privileges/update-default-privileges-mutation.ts new file mode 100644 index 00000000000..fd7b94cb52b --- /dev/null +++ b/apps/studio/data/privileges/update-default-privileges-mutation.ts @@ -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 { + 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> + +export const useUpdateDefaultPrivilegesMutation = ({ + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars: UpdateDefaultPrivilegesVariables) => updateDefaultPrivileges(vars), + onError(error: Error) { + toast.error(`Failed to update default privileges: ${error.message}`) + }, + ...(onError ? { onError } : {}), + ...options, + }) +} diff --git a/apps/studio/data/privileges/update-exposed-entities-mutation.ts b/apps/studio/data/privileges/update-exposed-entities-mutation.ts index b5768804ac7..0a62e3d4361 100644 --- a/apps/studio/data/privileges/update-exposed-entities-mutation.ts +++ b/apps/studio/data/privileges/update-exposed-entities-mutation.ts @@ -54,7 +54,6 @@ export async function updateExposedEntities({ type UpdateExposedEntitiesData = Awaited> export const useUpdateExposedEntitiesMutation = ({ - onSuccess, onError, ...options }: Omit< diff --git a/apps/studio/lib/data-api-types.ts b/apps/studio/lib/data-api-types.ts index 2b2d082ea41..d9124a790ff 100644 --- a/apps/studio/lib/data-api-types.ts +++ b/apps/studio/lib/data-api-types.ts @@ -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 export const DEFAULT_DATA_API_PRIVILEGES: DeepReadonly = { anon: [...API_PRIVILEGE_TYPES], authenticated: [...API_PRIVILEGE_TYPES], + service_role: [...API_PRIVILEGE_TYPES], } export const EMPTY_DATA_API_PRIVILEGES: DeepReadonly = { anon: [], authenticated: [], + service_role: [], } export const checkDataApiPrivilegesNonEmpty = (