From b3c6992e56ca900e610960fa15fa9fc72a7ea32e Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 30 Jul 2025 11:38:50 +0200 Subject: [PATCH] feat: Make the protected schemas dynamic, namespace schemas are now protected (#37290) * Add hooks for async protected schemas. * Migrate the ProtectedSchemaWarning to support the new implementation. * sq * Migrate all uses of protected schemas to the new approach. * Delete extra file. * Refactor the import foreign schema dialog to forbid protected and exposed schemas. * Add the type to the protected schema. * Revert ImportForeignSchemaDialog, it'll be addressed in another PR. * Update apps/studio/hooks/useProtectedSchemas.ts Co-authored-by: Joshen Lim * Fix a bad commit. * Minor fixes. * Fix the FDW delete mutation to handle names with numbers. * Simplify the logic to skip a fetch. * Minor fixes. * Make the useIcebergFdwSchemasQuery work for all iceberg FDWs. * Fix the tab schemas to always show in the Table Editor. * Apply suggestion from @joshenlim Co-authored-by: Joshen Lim * Fix a minor typo. * Refactor ProtectedSchemaWarning to use Admonition, and standardise input field for target schema iceberg --------- Co-authored-by: Joshen Lim --- .../interfaces/Auth/Policies/Policies.tsx | 2 +- .../EnumeratedTypes/EnumeratedTypes.tsx | 18 ++- .../Functions/CreateFunction/index.tsx | 6 +- .../Functions/FunctionsList/FunctionsList.tsx | 19 ++- .../interfaces/Database/Indexes/Indexes.tsx | 18 ++- .../Database/ProtectedSchemaWarning.tsx | 117 +++++++++++------- .../Publications/PublicationsTables.tsx | 37 +++--- .../interfaces/Database/Tables/ColumnList.tsx | 16 +-- .../interfaces/Database/Tables/TableList.tsx | 16 +-- .../Database/Triggers/TriggerSheet.tsx | 8 +- .../Triggers/TriggersList/TriggersList.tsx | 27 ++-- .../Wrappers/CreateIcebergWrapperSheet.tsx | 2 +- .../Wrappers/CreateWrapperSheet.tsx | 2 +- .../Storage/ImportForeignSchemaDialog.tsx | 12 +- .../ImportForeignSchemaDialog.utils.ts | 8 +- .../TableGridEditor/GridHeaderActions.tsx | 14 +-- .../ColumnEditor/ColumnEditor.tsx | 5 +- .../TableEditor/TableEditor.tsx | 9 +- .../TableGridEditor/TableGridEditor.tsx | 8 +- .../SQLEditorLayout/SqlEditor.Commands.tsx | 31 +++-- .../TableEditorLayout/TableEditorMenu.tsx | 43 ++----- .../components/layouts/Tabs/SortableTab.tsx | 17 +-- apps/studio/data/fdw/fdw-delete-mutation.ts | 2 +- apps/studio/hooks/misc/useSchemaQueryState.ts | 7 +- apps/studio/hooks/useProtectedSchemas.ts | 112 +++++++++++++++++ apps/studio/lib/constants/schemas.ts | 28 ----- .../pages/project/[ref]/auth/policies.tsx | 17 +-- .../[ref]/database/column-privileges.tsx | 12 +- 28 files changed, 335 insertions(+), 278 deletions(-) create mode 100644 apps/studio/hooks/useProtectedSchemas.ts delete mode 100644 apps/studio/lib/constants/schemas.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx index ebb2a327ee135..2cb2d1d1516f5 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -10,7 +10,7 @@ import { PolicyTableRow, PolicyTableRowProps, } from 'components/interfaces/Auth/Policies/PolicyTableRow' -import ProtectedSchemaWarning from 'components/interfaces/Database/ProtectedSchemaWarning' +import { ProtectedSchemaWarning } from 'components/interfaces/Database/ProtectedSchemaWarning' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index c09796c1e3ab0..53b3c1f65ff47 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -13,7 +13,7 @@ import { useEnumeratedTypesQuery, } from 'data/enumerated-types/enumerated-types-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, DropdownMenu, @@ -22,7 +22,7 @@ import { DropdownMenuTrigger, Input, } from 'ui' -import ProtectedSchemaWarning from '../ProtectedSchemaWarning' +import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import CreateEnumeratedTypeSidePanel from './CreateEnumeratedTypeSidePanel' import DeleteEnumeratedTypeModal from './DeleteEnumeratedTypeModal' import EditEnumeratedTypeSidePanel from './EditEnumeratedTypeSidePanel' @@ -52,11 +52,7 @@ const EnumeratedTypes = () => { ) : enumeratedTypes.filter((x) => x.schema === selectedSchema) - const protectedSchemas = (schemas ?? []).filter((schema) => - PROTECTED_SCHEMAS.includes(schema?.name ?? '') - ) - const schema = schemas?.find((schema) => schema.name === selectedSchema) - const isLocked = protectedSchemas.some((s) => s.id === schema?.id) + const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) return (
@@ -81,7 +77,7 @@ const EnumeratedTypes = () => {
- {!isLocked && ( + {!isSchemaLocked && (
- {isLocked && } + {isSchemaLocked && ( + + )} {isLoading && } @@ -140,7 +138,7 @@ const EnumeratedTypes = () => { {type.name} {type.enums.join(', ')} - {!isLocked && ( + {!isSchemaLocked && (
diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index fd966494bdfc6..d481d68ea2707 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -13,7 +13,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useDatabaseFunctionCreateMutation } from 'data/database-functions/database-functions-create-mutation' import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import type { FormSchema } from 'types' import { Button, @@ -149,6 +149,8 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { } }, [visible, func]) + const { data: protectedSchemas } = useProtectedSchemas() + return ( isClosingSidePanel()}> { s.name)} size="small" onSelectSchema={(name) => field.onChange(name)} /> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 079776aec576c..22427a0581b80 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -1,6 +1,6 @@ import type { PostgresFunction } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop, partition } from 'lodash' +import { noop } from 'lodash' import { Search } from 'lucide-react' import { useRouter } from 'next/router' @@ -16,10 +16,10 @@ import { useDatabaseFunctionsQuery } from 'data/database-functions/database-func import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, Input } from 'ui' -import ProtectedSchemaWarning from '../../ProtectedSchemaWarning' +import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' interface FunctionsListProps { @@ -60,11 +60,8 @@ const FunctionsList = ({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const [protectedSchemas] = partition(schemas ?? [], (schema) => - PROTECTED_SCHEMAS.includes(schema?.name ?? '') - ) - const foundSchema = schemas?.find((schema) => schema.name === selectedSchema) - const isLocked = protectedSchemas.some((s) => s.id === foundSchema?.id) + + const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) const { data: functions, @@ -126,7 +123,7 @@ const FunctionsList = ({
- {!isLocked && ( + {!isSchemaLocked && ( <>
- {isLocked && } + {isSchemaLocked && } diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 263d20bfd4d5d..742a04ace3b1b 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -1,4 +1,4 @@ -import { partition, sortBy } from 'lodash' +import { sortBy } from 'lodash' import { AlertCircle, Search, Trash } from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -14,10 +14,10 @@ import { useDatabaseIndexDeleteMutation } from 'data/database-indexes/index-dele import { DatabaseIndex, useIndexesQuery } from 'data/database-indexes/indexes-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, Input, SidePanel } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import ProtectedSchemaWarning from '../ProtectedSchemaWarning' +import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import CreateIndexSidePanel from './CreateIndexSidePanel' const Indexes = () => { @@ -58,11 +58,7 @@ const Indexes = () => { }, }) - const [protectedSchemas] = partition(schemas ?? [], (schema) => - PROTECTED_SCHEMAS.includes(schema?.name ?? '') - ) - const schema = schemas?.find((schema) => schema.name === selectedSchema) - const isLocked = protectedSchemas.some((s) => s.id === schema?.id) + const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) const sortedIndexes = sortBy(allIndexes ?? [], (index) => index.name.toLocaleLowerCase()) const indexes = @@ -122,7 +118,7 @@ const Indexes = () => { icon={} /> - {!isLocked && ( + {!isSchemaLocked && ( - {!isLocked && ( + {!isSchemaLocked && ( - - } - onCancel={() => onClose()} - > - + <> + + Schemas managed by Supabase + + +

The following schemas are managed by Supabase and are currently protected from write access through the dashboard.

- {PROTECTED_SCHEMAS.map((schema) => ( + {INTERNAL_SCHEMAS.map((schema) => ( {schema} @@ -45,32 +43,67 @@ export const ProtectedSchemaModal = ({ You can, however, still interact with those schemas through the SQL Editor although we advise you only do so if you know what you are doing.

- - + + +
+ +
+
+ ) } -const ProtectedSchemaWarning = ({ schema, entity }: { schema: string; entity: string }) => { +export const ProtectedSchemaWarning = ({ + size = 'md', + schema, + entity, +}: { + size?: 'sm' | 'md' + schema: string + entity: string +}) => { const [showModal, setShowModal] = useState(false) + const { isSchemaLocked, reason } = useIsProtectedSchema({ schema }) + + if (!isSchemaLocked) return null return ( - <> - - - Currently viewing {entity} from a protected schema - + div>p]:prose [&>div>p]:max-w-full [&>div>p]:!leading-normal', + size === 'sm' ? '[&>div>p]:text-xs' : '[&>div>p]:text-sm' + )} + > + {reason === 'fdw' ? ( +

+ The {schema} schema is used by Supabase to connect to + analytics buckets and is read-only through the dashboard. +

+ ) : ( + <>

The {schema} schema is managed by Supabase and is read-only through the dashboard.

- -
-
- setShowModal(false)} /> - + + + + + + setShowModal(false)} /> + + + + )} + ) } - -export default ProtectedSchemaWarning diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index fe0f306660f1f..a67ff1ea1312e 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -1,6 +1,6 @@ import type { PostgresPublication } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' @@ -10,10 +10,10 @@ import InformationBox from 'components/ui/InformationBox' import { Loading } from 'components/ui/Loading' import { useTablesQuery } from 'data/tables/tables-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useProtectedSchemas } from 'hooks/useProtectedSchemas' +import { AlertCircle, ChevronLeft, Search } from 'lucide-react' import { Button, Input } from 'ui' import PublicationsTableItem from './PublicationsTableItem' -import { ChevronLeft, Search, AlertCircle } from 'lucide-react' interface PublicationsTablesProps { selectedPublication: PostgresPublication @@ -29,27 +29,26 @@ const PublicationsTables = ({ selectedPublication, onSelectBack }: PublicationsT 'publications' ) + const { data: protectedSchemas } = useProtectedSchemas() + const { - data: tables, + data: tablesData, isLoading, isSuccess, isError, error, - } = useTablesQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { - select(tables) { - return tables.filter((table) => - filterString.length === 0 - ? !PROTECTED_SCHEMAS.includes(table.schema) - : !PROTECTED_SCHEMAS.includes(table.schema) && table.name.includes(filterString) - ) - }, - } - ) + } = useTablesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const tables = useMemo(() => { + return (tablesData || []).filter((table) => + filterString.length === 0 + ? !protectedSchemas.find((s) => s.name === table.schema) + : !protectedSchemas.find((s) => s.name === table.schema) && + table.name.includes(filterString) + ) + }, [tablesData, protectedSchemas, filterString]) return ( <> diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index c77fee04b4ea2..e3830b6680ea9 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -14,7 +14,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, DropdownMenu, @@ -26,7 +26,7 @@ import { TooltipContent, TooltipTrigger, } from 'ui' -import ProtectedSchemaWarning from '../ProtectedSchemaWarning' +import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' interface ColumnListProps { onAddColumn: () => void @@ -63,7 +63,7 @@ const ColumnList = ({ ? selectedTable?.columns ?? [] : selectedTable?.columns?.filter((column) => column.name.includes(filterString))) ?? [] - const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '') + const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' }) const canUpdateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') return ( @@ -81,7 +81,7 @@ const ColumnList = ({ icon={} />
- {!isLocked && isTableEntity && ( + {!isSchemaLocked && isTableEntity && ( } disabled={!canUpdateColumns} @@ -100,7 +100,9 @@ const ColumnList = ({ )} - {isLocked && } + {isSchemaLocked && ( + + )} {isLoading && } @@ -156,7 +158,7 @@ const ColumnList = ({ )} - {!isLocked && isTableEntity && ( + {!isSchemaLocked && isTableEntity && ( - {!isLocked && ( + {!isSchemaLocked && (
diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx index 25c9659027e20..9d22737a437ad 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateIcebergWrapperSheet.tsx @@ -337,7 +337,7 @@ export const CreateIcebergWrapperSheet = ({ />

A new schema will be created. For security purposes, the wrapper tables - from the foreign schema cannot be created within an existing schema + from the foreign schema cannot be created within an existing schema.

diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index 73b2ce54d26c7..d388404874029 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -440,7 +440,7 @@ export const CreateWrapperSheet = ({ />

A new schema will be created. For security purposes, the wrapper tables - from the foreign schema cannot be created within an existing schema + from the foreign schema cannot be created within an existing schema.

diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx index c54d1e3bd45d6..d7371c08591c3 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx @@ -107,7 +107,7 @@ export const ImportForeignSchemaDialog = ({ const serverOptions = await getDecryptedParameters({ ref: project?.ref, connectionString: project?.connectionString ?? undefined, - serverName, + wrapper, }) const formValues: Record = { @@ -158,11 +158,7 @@ export const ImportForeignSchemaDialog = ({ hideFooter visible={visible} size="medium" - header={ - - Connect namespace {namespace} - - } + header={Connect namespace "{namespace}"} onCancel={() => onClose()} > @@ -173,9 +169,9 @@ export const ImportForeignSchemaDialog = ({ name="targetSchema" render={({ field }) => ( diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts index 923e41ae733dc..1b1f6d5172097 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts @@ -1,4 +1,4 @@ -import { getFDWs } from 'data/fdw/fdws-query' +import { type FDW } from 'data/fdw/fdws-query' import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' import { INTEGRATIONS } from '../Integrations/Landing/Integrations.constants' import { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types' @@ -7,18 +7,16 @@ import { convertKVStringArrayToJson } from '../Integrations/Wrappers/Wrappers.ut export const getDecryptedParameters = async ({ ref, connectionString, - serverName, + wrapper, }: { ref?: string connectionString?: string - serverName: string + wrapper: FDW }) => { const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta const wrapperServerOptions = wrapperMeta.server.options - const FDWs = await getFDWs({ projectRef: ref, connectionString: connectionString }) - const wrapper = FDWs.find((fdw) => fdw.server_name === serverName) const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? []) const paramsToBeDecrypted = Object.fromEntries( diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index a14889e5736e6..f32a66bb37a61 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -25,7 +25,7 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, @@ -60,7 +60,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { const isMaterializedView = isTableLikeMaterializedView(table) const realtimeEnabled = useIsFeatureEnabled('realtime:all') - const isLocked = PROTECTED_SCHEMAS.includes(table.schema) + const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema }) const { mutate: updateTable } = useTableUpdateMutation({ onError: (error) => { @@ -200,10 +200,10 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { )} - {isTable && !isLocked ? ( + {isTable && !isSchemaLocked ? ( table.rls_enabled ? ( <> - {policies.length < 1 && !isLocked ? ( + {policies.length < 1 && !isSchemaLocked ? ( { ) : ( - - + )} @@ -282,7 +254,7 @@ const TableEditorMenu = () => { itemProps={{ projectRef: project?.ref!, id: Number(id), - isLocked, + isSchemaLocked, onExportCLI: () => { const entity = entityTypes?.find((x) => x.id === id) if (!entity) return @@ -308,7 +280,6 @@ const TableEditorMenu = () => { if (!open) setTableToExport(undefined) }} /> - setShowModal(false)} /> ) } diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 4f03c326c8d71..11cab9c709578 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -2,10 +2,10 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { AnimatePresence, motion } from 'framer-motion' import { X } from 'lucide-react' -import { useRouter } from 'next/router' import { useMemo } from 'react' import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' +import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useTabsStateSnapshot, type Tab } from 'state/tabs' import { cn, TabsTrigger_Shadcn_ } from 'ui' @@ -28,8 +28,7 @@ export const SortableTab = ({ openTabs: Tab[] onClose: (id: string) => void }) => { - const router = useRouter() - const currentSchema = (router.query.schema as string) || 'public' + const { selectedSchema: currentSchema } = useQuerySchemaState() const tabs = useTabsStateSnapshot() const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, @@ -45,16 +44,8 @@ export const SortableTab = ({ const shouldShowSchema = useMemo(() => { // For both table and schema tabs, show schema if: // Any tab has a different schema than the current schema parameter - if (tab.type === 'r') { - const anyTabHasDifferentSchema = openTabs - .filter((t) => t.type === 'r') - .some((t) => t.metadata?.schema !== currentSchema) - - return anyTabHasDifferentSchema - } - - return false - }, [openTabs, currentSchema, tab.type]) + return openTabs.some((t) => t.metadata?.schema !== currentSchema) + }, [openTabs, currentSchema]) // Create a motion version of TabsTrigger while preserving all functionality // const MotionTabsTrigger = motion(TabsTrigger_Shadcn_) diff --git a/apps/studio/data/fdw/fdw-delete-mutation.ts b/apps/studio/data/fdw/fdw-delete-mutation.ts index 19e2778c8f1cb..37cd3030c467c 100644 --- a/apps/studio/data/fdw/fdw-delete-mutation.ts +++ b/apps/studio/data/fdw/fdw-delete-mutation.ts @@ -73,7 +73,7 @@ export const getDeleteFDWSql = ({ const deleteEncryptedSecretsSql = deleteEncryptedSecretsSqlArray.join('\n') const sql = /* SQL */ ` - drop foreign data wrapper if exists ${wrapper.name} cascade; + drop foreign data wrapper if exists "${wrapper.name}" cascade; ${deleteEncryptedSecretsSql} ` diff --git a/apps/studio/hooks/misc/useSchemaQueryState.ts b/apps/studio/hooks/misc/useSchemaQueryState.ts index f981ac4495189..12ab49493aa93 100644 --- a/apps/studio/hooks/misc/useSchemaQueryState.ts +++ b/apps/studio/hooks/misc/useSchemaQueryState.ts @@ -12,7 +12,12 @@ const useIsomorphicUseQueryState = (defaultSchema: string) => { return [defaultSchema, () => {}] as const } else { // eslint-disable-next-line react-hooks/rules-of-hooks - return useQueryState('schema', parseAsString.withDefault(defaultSchema)) + return useQueryState( + 'schema', + parseAsString.withDefault(defaultSchema).withOptions({ + clearOnDefault: false, + }) + ) } } diff --git a/apps/studio/hooks/useProtectedSchemas.ts b/apps/studio/hooks/useProtectedSchemas.ts new file mode 100644 index 0000000000000..92dfcf9625d38 --- /dev/null +++ b/apps/studio/hooks/useProtectedSchemas.ts @@ -0,0 +1,112 @@ +import { uniq } from 'lodash' +import { useMemo } from 'react' + +import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' +import { + convertKVStringArrayToJson, + wrapperMetaComparator, +} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { QUEUES_SCHEMA } from 'data/database-queues/database-queues-toggle-postgrest-mutation' +import { useFDWsQuery } from 'data/fdw/fdws-query' + +/** + * A list of system schemas that users should not interact with + */ +export const INTERNAL_SCHEMAS = [ + 'auth', + 'cron', + 'extensions', + 'information_schema', + 'net', + 'pgsodium', + 'pgsodium_masks', + 'pgbouncer', + 'pgtle', + 'realtime', + 'storage', + 'supabase_functions', + 'supabase_migrations', + 'vault', + 'graphql', + 'graphql_public', + QUEUES_SCHEMA, +] + +/** + * Get the list of schemas used by IcebergFDWs + */ +const useIcebergFdwSchemasQuery = () => { + const { project } = useProjectContext() + const result = useFDWsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const schemas = useMemo(() => { + const icebergFDWs = result.data?.filter((wrapper) => + wrapperMetaComparator( + { handlerName: WRAPPER_HANDLERS.ICEBERG, server: { options: [] } }, + wrapper + ) + ) + + const fdwSchemas = icebergFDWs + ?.map((fdw) => convertKVStringArrayToJson(fdw.server_options)) + .map((options) => options['supabase_target_schema']) + .flatMap((s) => s?.split(',')) + .filter(Boolean) + + return uniq(fdwSchemas) + }, [result.data]) + + return { ...result, data: schemas } +} + +/** + * Returns a list of schemas that are protected by Supabase (internal schemas or schemas used by Iceberg FDWs). + */ +export const useProtectedSchemas = ({ + excludeSchemas = [], +}: { excludeSchemas?: string[] } = {}) => { + // Stabilize the excludeSchemas array to prevent unnecessary re-computations + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableExcludeSchemas = useMemo(() => excludeSchemas, [JSON.stringify(excludeSchemas)]) + + const result = useIcebergFdwSchemasQuery() + + const schemas = useMemo<{ name: string; type: 'fdw' | 'internal' }[]>(() => { + const internalSchemas = INTERNAL_SCHEMAS.map((s) => ({ name: s, type: 'internal' as const })) + const icebergFdwSchemas = result.data?.map((s) => ({ name: s, type: 'fdw' as const })) + + const schemas = uniq([...internalSchemas, ...icebergFdwSchemas]) + return schemas.filter((schema) => !stableExcludeSchemas.includes(schema.name)) + }, [result.data, stableExcludeSchemas]) + + return { ...result, data: schemas } +} + +/** + * Returns whether a given schema is protected by Supabase (internal schema or schema used by Iceberg FDWs). + */ +export const useIsProtectedSchema = ({ + schema, + excludedSchemas = [], +}: { + schema: string + excludedSchemas?: string[] +}): + | { isSchemaLocked: false; reason: undefined } + | { isSchemaLocked: true; reason: 'fdw' | 'internal' } => { + const { data: schemas } = useProtectedSchemas({ excludeSchemas: excludedSchemas }) + + const foundSchema = schemas.find((s) => s.name === schema) + + if (foundSchema) { + return { + isSchemaLocked: true, + reason: foundSchema.type, + } + } + return { isSchemaLocked: false, reason: undefined } +} diff --git a/apps/studio/lib/constants/schemas.ts b/apps/studio/lib/constants/schemas.ts deleted file mode 100644 index 0d15f8ff19a39..0000000000000 --- a/apps/studio/lib/constants/schemas.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { QUEUES_SCHEMA } from 'data/database-queues/database-queues-toggle-postgrest-mutation' - -/** - * A list of system schemas that users should not interact with - */ -export const PROTECTED_SCHEMAS = [ - 'auth', - 'cron', - 'extensions', - 'information_schema', - 'net', - 'pgsodium', - 'pgsodium_masks', - 'pgbouncer', - 'pgtle', - 'realtime', - 'storage', - 'supabase_functions', - 'supabase_migrations', - 'vault', - 'graphql', - 'graphql_public', - QUEUES_SCHEMA, -] - -export const PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS = PROTECTED_SCHEMAS.filter( - (x) => x !== 'extensions' -) diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index 8cf5cd173a6af..0b086d6c89605 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -1,6 +1,5 @@ import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { partition } from 'lodash' import { Search } from 'lucide-react' import { useState } from 'react' @@ -18,11 +17,10 @@ import NoPermission from 'components/ui/NoPermission' import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' -import { useSchemasQuery } from 'data/database/schemas-query' import { useTablesQuery } from 'data/tables/tables-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import { useUrlState } from 'hooks/ui/useUrlState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useAppStateSnapshot } from 'state/app-state' import type { NextPageWithLayout } from 'types' import { Input } from 'ui' @@ -76,16 +74,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false) const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState() - const { data: schemas } = useSchemasQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const [protectedSchemas] = partition( - schemas, - (schema) => schema?.name !== 'realtime' && PROTECTED_SCHEMAS.includes(schema?.name ?? '') - ) - const selectedSchema = schemas?.find((s) => s.name === schema) - const isLocked = protectedSchemas.some((s) => s.id === selectedSchema?.id) + const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] }) const { data: policies } = useDatabasePoliciesQuery({ projectRef: project?.ref, @@ -151,7 +140,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { schema={schema} tables={filteredTables} hasTables={tables.length > 0} - isLocked={isLocked} + isLocked={isSchemaLocked} onSelectCreatePolicy={(table: string) => { if (isInlineEditorEnabled) { setEditorPanel({ diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index abbd13827b36c..0d78688c571f7 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -16,7 +16,7 @@ import { } from 'components/interfaces/Database/Privileges/Privileges.utils' import PrivilegesHead from 'components/interfaces/Database/Privileges/PrivilegesHead' import PrivilegesTable from 'components/interfaces/Database/Privileges/PrivilegesTable' -import ProtectedSchemaWarning from 'components/interfaces/Database/ProtectedSchemaWarning' +import { ProtectedSchemaWarning } from 'components/interfaces/Database/ProtectedSchemaWarning' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' @@ -30,7 +30,7 @@ import { useTablePrivilegesQuery } from 'data/privileges/table-privileges-query' import { useTablesQuery } from 'data/tables/tables-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' +import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' @@ -132,7 +132,7 @@ const PrivilegesPage: NextPageWithLayout = () => { const table = tableList?.find( (table) => table.schema === selectedSchema && table.name === selectedTable ) - const isLocked = PROTECTED_SCHEMAS.includes(selectedSchema) + const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) const { tableCheckedStates, @@ -286,7 +286,7 @@ const PrivilegesPage: NextPageWithLayout = () => { )} { hasChanges={hasChanges} isApplyingChanges={isApplyingChanges} /> - {isLocked && ( + {isSchemaLocked && ( )} {isLoading ? ( @@ -310,7 +310,7 @@ const PrivilegesPage: NextPageWithLayout = () => { ) : table && tablePrivilege ? (