From c7dcda5644a6c223277f76c3708cf038d613afc6 Mon Sep 17 00:00:00 2001
From: Charis <26616127+charislam@users.noreply.github.com>
Date: Thu, 12 Feb 2026 10:18:39 -0500
Subject: [PATCH] feat(studio): move data api settings to integrations (#42711)
Feature
## What is the current behavior?
Data API settings live under Project Settings.
## What is the new behavior?
Data API settings are moved to the Integrations page, treating Data API
as a platform integration. Includes security checks when toggling on the
Data API to prevent unintended exposure of project data.
## Additional context
Towards FE-2517
## Summary by CodeRabbit
* **New Features**
* Added a Data API integration with Overview and Settings pages,
endpoint display, and enable/disable toggle with safety checks and
confirmation flow.
* **Navigation Changes**
* Data API moved from Project Settings into the Integrations area and
routes now redirect to the new Overview page.
* **Documentation**
* Added an overview doc describing Data API endpoints and configuration.
* **Tests**
* Added unit tests for endpoint resolution and schema parsing utilities.
---
.../Integrations/DataApi/OverviewTab.tsx | 40 +++
.../Integrations/DataApi/SettingsTab.tsx | 40 +++
.../Landing/Integrations.constants.tsx | 49 +++-
.../Landing/useInstalledIntegrations.tsx | 7 +
.../Settings/API/DataApiDisabledAlert.tsx | 19 ++
.../Settings/API/DataApiEnableSwitch.tsx | 163 +++++++++++
.../Settings/API/DataApiEnableSwitch.types.ts | 19 ++
.../API/DataApiEnableSwitch.utils.test.ts | 42 +++
.../Settings/API/DataApiEnableSwitch.utils.ts | 68 +++++
.../Settings/API/DataApiEnableSwitchForm.tsx | 78 ++++++
.../API/DataApiEnableSwitchStates.tsx | 17 ++
.../Settings/API/DataApiProjectUrlCard.tsx | 106 ++++++++
.../API/DataApiProjectUrlCard.utils.test.ts | 120 ++++++++
.../API/DataApiProjectUrlCard.utils.ts | 34 +++
.../Settings/API/PostgrestConfig.tsx | 256 +++++++-----------
.../interfaces/Settings/API/ServiceList.tsx | 107 +-------
.../API/UnsafeEntitiesConfirmModal.tsx | 178 ++++++++++++
.../SettingsMenu.utils.tsx | 19 +-
.../data/queries/sql/tables-without-rls.ts | 73 +++++
apps/studio/hooks/misc/useIsDataApiEnabled.ts | 15 +
apps/studio/next.config.js | 5 +
.../integrations/[id]/[pageId]/index.tsx | 5 +
.../pages/project/[ref]/settings/api.tsx | 37 +--
.../integrations/data_api/overview.md | 4 +
24 files changed, 1209 insertions(+), 292 deletions(-)
create mode 100644 apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx
create mode 100644 apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts
create mode 100644 apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts
create mode 100644 apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx
create mode 100644 apps/studio/data/queries/sql/tables-without-rls.ts
create mode 100644 apps/studio/hooks/misc/useIsDataApiEnabled.ts
create mode 100644 apps/studio/static-data/integrations/data_api/overview.md
diff --git a/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx
new file mode 100644
index 0000000000000..f24c7fb34b6f6
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx
@@ -0,0 +1,40 @@
+import { useParams } from 'common'
+import { AlertCircle } from 'lucide-react'
+import { Alert_Shadcn_, AlertTitle_Shadcn_, cn } from 'ui'
+
+import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
+import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch'
+import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard'
+import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { PROJECT_STATUS } from '@/lib/constants'
+
+export const DataApiOverviewTab = () => {
+ const { ref: projectRef } = useParams()
+ const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery()
+ const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef })
+
+ const isLoading = isProjectLoading || isConfigLoading
+
+ return (
+
+
+ {!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? (
+
+
+
+ API settings are unavailable as the project is not active
+
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx
new file mode 100644
index 0000000000000..819323890c765
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx
@@ -0,0 +1,40 @@
+import { useParams } from 'common'
+import { AlertCircle } from 'lucide-react'
+import Link from 'next/link'
+import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
+import { PageContainer } from 'ui-patterns'
+
+import { ServiceList } from '@/components/interfaces/Settings/API/ServiceList'
+import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
+
+export const DataApiSettingsTab = () => {
+ const { ref: projectRef } = useParams()
+ const { isEnabled, isPending } = useIsDataApiEnabled({ projectRef })
+
+ if (!isPending && !isEnabled) {
+ return (
+
+
+
+ Data API is disabled
+
+ Enable the Data API in the{' '}
+
+ Overview
+ {' '}
+ tab to configure settings.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
index 8dff7c8b05973..c9ad5e65f21a9 100644
--- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
+++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
@@ -1,4 +1,4 @@
-import { Clock5, Layers, Timer, Vault, Webhook, Receipt } from 'lucide-react'
+import { Clock5, Code2, Layers, Timer, Vault, Webhook } from 'lucide-react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import { ComponentType, ReactNode } from 'react'
@@ -260,6 +260,53 @@ const SUPABASE_INTEGRATIONS: IntegrationDefinition[] = [
return null
},
},
+ {
+ id: 'data_api',
+ type: 'custom' as const,
+ requiredExtensions: [],
+ name: `Data API`,
+ icon: ({ className, ...props } = {}) => (
+
+ ),
+ description: 'Auto-generate an API directly from your database schema',
+ docsUrl: `${DOCS_URL}/guides/api`,
+ author: authorSupabase,
+ navigation: [
+ {
+ route: 'overview',
+ label: 'Overview',
+ },
+ {
+ route: 'settings',
+ label: 'Settings',
+ },
+ ],
+ navigate: (_id: string, pageId: string = 'overview', _childId: string | undefined) => {
+ switch (pageId) {
+ case 'overview':
+ return dynamic(
+ () =>
+ import('components/interfaces/Integrations/DataApi/OverviewTab').then(
+ (mod) => mod.DataApiOverviewTab
+ ),
+ {
+ loading: Loading,
+ }
+ )
+ case 'settings':
+ return dynamic(
+ () =>
+ import('components/interfaces/Integrations/DataApi/SettingsTab').then(
+ (mod) => mod.DataApiSettingsTab
+ ),
+ {
+ loading: Loading,
+ }
+ )
+ }
+ return null
+ },
+ },
{
id: 'graphiql',
type: 'postgres_extension' as const,
diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx
index 14e92904770d9..8b9d954af0aae 100644
--- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx
+++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx
@@ -6,6 +6,7 @@ import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useFlag } from 'common'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { IS_PLATFORM } from 'lib/constants'
import { EMPTY_ARR } from 'lib/void'
import {
INSTALLATION_INSTALLED_SUFFIX,
@@ -30,6 +31,9 @@ export const useInstalledIntegrations = () => {
if (!stripeSyncEnabled && integration.id === 'stripe_sync_engine') {
return false
}
+ if (!IS_PLATFORM && integration.id === 'data_api') {
+ return false
+ }
return true
})
}, [integrationsWrappers, stripeSyncEnabled])
@@ -76,6 +80,9 @@ export const useInstalledIntegrations = () => {
if (integration.id === 'webhooks') {
return isHooksEnabled
}
+ if (integration.id === 'data_api') {
+ return true
+ }
if (integration.id === 'stripe_sync_engine') {
const stripeSchema = schemas?.find(({ name }) => name === 'stripe')
return (
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx b/apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx
new file mode 100644
index 0000000000000..e17deb9f1681e
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx
@@ -0,0 +1,19 @@
+import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, WarningIcon } from 'ui'
+
+export const DataApiDisabledAlert = () => {
+ return (
+
+
+ No schemas can be queried
+
+
+ With this setting disabled, you will not be able to query any schemas via the Data API.
+
+
+ You will see errors from the Postgrest endpoint{' '}
+ /rest/v1/.
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx
new file mode 100644
index 0000000000000..ec63e627042ff
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx
@@ -0,0 +1,163 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useParams } from 'common'
+import { useCallback, useEffect, useReducer } from 'react'
+import { useForm } from 'react-hook-form'
+import { toast } from 'sonner'
+import { Card } from 'ui'
+
+import { DataApiEnableSwitchForm } from './DataApiEnableSwitchForm'
+import { DataApiEnableSwitchError, DataApiEnableSwitchLoading } from './DataApiEnableSwitchStates'
+import { dataApiFormSchema, type DataApiFormValues } from './DataApiEnableSwitch.types'
+import {
+ enableCheckReducer,
+ getDefaultSchemas,
+ queryUnsafeEntitiesInApi,
+} from './DataApiEnableSwitch.utils'
+import { UnsafeEntitiesConfirmModal } from './UnsafeEntitiesConfirmModal'
+import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
+import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
+import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
+
+export const DataApiEnableSwitch = () => {
+ const { ref: projectRef } = useParams()
+ const { data: project } = useSelectedProjectQuery()
+ const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } =
+ useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
+
+ const {
+ data: config,
+ isError,
+ isPending: isLoadingConfig,
+ } = useProjectPostgrestConfigQuery({ projectRef })
+ const { isEnabled, isPending: isEnabledCheckPending } = useIsDataApiEnabled({ projectRef })
+
+ const { mutate: updatePostgrestConfig, isPending: isUpdating } =
+ useProjectPostgrestConfigUpdateMutation({
+ onSuccess: (_data, variables) => {
+ toast.success(variables.dbSchema ? 'Data API enabled' : 'Data API disabled')
+ },
+ })
+
+ const [enableCheck, dispatchEnableCheck] = useReducer(enableCheckReducer, { status: 'idle' })
+
+ const formId = 'data-api-enable-form'
+ const isLoading = isLoadingConfig || !projectRef
+
+ const form = useForm({
+ resolver: zodResolver(dataApiFormSchema),
+ mode: 'onChange',
+ defaultValues: {
+ enableDataApi: false,
+ },
+ })
+
+ const syncForm = useStaticEffectEvent(() => {
+ if (!isEnabledCheckPending) {
+ form.reset({ enableDataApi: isEnabled })
+ }
+ })
+ useEffect(() => {
+ syncForm()
+ }, [syncForm, isEnabled])
+
+ const doUpdate = useCallback(
+ (enableDataApi: boolean) => {
+ if (!projectRef || !config) return
+
+ const dbSchema = enableDataApi ? getDefaultSchemas(config.db_schema).join(', ') : ''
+
+ updatePostgrestConfig({
+ projectRef,
+ dbSchema,
+ maxRows: config.max_rows,
+ dbExtraSearchPath: config.db_extra_search_path ?? '',
+ dbPool: config.db_pool ?? null,
+ })
+ },
+ [projectRef, config, updatePostgrestConfig]
+ )
+
+ const onSubmit = useCallback(
+ async ({ enableDataApi }: DataApiFormValues) => {
+ if (!projectRef) return
+
+ if (!enableDataApi || isEnabled) {
+ doUpdate(enableDataApi)
+ return
+ }
+
+ // Enabling — check for entities with security issues in the target schemas
+ const targetSchemas = getDefaultSchemas(config?.db_schema)
+
+ dispatchEnableCheck({ type: 'START_CHECK' })
+ try {
+ const entities = await queryUnsafeEntitiesInApi({
+ projectRef,
+ connectionString: project?.connectionString,
+ schemas: targetSchemas,
+ })
+
+ if (entities.length > 0) {
+ dispatchEnableCheck({ type: 'ENTITIES_FOUND', unsafeEntities: entities })
+ } else {
+ dispatchEnableCheck({ type: 'DISMISS' })
+ doUpdate(true)
+ }
+ } catch (error) {
+ console.error('Failed to check for exposed entities', error)
+ dispatchEnableCheck({ type: 'DISMISS' })
+ toast.error('Failed to check for exposed entities')
+ }
+ },
+ [projectRef, isEnabled, config?.db_schema, project?.connectionString, doUpdate]
+ )
+
+ const handleReset = useCallback(() => {
+ if (isEnabledCheckPending) return
+ form.reset({ enableDataApi: isEnabled })
+ }, [isEnabledCheckPending, isEnabled, form])
+
+ const isBusy = isUpdating || enableCheck.status === 'checking'
+ const disabled = !canUpdatePostgrestConfig || isBusy
+ const permissionsHelper =
+ isPermissionsLoaded && !canUpdatePostgrestConfig
+ ? "You need additional permissions to update your project's API settings"
+ : undefined
+
+ const cardContent = isLoading ? (
+
+ ) : isError || !config ? (
+
+ ) : (
+
+ )
+
+ return (
+ <>
+ {cardContent}
+
+ dispatchEnableCheck({ type: 'DISMISS' })}
+ onConfirm={() => {
+ dispatchEnableCheck({ type: 'DISMISS' })
+ doUpdate(true)
+ }}
+ />
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts
new file mode 100644
index 0000000000000..37be4a242f898
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts
@@ -0,0 +1,19 @@
+import { z } from 'zod'
+
+import type { ExposedEntity } from './DataApiEnableSwitch.utils'
+
+export const dataApiFormSchema = z.object({
+ enableDataApi: z.boolean(),
+})
+
+export type DataApiFormValues = z.infer
+
+export type EnableCheckState =
+ | { status: 'idle' }
+ | { status: 'checking' }
+ | { status: 'confirming'; unsafeEntities: Array }
+
+export type EnableCheckAction =
+ | { type: 'START_CHECK' }
+ | { type: 'ENTITIES_FOUND'; unsafeEntities: Array }
+ | { type: 'DISMISS' }
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts
new file mode 100644
index 0000000000000..0e423ba255e2a
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+
+import type { EnableCheckState } from './DataApiEnableSwitch.types'
+import { enableCheckReducer, getDefaultSchemas } from './DataApiEnableSwitch.utils'
+
+describe('getDefaultSchemas', () => {
+ it('returns ["public"] for undefined', () => {
+ expect(getDefaultSchemas(undefined)).toEqual(['public'])
+ })
+
+ it('returns ["public"] for null', () => {
+ expect(getDefaultSchemas(null)).toEqual(['public'])
+ })
+
+ it('returns ["public"] for empty string', () => {
+ expect(getDefaultSchemas('')).toEqual(['public'])
+ })
+
+ it('returns ["public"] for whitespace-only string', () => {
+ expect(getDefaultSchemas(' , , ')).toEqual(['public'])
+ })
+
+ it('parses a single schema', () => {
+ expect(getDefaultSchemas('api')).toEqual(['api'])
+ })
+
+ it('parses multiple comma-separated schemas', () => {
+ expect(getDefaultSchemas('public, storage, graphql_public')).toEqual([
+ 'public',
+ 'storage',
+ 'graphql_public',
+ ])
+ })
+
+ it('trims whitespace from schemas', () => {
+ expect(getDefaultSchemas(' public , storage ')).toEqual(['public', 'storage'])
+ })
+
+ it('filters out empty segments', () => {
+ expect(getDefaultSchemas('public,,storage,')).toEqual(['public', 'storage'])
+ })
+})
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts
new file mode 100644
index 0000000000000..0e97ada28a678
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts
@@ -0,0 +1,68 @@
+import { unsafeEntitiesInApiSql } from '@/data/queries/sql/tables-without-rls'
+import { executeSql } from '@/data/sql/execute-sql-query'
+
+import type { EnableCheckAction, EnableCheckState } from './DataApiEnableSwitch.types'
+
+export type ExposedEntity = {
+ schema: string
+ name: string
+ type: 'table' | 'foreign table' | 'materialized view' | 'view'
+}
+
+/**
+ * Queries for entities that would be exposed through the Data API with
+ * potential security issues: tables without RLS, foreign tables, materialized
+ * views, and views without SECURITY INVOKER.
+ *
+ * This checks against the _target_ schemas rather than the currently active
+ * PostgREST config, so it works correctly when enabling the Data API.
+ */
+export async function queryUnsafeEntitiesInApi({
+ projectRef,
+ connectionString,
+ schemas,
+}: {
+ projectRef: string
+ connectionString?: string | null
+ schemas: Array
+}): Promise> {
+ if (schemas.length === 0) return []
+
+ const { result } = await executeSql>({
+ projectRef,
+ connectionString,
+ sql: unsafeEntitiesInApiSql(schemas),
+ queryKey: ['unsafe-entities-in-api'],
+ })
+
+ return result ?? []
+}
+
+export const getDefaultSchemas = (dbSchema: string | null | undefined) => {
+ const schemas =
+ dbSchema
+ ?.split(',')
+ .map((schema) => schema.trim())
+ .filter((schema) => schema.length > 0) ?? []
+
+ return schemas.length > 0 ? schemas : ['public']
+}
+
+export function enableCheckReducer(
+ state: EnableCheckState,
+ action: EnableCheckAction
+): EnableCheckState {
+ switch (state.status) {
+ case 'idle':
+ if (action.type === 'START_CHECK') return { status: 'checking' }
+ return state
+ case 'checking':
+ if (action.type === 'ENTITIES_FOUND')
+ return { status: 'confirming', unsafeEntities: action.unsafeEntities }
+ if (action.type === 'DISMISS') return { status: 'idle' }
+ return state
+ case 'confirming':
+ if (action.type === 'DISMISS') return { status: 'idle' }
+ return state
+ }
+}
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx
new file mode 100644
index 0000000000000..cdb8e2ab8bbdc
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx
@@ -0,0 +1,78 @@
+import type { UseFormReturn } from 'react-hook-form'
+import {
+ CardContent,
+ CardFooter,
+ Form_Shadcn_,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ FormItem_Shadcn_,
+ Switch,
+} from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+
+import { DataApiDisabledAlert } from './DataApiDisabledAlert'
+import type { DataApiFormValues } from './DataApiEnableSwitch.types'
+import { FormActions } from '@/components/ui/Forms/FormActions'
+
+export const DataApiEnableSwitchForm = ({
+ form,
+ formId,
+ disabled,
+ isBusy,
+ permissionsHelper,
+ onSubmit,
+ handleReset,
+}: {
+ form: UseFormReturn
+ formId: string
+ disabled: boolean
+ isBusy: boolean
+ permissionsHelper: string | undefined
+ onSubmit: (values: DataApiFormValues) => void
+ handleReset: () => void
+}) => {
+ const watchedEnabled = form.watch('enableDataApi')
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx
new file mode 100644
index 0000000000000..4fcf33d557a89
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx
@@ -0,0 +1,17 @@
+import { AlertCircle } from 'lucide-react'
+import { Alert_Shadcn_, AlertTitle_Shadcn_, CardContent } from 'ui'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+export const DataApiEnableSwitchLoading = () => (
+
+
+
+
+)
+
+export const DataApiEnableSwitchError = () => (
+
+
+ Failed to retrieve Data API settings
+
+)
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx
new file mode 100644
index 0000000000000..bbee03d3ba896
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx
@@ -0,0 +1,106 @@
+import { useParams } from 'common'
+import { AlertCircle } from 'lucide-react'
+import { parseAsString, useQueryState } from 'nuqs'
+import { useEffect } from 'react'
+import { Alert_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
+import {
+ PageSection,
+ PageSectionAside,
+ PageSectionContent,
+ PageSectionDescription,
+ PageSectionMeta,
+ PageSectionSummary,
+ PageSectionTitle,
+} from 'ui-patterns'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
+import { useCustomDomainsQuery } from '@/data/custom-domains/custom-domains-query'
+import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query'
+import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
+import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
+import { getApiEndpoint } from './DataApiProjectUrlCard.utils'
+
+export const DataApiProjectUrlCard = () => {
+ const { isPending: isLoading } = useSelectedProjectQuery()
+ const { ref: projectRef } = useParams()
+ const state = useDatabaseSelectorStateSnapshot()
+
+ const [querySource, setQuerySource] = useQueryState('source', parseAsString)
+
+ const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
+ const {
+ data: databases,
+ isError,
+ isPending: isLoadingDatabases,
+ } = useReadReplicasQuery({ projectRef })
+ const { data: loadBalancers } = useLoadBalancersQuery({ projectRef })
+
+ const syncSelectedDb = useStaticEffectEvent(() => {
+ if (querySource && querySource !== state.selectedDatabaseId) {
+ state.setSelectedDatabaseId(querySource)
+ }
+ })
+ useEffect(() => {
+ syncSelectedDb()
+ }, [syncSelectedDb, querySource, projectRef])
+
+ const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId)
+ const loadBalancerSelected = state.selectedDatabaseId === 'load-balancer'
+ const replicaSelected = selectedDatabase?.identifier !== projectRef
+
+ const endpoint = getApiEndpoint({
+ selectedDatabaseId: state.selectedDatabaseId,
+ projectRef,
+ customDomainData,
+ loadBalancers,
+ selectedDatabase,
+ })
+
+ return (
+
+
+
+ API URL
+
+ {loadBalancerSelected
+ ? 'RESTful endpoint for querying and managing your databases through your load balancer'
+ : replicaSelected
+ ? 'RESTful endpoint for querying your read replica'
+ : 'RESTful endpoint for querying and managing your database'}
+
+
+
+ 0
+ ? [{ id: 'load-balancer', name: 'API Load Balancer' }]
+ : []
+ }
+ onSelectId={() => {
+ setQuerySource(null)
+ }}
+ />
+
+
+
+ {isLoading || isLoadingDatabases ? (
+
+
+
+
+ ) : isError ? (
+
+
+ Failed to retrieve project URL
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts
new file mode 100644
index 0000000000000..56bdea65cc6f2
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+
+import { getApiEndpoint } from './DataApiProjectUrlCard.utils'
+import type {
+ CustomDomainResponse,
+ CustomDomainsData,
+} from '@/data/custom-domains/custom-domains-query'
+import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query'
+import type { Database } from '@/data/read-replicas/replicas-query'
+
+const makeCustomDomainData = (hostname: string): CustomDomainsData => ({
+ customDomain: {
+ id: '',
+ ssl: {} as CustomDomainResponse['ssl'],
+ hostname,
+ status: 'active',
+ created_at: '',
+ custom_metadata: null,
+ custom_origin_server: '',
+ },
+ status: '5_services_reconfigured',
+})
+
+const makeDatabase = (
+ identifier: string,
+ restUrl: string
+): Pick => ({ identifier, restUrl })
+
+const makeLoadBalancer = (endpoint: string): Pick => ({ endpoint })
+
+describe('getApiEndpoint', () => {
+ it('returns custom domain URL when custom domain is active and primary database is selected', () => {
+ expect(
+ getApiEndpoint({
+ selectedDatabaseId: 'project-ref',
+ projectRef: 'project-ref',
+ customDomainData: makeCustomDomainData('api.example.com'),
+ loadBalancers: undefined,
+ selectedDatabase: makeDatabase(
+ 'project-ref',
+ 'https://project-ref.supabase.co/rest/v1'
+ ) as Database,
+ })
+ ).toBe('https://api.example.com')
+ })
+
+ it('returns database restUrl when custom domain is active but a replica is selected', () => {
+ expect(
+ getApiEndpoint({
+ selectedDatabaseId: 'replica-1',
+ projectRef: 'project-ref',
+ customDomainData: makeCustomDomainData('api.example.com'),
+ loadBalancers: undefined,
+ selectedDatabase: makeDatabase(
+ 'replica-1',
+ 'https://replica-1.supabase.co/rest/v1'
+ ) as Database,
+ })
+ ).toBe('https://replica-1.supabase.co/rest/v1')
+ })
+
+ it('returns load balancer endpoint when load balancer is selected', () => {
+ expect(
+ getApiEndpoint({
+ selectedDatabaseId: 'load-balancer',
+ projectRef: 'project-ref',
+ customDomainData: undefined,
+ loadBalancers: [makeLoadBalancer('https://lb.supabase.co') as LoadBalancer],
+ selectedDatabase: undefined,
+ })
+ ).toBe('https://lb.supabase.co')
+ })
+
+ it('returns empty string when load balancer is selected but none exist', () => {
+ expect(
+ getApiEndpoint({
+ selectedDatabaseId: 'load-balancer',
+ projectRef: 'project-ref',
+ customDomainData: undefined,
+ loadBalancers: undefined,
+ selectedDatabase: undefined,
+ })
+ ).toBe('')
+ })
+
+ it('returns database restUrl for a normal database selection', () => {
+ expect(
+ getApiEndpoint({
+ selectedDatabaseId: 'project-ref',
+ projectRef: 'project-ref',
+ customDomainData: undefined,
+ loadBalancers: undefined,
+ selectedDatabase: makeDatabase(
+ 'project-ref',
+ 'https://project-ref.supabase.co/rest/v1'
+ ) as Database,
+ })
+ ).toBe('https://project-ref.supabase.co/rest/v1')
+ })
+
+ it('ignores custom domain when it is not active', () => {
+ const inactiveCustomDomain: CustomDomainsData = {
+ customDomain: null,
+ status: '0_no_hostname_configured',
+ }
+
+ expect(
+ getApiEndpoint({
+ selectedDatabaseId: 'project-ref',
+ projectRef: 'project-ref',
+ customDomainData: inactiveCustomDomain,
+ loadBalancers: undefined,
+ selectedDatabase: makeDatabase(
+ 'project-ref',
+ 'https://project-ref.supabase.co/rest/v1'
+ ) as Database,
+ })
+ ).toBe('https://project-ref.supabase.co/rest/v1')
+ })
+})
diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts
new file mode 100644
index 0000000000000..880dbbb6c4fef
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts
@@ -0,0 +1,34 @@
+import type { CustomDomainsData } from '@/data/custom-domains/custom-domains-query'
+import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query'
+import type { Database } from '@/data/read-replicas/replicas-query'
+
+/**
+ * Resolves the API endpoint URL based on the selected database, custom domain
+ * status, and load balancer configuration.
+ */
+export function getApiEndpoint({
+ selectedDatabaseId,
+ projectRef,
+ customDomainData,
+ loadBalancers,
+ selectedDatabase,
+}: {
+ selectedDatabaseId: string | undefined
+ projectRef: string | undefined
+ customDomainData: CustomDomainsData | undefined
+ loadBalancers: Array | undefined
+ selectedDatabase: Database | undefined
+}): string {
+ const isCustomDomainActive = customDomainData?.customDomain?.status === 'active'
+ const loadBalancerSelected = selectedDatabaseId === 'load-balancer'
+
+ if (isCustomDomainActive && selectedDatabaseId === projectRef) {
+ return `https://${customDomainData.customDomain.hostname}`
+ }
+
+ if (loadBalancerSelected) {
+ return loadBalancers?.[0]?.endpoint ?? ''
+ }
+
+ return selectedDatabase?.restUrl ?? ''
+}
diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
index 30360a645b038..5a1da6e630977 100644
--- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
+++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
@@ -1,32 +1,17 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
-import { DocsButton } from 'components/ui/DocsButton'
-import { FormActions } from 'components/ui/Forms/FormActions'
-import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
-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 { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { DOCS_URL } from 'lib/constants'
import { indexOf } from 'lodash'
import { Lock } from 'lucide-react'
import Link from 'next/link'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
- Alert_Shadcn_,
- AlertDescription_Shadcn_,
- AlertTitle_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
- CardHeader,
- Collapsible_Shadcn_,
- CollapsibleContent_Shadcn_,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -34,10 +19,8 @@ import {
Input_Shadcn_,
PrePostTab,
Skeleton,
- Switch,
- WarningIcon,
} from 'ui'
-import { GenericSkeletonLoader } from 'ui-patterns'
+import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
@@ -50,32 +33,25 @@ import {
import { z } from 'zod'
import { HardenAPIModal } from './HardenAPIModal'
+import { FormActions } from '@/components/ui/Forms/FormActions'
+import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
+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 { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
-const formSchema = z
- .object({
- dbSchema: z.array(z.string()),
- dbExtraSearchPath: z.array(z.string()),
- maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
- dbPool: z
- .number()
- .min(0, 'Must be more than 0')
- .max(1000, "Can't be more than 1000")
- .optional()
- .nullable(),
- enableDataApi: z.boolean(),
- })
- .refine(
- (data) => {
- if (data.enableDataApi && data.dbSchema.length === 0) {
- return false
- }
- return true
- },
- {
- message: 'Must have at least one schema if Data API is enabled',
- path: ['dbSchema'],
- }
- )
+const formSchema = z.object({
+ dbSchema: z.array(z.string()),
+ dbExtraSearchPath: z.array(z.string()),
+ maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
+ dbPool: z
+ .number()
+ .min(0, 'Must be more than 0')
+ .max(1000, "Can't be more than 1000")
+ .optional()
+ .nullable(),
+})
export const PostgrestConfig = () => {
const { ref: projectRef } = useParams()
@@ -128,16 +104,18 @@ export const PostgrestConfig = () => {
const isGraphqlExtensionEnabled =
(extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null
- const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : []
- const defaultValues = {
- dbSchema,
- maxRows: config?.max_rows,
- dbExtraSearchPath: (config?.db_extra_search_path ?? '')
- .split(',')
- .map((x) => x.trim())
- .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)),
- dbPool: config?.db_pool,
- }
+ const defaultValues = useMemo(() => {
+ const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : []
+ return {
+ dbSchema,
+ maxRows: config?.max_rows,
+ dbExtraSearchPath: (config?.db_extra_search_path ?? '')
+ .split(',')
+ .map((x) => x.trim())
+ .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)),
+ dbPool: config?.db_pool,
+ }
+ }, [config, allSchemas])
const form = useForm>({
resolver: zodResolver(formSchema),
@@ -160,10 +138,9 @@ export const PostgrestConfig = () => {
}
}) ?? []
- function resetForm() {
- const enableDataApi = config?.db_schema ? true : false
- form.reset({ ...defaultValues, enableDataApi })
- }
+ const resetForm = useCallback(() => {
+ form.reset({ ...defaultValues })
+ }, [form, defaultValues])
const onSubmit = async (values: z.infer) => {
if (!projectRef) return console.error('Project ref is required') // is this needed ?
@@ -179,91 +156,26 @@ export const PostgrestConfig = () => {
useEffect(() => {
if (config && isSuccessSchemas) {
- /**
- * Checks if enableDataApi should be enabled or disabled
- * based on the db_schema value being empty string
- */
resetForm()
}
- }, [config, isSuccessSchemas])
-
- const isDataApiEnabledInForm = form.getValues('enableDataApi')
+ }, [config, isSuccessSchemas, resetForm])
return (
-
-
- Data API Settings
-
-
- } onClick={() => setShowModal(true)}>
- Harden Data API
-
-
-
-
-
-
-
-
-
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+ } onClick={() => setShowModal(true)}>
+ Harden Data API
+
+
+
+
+
+
setShowModal(false)} />
-
+
)
}
diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
index e4c4d00f50296..5fc24b9606bd6 100644
--- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
+++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
@@ -1,55 +1,13 @@
import { AlertCircle } from 'lucide-react'
-import { parseAsString, useQueryState } from 'nuqs'
-import { useEffect } from 'react'
+import { Alert_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
-import { useParams } from 'common'
-import { ScaffoldSection } from 'components/layouts/Scaffold'
-import { DatabaseSelector } from 'components/ui/DatabaseSelector'
-import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
-import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
-import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
-import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { PROJECT_STATUS } from 'lib/constants'
-import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
-import { Alert_Shadcn_, AlertTitle_Shadcn_, Badge, Card, CardContent, CardHeader } from 'ui'
-import { Input } from 'ui-patterns/DataInputs/Input'
-import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
-import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { PostgrestConfig } from './PostgrestConfig'
+import { ScaffoldSection } from '@/components/layouts/Scaffold'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+import { PROJECT_STATUS } from '@/lib/constants'
export const ServiceList = () => {
const { data: project, isPending: isLoading } = useSelectedProjectQuery()
- const { ref: projectRef } = useParams()
- const state = useDatabaseSelectorStateSnapshot()
-
- const [querySource, setQuerySource] = useQueryState('source', parseAsString)
-
- const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
- const {
- data: databases,
- isError,
- isPending: isLoadingDatabases,
- } = useReadReplicasQuery({ projectRef })
- const { data: loadBalancers } = useLoadBalancersQuery({ projectRef })
-
- useEffect(() => {
- if (querySource && querySource !== state.selectedDatabaseId) {
- state.setSelectedDatabaseId(querySource)
- }
- }, [querySource, state, projectRef])
-
- // Get the API service
- const isCustomDomainActive = customDomainData?.customDomain?.status === 'active'
- const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId)
- const loadBalancerSelected = state.selectedDatabaseId === 'load-balancer'
- const replicaSelected = selectedDatabase?.identifier !== projectRef
-
- const endpoint =
- isCustomDomainActive && state.selectedDatabaseId === projectRef
- ? `https://${customDomainData.customDomain.hostname}`
- : loadBalancerSelected
- ? loadBalancers?.[0].endpoint ?? ''
- : selectedDatabase?.restUrl
return (
@@ -61,62 +19,7 @@ export const ServiceList = () => {
) : (
- <>
-
-
- Project URL
- 0
- ? [{ id: 'load-balancer', name: 'API Load Balancer' }]
- : []
- }
- onSelectId={() => {
- setQuerySource(null)
- }}
- />
-
-
- {isLoading || isLoadingDatabases ? (
-
-
-
-
- ) : isError ? (
-
-
- Failed to retrieve project URL
-
- ) : (
-
- URL
- Custom domain active
-
- ) : (
- 'URL'
- )
- }
- description={
- loadBalancerSelected
- ? 'RESTful endpoint for querying and managing your databases through your load balancer'
- : replicaSelected
- ? 'RESTful endpoint for querying your read replica'
- : 'RESTful endpoint for querying and managing your database'
- }
- className="[&>div]:xl:w-1/2 [&>div>div]:w-full"
- >
-
-
- )}
-
-
-
-
- >
+
)}
)
diff --git a/apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx b/apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx
new file mode 100644
index 0000000000000..2c022c962dfba
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx
@@ -0,0 +1,178 @@
+import { ChevronRight, ChevronUp } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import {
+ Button,
+ Collapsible_Shadcn_ as Collapsible,
+ CollapsibleContent_Shadcn_ as CollapsibleContent,
+ CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
+} from 'ui'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+
+import { type ExposedEntity } from './DataApiEnableSwitch.utils'
+
+interface UnsafeEntitiesConfirmModalProps {
+ visible: boolean
+ loading: boolean
+ unsafeEntities: Array
+ onCancel: () => void
+ onConfirm: () => void
+}
+
+const ENTITY_TYPE_META: Record<
+ ExposedEntity['type'],
+ { heading: string; recommendation: string; docsUrl: string }
+> = {
+ table: {
+ heading: 'Tables without Row Level Security',
+ recommendation: 'Enable RLS on these tables to control access per-row.',
+ docsUrl:
+ 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public',
+ },
+ 'foreign table': {
+ heading: 'Foreign tables',
+ recommendation:
+ 'Foreign tables do not support RLS. Revoke access from the anon and authenticated roles.',
+ docsUrl:
+ 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api',
+ },
+ 'materialized view': {
+ heading: 'Materialized views',
+ recommendation:
+ 'Materialized views do not support RLS. Revoke access from the anon and authenticated roles.',
+ docsUrl:
+ 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api',
+ },
+ view: {
+ heading: 'Views without SECURITY INVOKER',
+ recommendation:
+ 'These views run with the permissions of the view creator, not the querying user. Set SECURITY INVOKER to enforce caller permissions.',
+ docsUrl:
+ 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view',
+ },
+}
+
+const ENTITY_TYPE_ORDER: Array = [
+ 'table',
+ 'foreign table',
+ 'materialized view',
+ 'view',
+]
+
+const COLLAPSE_THRESHOLD = 3
+
+export const UnsafeEntitiesConfirmModal = ({
+ visible,
+ loading,
+ unsafeEntities,
+ onCancel,
+ onConfirm,
+}: UnsafeEntitiesConfirmModalProps) => {
+ const groupedEntities = useMemo(() => {
+ const groups = new Map>()
+ for (const entity of unsafeEntities) {
+ const group = groups.get(entity.type)
+ if (group) {
+ group.push(entity)
+ } else {
+ groups.set(entity.type, [entity])
+ }
+ }
+ return ENTITY_TYPE_ORDER.filter((type) => groups.has(type)).map((type) => ({
+ type,
+ ...ENTITY_TYPE_META[type],
+ entities: groups.get(type) ?? [],
+ }))
+ }, [unsafeEntities])
+
+ return (
+
+
+
+ The following objects will be publicly accessible through the Data API and are insecure.
+
+ {groupedEntities.map(({ type, heading, recommendation, docsUrl, entities }) => (
+
+ ))}
+
+
+ )
+}
+
+const EntityListItem = ({ entity }: { entity: ExposedEntity }) => (
+
+
+ {entity.schema}.{entity.name}
+
+
+)
+
+const EntityList = ({ entities }: { entities: Array }) => {
+ const [open, setOpen] = useState(false)
+
+ const shouldCollapse = entities.length > COLLAPSE_THRESHOLD
+ const visibleEntities = entities.slice(0, COLLAPSE_THRESHOLD)
+ const hiddenEntities = entities.slice(COLLAPSE_THRESHOLD)
+
+ if (!shouldCollapse) {
+ return (
+
+ {entities.map((entity) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+ {visibleEntities.map((entity) => (
+
+ ))}
+
+
+
+ {hiddenEntities.map((entity) => (
+
+ ))}
+
+
+
+
+
+ {open ? : }
+ {open ? 'Show less' : `Show ${hiddenEntities.length} more`}
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
index 41f0be4aa73bc..cd82c7aa6a5f5 100644
--- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
+++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
@@ -80,13 +80,6 @@ export const generateSettingsMenu = (
disabled: !isProjectActive,
},
- {
- name: 'Data API',
- key: 'api',
- url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/api`,
- items: [],
- disabled: !isProjectActive,
- },
{
name: 'API Keys',
key: 'api-keys',
@@ -117,6 +110,18 @@ export const generateSettingsMenu = (
url: `/project/${ref}/settings/addons`,
items: [],
},
+ ],
+ },
+ {
+ title: 'Configuration',
+ items: [
+ {
+ name: 'Data API',
+ key: 'api',
+ url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/data_api/overview`,
+ items: [],
+ rightIcon: ,
+ },
{
name: 'Vault',
key: 'vault',
diff --git a/apps/studio/data/queries/sql/tables-without-rls.ts b/apps/studio/data/queries/sql/tables-without-rls.ts
new file mode 100644
index 0000000000000..6251d1671f659
--- /dev/null
+++ b/apps/studio/data/queries/sql/tables-without-rls.ts
@@ -0,0 +1,73 @@
+import { quoteLiteral } from '@/lib/pg-format'
+
+/**
+ * Builds a SQL query that returns entities exposed through the Data API that
+ * have potential security issues:
+ *
+ * - Tables without Row Level Security (lint 0013)
+ * - Foreign tables accessible by anon/authenticated (lint 0017)
+ * - Materialized views accessible by anon/authenticated (lint 0016)
+ * - Views without SECURITY INVOKER on PG 15+ (lint 0010)
+ *
+ * Checks against the _target_ schemas rather than the currently active
+ * PostgREST config, so it works correctly when enabling the Data API.
+ */
+export const unsafeEntitiesInApiSql = (schemas: Array) => {
+ const schemaList = schemas.map(quoteLiteral).join(', ')
+
+ return /* SQL */ `
+ select
+ n.nspname as schema,
+ c.relname as name,
+ case c.relkind
+ when 'r' then 'table'
+ when 'f' then 'foreign table'
+ when 'm' then 'materialized view'
+ when 'v' then 'view'
+ end as type
+ from
+ pg_catalog.pg_class c
+ join pg_catalog.pg_namespace n on c.relnamespace = n.oid
+ left join pg_catalog.pg_depend dep
+ on c.oid = dep.objid
+ and dep.deptype = 'e'
+ where
+ (
+ pg_catalog.has_table_privilege('anon', c.oid, 'SELECT')
+ or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT')
+ )
+ and n.nspname in (${schemaList})
+ and n.nspname not in (
+ '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config',
+ '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql',
+ 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga',
+ 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog',
+ 'realtime', 'repack', 'storage', 'supabase_functions',
+ 'supabase_migrations', 'tiger', 'topology', 'vault'
+ )
+ and dep.objid is null
+ and (
+ -- Tables without RLS
+ (c.relkind = 'r' and not c.relrowsecurity)
+ -- Foreign tables (RLS not supported)
+ or c.relkind = 'f'
+ -- Materialized views (RLS not supported)
+ or c.relkind = 'm'
+ -- Views without security invoker (PG 15+)
+ or (
+ c.relkind = 'v'
+ and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15'
+ and not (
+ lower(coalesce(c.reloptions::text, '{}'))::text[]
+ && array[
+ 'security_invoker=1',
+ 'security_invoker=true',
+ 'security_invoker=yes',
+ 'security_invoker=on'
+ ]
+ )
+ )
+ )
+ order by n.nspname, c.relname
+ `
+}
diff --git a/apps/studio/hooks/misc/useIsDataApiEnabled.ts b/apps/studio/hooks/misc/useIsDataApiEnabled.ts
new file mode 100644
index 0000000000000..cb3662c8bd20a
--- /dev/null
+++ b/apps/studio/hooks/misc/useIsDataApiEnabled.ts
@@ -0,0 +1,15 @@
+import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
+
+/**
+ * Returns whether the Data API is enabled for the given project.
+ *
+ * The Data API is considered enabled when the PostgREST `db_schema` config
+ * contains at least one non-empty schema name.
+ */
+export const useIsDataApiEnabled = ({ projectRef }: { projectRef?: string }) => {
+ const { data: config, ...rest } = useProjectPostgrestConfigQuery({ projectRef })
+
+ const isEnabled = !!config?.db_schema?.trim()
+
+ return { ...rest, data: isEnabled, isEnabled }
+}
diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js
index 29599dc5d512e..b440009d0d93c 100644
--- a/apps/studio/next.config.js
+++ b/apps/studio/next.config.js
@@ -439,6 +439,11 @@ const nextConfig = {
destination: '/project/:ref/auth/providers',
permanent: true,
},
+ {
+ source: '/project/:ref/settings/api',
+ destination: '/project/:ref/integrations/data_api/overview',
+ permanent: false,
+ },
...(process.env.NEXT_PUBLIC_BASE_PATH?.length
? [
diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
index ee4fdac390509..22971a6f31703 100644
--- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx
@@ -1,4 +1,5 @@
import { useFlag, useParams } from 'common'
+import { IS_PLATFORM } from 'lib/constants'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { DefaultLayout } from 'components/layouts/DefaultLayout'
@@ -135,6 +136,10 @@ const IntegrationPage: NextPageWithLayout = () => {
return null
}
+ if (id === 'data_api' && !IS_PLATFORM) {
+ return
+ }
+
if (id === 'stripe_sync_engine' && !stripeSyncEnabled) {
return
}
diff --git a/apps/studio/pages/project/[ref]/settings/api.tsx b/apps/studio/pages/project/[ref]/settings/api.tsx
index 2914082b6a74d..7c9cfd014053a 100644
--- a/apps/studio/pages/project/[ref]/settings/api.tsx
+++ b/apps/studio/pages/project/[ref]/settings/api.tsx
@@ -1,23 +1,24 @@
-import { ServiceList } from 'components/interfaces/Settings/API/ServiceList'
-import DefaultLayout from 'components/layouts/DefaultLayout'
-import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
-import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
-import { ScaffoldContainer } from 'components/layouts/Scaffold'
-import type { NextPageWithLayout } from 'types'
+import { useParams } from 'common'
+import { IS_PLATFORM } from 'lib/constants'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+
+import type { NextPageWithLayout } from '@/types'
const ApiSettings: NextPageWithLayout = () => {
- return (
-
-
-
- )
+ const router = useRouter()
+ const { ref } = useParams()
+
+ useEffect(() => {
+ if (!ref) return
+ if (IS_PLATFORM) {
+ router.replace(`/project/${ref}/integrations/data_api/overview`)
+ } else {
+ router.replace(`/project/${ref}/settings/general`)
+ }
+ }, [ref, router])
+
+ return null
}
-ApiSettings.getLayout = (page) => (
-
-
- {page}
-
-
-)
export default ApiSettings
diff --git a/apps/studio/static-data/integrations/data_api/overview.md b/apps/studio/static-data/integrations/data_api/overview.md
new file mode 100644
index 0000000000000..028af1154b6bc
--- /dev/null
+++ b/apps/studio/static-data/integrations/data_api/overview.md
@@ -0,0 +1,4 @@
+The Supabase Data API exposes REST endpoints for your project's databases, including read replicas
+and load balancers.
+
+Configure the API base URL, custom domains, and PostgREST settings for your project.