mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user