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 


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ali Waseem
2026-04-30 07:16:06 -06:00
committed by GitHub
parent ebe3ef0133
commit 2f5f6ffa79
8 changed files with 248 additions and 18 deletions
@@ -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<GraphqlExposureLintName, 'anon' | 'authenticated'> = {
pg_graphql_anon_table_exposed: 'anon',
pg_graphql_authenticated_table_exposed: 'authenticated',
}
const AUDIENCE: Record<GraphqlExposureLintName, { lower: string; upper: string }> = {
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<GraphqlExposureLintName, string> = {
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 (
<>
<Button type="primary" disabled={!canAct} onClick={() => setShowConfirmRevoke(true)}>
{TRIGGER_LABEL[lintName]}
</Button>
<ConfirmationModal
visible={showConfirmRevoke}
size="xlarge"
title={
canAct
? `Remove access to ${schema}.${name} for ${audience.lower}?`
: `Remove access for ${audience.lower}?`
}
confirmLabel="Remove access"
confirmLabelLoading="Removing access..."
cancelLabel="Cancel"
loading={isRevoking}
onCancel={() => setShowConfirmRevoke(false)}
onConfirm={handleRevoke}
>
<div className="text-sm text-foreground mb-6">
<p>This change affects both schema visibility and data access for {audience.lower}.</p>
<p>
Alternatively, you can{' '}
<InlineLink href={`/project/${projectRef}/database/extensions`}>
disable GraphQL
</InlineLink>{' '}
to remove schema visibility.
</p>
</div>
<div className="space-y-5">
<div className="flex gap-3">
<Lock className="text-foreground-light shrink-0 mt-0.5" size={20} strokeWidth={1.5} />
<div>
<div className="flex items-center gap-2">
<p className="text-sm text-foreground">Data API access removed</p>
<Badge variant="warning">Breaking change</Badge>
</div>
<p className="text-sm text-foreground-light mt-1">
{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.
</p>
</div>
</div>
<div className="flex gap-3">
<EyeOff className="text-foreground-light shrink-0 mt-0.5" size={20} strokeWidth={1.5} />
<div>
<p className="text-sm text-foreground">Schema hidden from GraphQL</p>
<p className="text-sm text-foreground-light mt-1">
This {objectType} will no longer appear in the GraphQL schema. {audience.upper}{' '}
won't be able to discover its name, columns, or relationships.
</p>
</div>
</div>
</div>
<Admonition
type="warning"
title="When to keep access"
description={`If your app needs ${audience.lower} to query this ${objectType}, keep access and ignore this warning. Be aware that this ${objectType}'s schema will remain visible via the GraphQL API.`}
className="mt-6"
/>
<p className="text-sm text-foreground-light mt-6">
The following statement will be executed:
</p>
<pre className="mt-2 px-3 py-2 rounded bg-surface-200 text-xs font-mono whitespace-pre-wrap break-all">
{revokeSql}
</pre>
</ConfirmationModal>
</>
)
}
export const GraphqlExposureCallout = ({ projectRef }: { projectRef: string }) => {
return (
<Admonition
type="default"
title="Why this appears"
description={
<p>
These warnings are triggered by GraphQL exposing your table schemas. If you're not using
GraphQL, disable it from the{' '}
<InlineLink href={`/project/${projectRef}/database/extensions`}>
Database extensions page
</InlineLink>
.
</p>
}
/>
)
}
@@ -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, '`')}
</Markdown>
{isGraphqlExposureLint && (
<div className="mb-4">
<GraphqlExposureCallout projectRef={projectRef} />
</div>
)}
<h3 className="text-sm mb-2">Resolve</h3>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<AiAssistantDropdown
label="Ask Assistant"
buildPrompt={buildPromptForCopy}
onOpenAssistant={handleAskAssistant}
telemetrySource="lint_detail"
/>
<LintCTA title={lint.name} projectRef={projectRef} metadata={lint.metadata} />
<LintCTA
title={lint.name}
projectRef={projectRef}
metadata={lint.metadata}
onAfterAction={onAfterAction}
/>
<Button asChild type="text">
<Link
href={
@@ -89,5 +110,3 @@ const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => {
</div>
)
}
export default LintDetail
@@ -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 (
<GraphqlExposureLintCTA
lintName={graphqlExposureLintName}
projectRef={projectRef}
metadata={metadata}
onAfterAction={onAfterAction}
/>
)
}
const link = lintInfo.link({ projectRef, metadata })
const linkText = lintInfo.linkText
@@ -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 = ({
</div>
<Button type="text" icon={<X />} onClick={handleSidepanelClose} />
</div>
<div className="p-6 grow min-h-0 overflow-y-auto">
<LintDetail lint={selectedLint} projectRef={ref!} />
<div className="p-6 flex-grow min-h-0 overflow-y-auto">
<LintDetail
lint={selectedLint}
projectRef={ref!}
onAfterAction={handleSidepanelClose}
/>
</div>
</ResizablePanel>
</>
@@ -204,5 +208,3 @@ const LinterDataGrid = ({
</ResizablePanelGroup>
)
}
export default LinterDataGrid
@@ -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 (
<div className="px-6 py-6">
<LintDetail lint={lint} projectRef={projectRef} />
<LintDetail lint={lint} projectRef={projectRef} onAfterAction={onAfterLintAction} />
</div>
)
}
@@ -102,7 +102,7 @@ export const AdvisorPanel = () => {
}
const lintItems = useMemo<AdvisorItem[]>(() => {
return createAdvisorLintItems(lintData)
return createAdvisorLintItems(lintData ?? [])
}, [lintData])
const notificationItems = useMemo<AdvisorItem[]>(() => {
@@ -240,6 +240,7 @@ export const AdvisorPanel = () => {
item={selectedItem}
projectRef={project?.ref ?? ''}
onUpdateNotificationStatus={handleUpdateNotificationStatus}
onAfterLintAction={handleBackToList}
/>
) : (
<div className="px-6 py-8">
@@ -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'
@@ -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'