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
+
+ .
+