From 2f5f6ffa795d097580738343489ae49cd08089cb Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 30 Apr 2026 07:16:06 -0600 Subject: [PATCH] chore: help users navigate graphql lints for anon and authenticated roles (#45295) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix, feature, docs update, ... - Hide lints when exposed within local storage - Revoke on roles ## Summary by CodeRabbit * **New Features** * Added a GraphQL-exposure action in linter items that shows a confirmation modal with the exact SQL, lets you revoke GraphQL access, executes the operation, shows success/error toasts, and refreshes lint results. * Added an informational callout linking to database integration settings when GraphQL exposure is detected. * Lint actions now close the side panel and return the UI to the list after completion. --------- Co-authored-by: Joshen Lim --- .../Linter/GraphqlExposureLintCTA.tsx | 191 ++++++++++++++++++ .../interfaces/Linter/LintDetail.tsx | 29 ++- .../interfaces/Linter/Linter.utils.tsx | 15 ++ .../interfaces/Linter/LinterDataGrid.tsx | 14 +- .../ui/AdvisorPanel/AdvisorDetail.tsx | 6 +- .../ui/AdvisorPanel/AdvisorPanel.tsx | 3 +- .../project/[ref]/advisors/performance.tsx | 4 +- .../pages/project/[ref]/advisors/security.tsx | 4 +- 8 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx diff --git a/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx b/apps/studio/components/interfaces/Linter/GraphqlExposureLintCTA.tsx new file mode 100644 index 0000000000..85cf2b4374 --- /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(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 7b11bdcb94..b730df09e0 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 ee95dbffad..387c8d3543 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 6a7e10abe9..93b66bcfd4 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 = ({