diff --git a/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx
new file mode 100644
index 00000000000..85cf2b4374e
--- /dev/null
+++ b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx
@@ -0,0 +1,191 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { EyeOff, Lock } from 'lucide-react'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import { Badge, Button } from 'ui'
+import { Admonition } from 'ui-patterns'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+
+import { InlineLink } from '@/components/ui/InlineLink'
+import { lintKeys } from '@/data/lint/keys'
+import { Lint } from '@/data/lint/lint-query'
+import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+
+const GRAPHQL_EXPOSURE_LINT_NAMES = [
+ 'pg_graphql_anon_table_exposed',
+ 'pg_graphql_authenticated_table_exposed',
+] as const
+
+export type GraphqlExposureLintName = (typeof GRAPHQL_EXPOSURE_LINT_NAMES)[number]
+
+export const asGraphqlExposureLint = (
+ name: string | undefined | null
+): GraphqlExposureLintName | null =>
+ !!name && (GRAPHQL_EXPOSURE_LINT_NAMES as readonly string[]).includes(name)
+ ? (name as GraphqlExposureLintName)
+ : null
+
+const quoteIdent = (ident: string) => `"${ident.replace(/"/g, '""')}"`
+
+interface GraphqlExposureLintCTAProps {
+ lintName: GraphqlExposureLintName
+ projectRef: string
+ metadata: Lint['metadata']
+ onAfterAction?: () => void
+}
+
+const ROLE_BY_LINT: Record = {
+ pg_graphql_anon_table_exposed: 'anon',
+ pg_graphql_authenticated_table_exposed: 'authenticated',
+}
+
+const AUDIENCE: Record = {
+ pg_graphql_anon_table_exposed: { lower: 'anonymous users', upper: 'Anonymous users' },
+ pg_graphql_authenticated_table_exposed: { lower: 'signed-in users', upper: 'Signed-in users' },
+}
+
+const TRIGGER_LABEL: Record = {
+ pg_graphql_anon_table_exposed: 'Remove access for anonymous users',
+ pg_graphql_authenticated_table_exposed: 'Remove access for signed-in users',
+}
+
+export const GraphqlExposureLintCTA = ({
+ lintName,
+ projectRef,
+ metadata,
+ onAfterAction,
+}: GraphqlExposureLintCTAProps) => {
+ const { data: project } = useSelectedProjectQuery()
+ const queryClient = useQueryClient()
+
+ const [showConfirmRevoke, setShowConfirmRevoke] = useState(false)
+
+ const schema = metadata?.schema
+ const name = metadata?.name
+ const objectType = metadata?.type ?? 'object'
+ const role = ROLE_BY_LINT[lintName]
+ const audience = AUDIENCE[lintName]
+ const canAct = !!schema && !!name
+
+ const revokeSql = canAct
+ ? `revoke all on ${quoteIdent(schema)}.${quoteIdent(name)} from ${role};`
+ : ''
+
+ const { mutate: executeSql, isPending: isRevoking } = useExecuteSqlMutation({
+ onSuccess: async () => {
+ toast.success(
+ `Revoked access to ${schema}.${name} from ${role}. ${audience.upper} can no longer query this ${objectType} via GraphQL or Data API.`
+ )
+ setShowConfirmRevoke(false)
+ await queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
+ onAfterAction?.()
+ },
+ onError: (error) => {
+ toast.error(`Failed to revoke access: ${error.message}`)
+ },
+ })
+
+ const handleRevoke = () => {
+ if (!canAct) return
+ executeSql({
+ projectRef,
+ connectionString: project?.connectionString,
+ sql: revokeSql,
+ })
+ }
+
+ return (
+ <>
+ setShowConfirmRevoke(true)}>
+ {TRIGGER_LABEL[lintName]}
+
+ setShowConfirmRevoke(false)}
+ onConfirm={handleRevoke}
+ >
+
+
This change affects both schema visibility and data access for {audience.lower}.
+
+ Alternatively, you can{' '}
+
+ disable GraphQL
+ {' '}
+ to remove schema visibility.
+
+
+
+
+
+
+
+
+
Data API access removed
+
Breaking change
+
+
+ {audience.upper} will no longer be able to read or write to this {objectType} via
+ Supabase APIs (GraphQL or Data API), even if RLS policies allow it.
+
+
+
+
+
+
+
+
Schema hidden from GraphQL
+
+ This {objectType} will no longer appear in the GraphQL schema. {audience.upper}{' '}
+ won't be able to discover its name, columns, or relationships.
+
+
+
+
+
+
+
+
+ The following statement will be executed:
+
+
+ {revokeSql}
+
+
+ >
+ )
+}
+
+export const GraphqlExposureCallout = ({ projectRef }: { projectRef: string }) => {
+ return (
+
+ These warnings are triggered by GraphQL exposing your table schemas. If you're not using
+ GraphQL, disable it from the{' '}
+
+ Database extensions page
+
+ .
+
+ }
+ />
+ )
+}
diff --git a/apps/studio/components/interfaces/Linter/LintDetail.tsx b/apps/studio/components/interfaces/Linter/LintDetail.tsx
index 7b11bdcb944..b730df09e0d 100644
--- a/apps/studio/components/interfaces/Linter/LintDetail.tsx
+++ b/apps/studio/components/interfaces/Linter/LintDetail.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link'
import { Button } from 'ui'
import { Markdown } from '../Markdown'
+import { asGraphqlExposureLint, GraphqlExposureCallout } from './GraphqlExposureLintCTA'
import { EntityTypeIcon, LintCTA, LintEntity } from './Linter.utils'
import { createLintSummaryPrompt, lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
@@ -17,12 +18,19 @@ interface LintDetailProps {
lint: Lint
projectRef: string
onAskAssistant?: () => void
+ onAfterAction?: () => void
}
-const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => {
+export const LintDetail = ({
+ lint,
+ projectRef,
+ onAskAssistant,
+ onAfterAction,
+}: LintDetailProps) => {
const track = useTrack()
const snap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
+ const isGraphqlExposureLint = !!asGraphqlExposureLint(lint.name)
const handleAskAssistant = () => {
track('advisor_assistant_button_clicked', {
@@ -61,15 +69,28 @@ const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => {
{lint.description.replace(/\\`/g, '`')}
+ {isGraphqlExposureLint && (
+
+
+
+ )}
+
Resolve
-
+
)
}
-
-export default LintDetail
diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx
index ee95dbffad3..387c8d35437 100644
--- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx
+++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx
@@ -14,6 +14,7 @@ import {
import Link from 'next/link'
import { Badge, Button } from 'ui'
+import { asGraphqlExposureLint, GraphqlExposureLintCTA } from './GraphqlExposureLintCTA'
import { LINTER_LEVELS, LintInfo } from '@/components/interfaces/Linter/Linter.constants'
import { Lint, LINT_TYPES } from '@/data/lint/lint-query'
import { DOCS_URL } from '@/lib/constants'
@@ -387,10 +388,12 @@ export const LintCTA = ({
title,
projectRef,
metadata,
+ onAfterAction,
}: {
title: LINT_TYPES
projectRef: string
metadata: Lint['metadata']
+ onAfterAction?: () => void
}) => {
const lintInfo = lintInfoMap.find((item) => item.name === title)
@@ -398,6 +401,18 @@ export const LintCTA = ({
return null
}
+ const graphqlExposureLintName = asGraphqlExposureLint(title)
+ if (graphqlExposureLintName) {
+ return (
+
+ )
+ }
+
const link = lintInfo.link({ projectRef, metadata })
const linkText = lintInfo.linkText
diff --git a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx
index 6a7e10abe96..93b66bcfd48 100644
--- a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx
+++ b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx
@@ -7,7 +7,7 @@ import ReactMarkdown from 'react-markdown'
import { Button, cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
-import LintDetail from './LintDetail'
+import { LintDetail } from './LintDetail'
import { EntityTypeIcon } from './Linter.utils'
import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
import {
@@ -26,7 +26,7 @@ interface LinterDataGridProps {
currentTab: LINTER_LEVELS
}
-const LinterDataGrid = ({
+export const LinterDataGrid = ({
isLoading,
filteredLints,
selectedLint,
@@ -195,8 +195,12 @@ const LinterDataGrid = ({
} onClick={handleSidepanelClose} />
-
-
+
+
>
@@ -204,5 +208,3 @@ const LinterDataGrid = ({
)
}
-
-export default LinterDataGrid
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx
index e18c77e43d0..39747950fbb 100644
--- a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx
+++ b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx
@@ -3,7 +3,7 @@ import { noop } from 'lodash'
import type { AdvisorItem } from './AdvisorPanel.types'
import { AdvisorSignalDetail } from './AdvisorSignalDetail'
import { NotificationDetail } from './NotificationDetail'
-import LintDetail from '@/components/interfaces/Linter/LintDetail'
+import { LintDetail } from '@/components/interfaces/Linter/LintDetail'
import type { Lint } from '@/data/lint/lint-query'
import type { Notification } from '@/data/notifications/notifications-v2-query'
@@ -11,18 +11,20 @@ interface AdvisorDetailProps {
item: AdvisorItem
projectRef: string
onUpdateNotificationStatus?: (id: string, status: 'archived' | 'seen') => void
+ onAfterLintAction?: () => void
}
export const AdvisorDetail = ({
item,
projectRef,
onUpdateNotificationStatus = noop,
+ onAfterLintAction,
}: AdvisorDetailProps) => {
if (item.source === 'lint') {
const lint = item.original as Lint
return (
-
+
)
}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx
index 83d1a259933..fde0e64638f 100644
--- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx
+++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx
@@ -102,7 +102,7 @@ export const AdvisorPanel = () => {
}
const lintItems = useMemo
(() => {
- return createAdvisorLintItems(lintData)
+ return createAdvisorLintItems(lintData ?? [])
}, [lintData])
const notificationItems = useMemo(() => {
@@ -240,6 +240,7 @@ export const AdvisorPanel = () => {
item={selectedItem}
projectRef={project?.ref ?? ''}
onUpdateNotificationStatus={handleUpdateNotificationStatus}
+ onAfterLintAction={handleBackToList}
/>
) : (
diff --git a/apps/studio/pages/project/[ref]/advisors/performance.tsx b/apps/studio/pages/project/[ref]/advisors/performance.tsx
index 8a05fe88874..13758a69ee7 100644
--- a/apps/studio/pages/project/[ref]/advisors/performance.tsx
+++ b/apps/studio/pages/project/[ref]/advisors/performance.tsx
@@ -4,12 +4,12 @@ import { LoadingLine } from 'ui'
import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
-import LinterDataGrid from '@/components/interfaces/Linter/LinterDataGrid'
+import { LinterDataGrid } from '@/components/interfaces/Linter/LinterDataGrid'
import LinterFilters from '@/components/interfaces/Linter/LinterFilters'
import { LinterPageFooter } from '@/components/interfaces/Linter/LinterPageFooter'
import LintPageTabs from '@/components/interfaces/Linter/LintPageTabs'
import AdvisorsLayout from '@/components/layouts/AdvisorsLayout/AdvisorsLayout'
-import DefaultLayout from '@/components/layouts/DefaultLayout'
+import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { FormHeader } from '@/components/ui/Forms/FormHeader'
import { Lint, useProjectLintsQuery } from '@/data/lint/lint-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
diff --git a/apps/studio/pages/project/[ref]/advisors/security.tsx b/apps/studio/pages/project/[ref]/advisors/security.tsx
index 2902b3a22ca..993a607227d 100644
--- a/apps/studio/pages/project/[ref]/advisors/security.tsx
+++ b/apps/studio/pages/project/[ref]/advisors/security.tsx
@@ -4,12 +4,12 @@ import { LoadingLine } from 'ui'
import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
-import LinterDataGrid from '@/components/interfaces/Linter/LinterDataGrid'
+import { LinterDataGrid } from '@/components/interfaces/Linter/LinterDataGrid'
import LinterFilters from '@/components/interfaces/Linter/LinterFilters'
import { LinterPageFooter } from '@/components/interfaces/Linter/LinterPageFooter'
import LintPageTabs from '@/components/interfaces/Linter/LintPageTabs'
import AdvisorsLayout from '@/components/layouts/AdvisorsLayout/AdvisorsLayout'
-import DefaultLayout from '@/components/layouts/DefaultLayout'
+import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { FormHeader } from '@/components/ui/Forms/FormHeader'
import { Lint, useProjectLintsQuery } from '@/data/lint/lint-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'