GraphqlExposureLintCTA.tsx191 lines · main
1import { ident, safeSql } from '@supabase/pg-meta/src/pg-format'
2import { useQueryClient } from '@tanstack/react-query'
3import { EyeOff, Lock } from 'lucide-react'
4import { useState } from 'react'
5import { toast } from 'sonner'
6import { Badge, Button } from 'ui'
7import { Admonition } from 'ui-patterns'
8import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
9
10import { InlineLink } from '@/components/ui/InlineLink'
11import { lintKeys } from '@/data/lint/keys'
12import { Lint } from '@/data/lint/lint-query'
13import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
14import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
15
16const GRAPHQL_EXPOSURE_LINT_NAMES = [
17 'pg_graphql_anon_table_exposed',
18 'pg_graphql_authenticated_table_exposed',
19] as const
20
21export type GraphqlExposureLintName = (typeof GRAPHQL_EXPOSURE_LINT_NAMES)[number]
22
23export const asGraphqlExposureLint = (
24 name: string | undefined | null
25): GraphqlExposureLintName | null =>
26 !!name && (GRAPHQL_EXPOSURE_LINT_NAMES as readonly string[]).includes(name)
27 ? (name as GraphqlExposureLintName)
28 : null
29
30interface GraphqlExposureLintCTAProps {
31 lintName: GraphqlExposureLintName
32 projectRef: string
33 metadata: Lint['metadata']
34 onAfterAction?: () => void
35}
36
37const ROLE_BY_LINT: Record<GraphqlExposureLintName, 'anon' | 'authenticated'> = {
38 pg_graphql_anon_table_exposed: 'anon',
39 pg_graphql_authenticated_table_exposed: 'authenticated',
40}
41
42const AUDIENCE: Record<GraphqlExposureLintName, { lower: string; upper: string }> = {
43 pg_graphql_anon_table_exposed: { lower: 'anonymous users', upper: 'Anonymous users' },
44 pg_graphql_authenticated_table_exposed: { lower: 'signed-in users', upper: 'Signed-in users' },
45}
46
47const TRIGGER_LABEL: Record<GraphqlExposureLintName, string> = {
48 pg_graphql_anon_table_exposed: 'Remove access for anonymous users',
49 pg_graphql_authenticated_table_exposed: 'Remove access for signed-in users',
50}
51
52export const GraphqlExposureLintCTA = ({
53 lintName,
54 projectRef,
55 metadata,
56 onAfterAction,
57}: GraphqlExposureLintCTAProps) => {
58 const { data: project } = useSelectedProjectQuery()
59 const queryClient = useQueryClient()
60
61 const [showConfirmRevoke, setShowConfirmRevoke] = useState(false)
62
63 const schema = metadata?.schema
64 const name = metadata?.name
65 const objectType = metadata?.type ?? 'object'
66 const role = ROLE_BY_LINT[lintName]
67 const audience = AUDIENCE[lintName]
68 const canAct = !!schema && !!name
69
70 const revokeSql =
71 schema && name
72 ? safeSql`revoke all on ${ident(schema)}.${ident(name)} from ${ident(role)};`
73 : undefined
74
75 const { mutate: executeSql, isPending: isRevoking } = useExecuteSqlMutation({
76 onSuccess: async () => {
77 toast.success(
78 `Revoked access to ${schema}.${name} from ${role}. ${audience.upper} can no longer query this ${objectType} via GraphQL or Data API.`
79 )
80 setShowConfirmRevoke(false)
81 await queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) })
82 onAfterAction?.()
83 },
84 onError: (error) => {
85 toast.error(`Failed to revoke access: ${error.message}`)
86 },
87 })
88
89 const handleRevoke = () => {
90 if (!revokeSql) return
91 executeSql({
92 projectRef,
93 connectionString: project?.connectionString,
94 sql: revokeSql,
95 })
96 }
97
98 return (
99 <>
100 <Button type="primary" disabled={!canAct} onClick={() => setShowConfirmRevoke(true)}>
101 {TRIGGER_LABEL[lintName]}
102 </Button>
103 <ConfirmationModal
104 visible={showConfirmRevoke}
105 size="xlarge"
106 title={
107 canAct
108 ? `Remove access to ${schema}.${name} for ${audience.lower}?`
109 : `Remove access for ${audience.lower}?`
110 }
111 confirmLabel="Remove access"
112 confirmLabelLoading="Removing access..."
113 cancelLabel="Cancel"
114 loading={isRevoking}
115 onCancel={() => setShowConfirmRevoke(false)}
116 onConfirm={handleRevoke}
117 >
118 <div className="text-sm text-foreground mb-6">
119 <p>This change affects both schema visibility and data access for {audience.lower}.</p>
120 <p>
121 Alternatively, you can{' '}
122 <InlineLink href={`/project/${projectRef}/database/extensions`}>
123 disable GraphQL
124 </InlineLink>{' '}
125 to remove schema visibility.
126 </p>
127 </div>
128
129 <div className="space-y-5">
130 <div className="flex gap-3">
131 <Lock className="text-foreground-light shrink-0 mt-0.5" size={20} strokeWidth={1.5} />
132 <div>
133 <div className="flex items-center gap-2">
134 <p className="text-sm text-foreground">Data API access removed</p>
135 <Badge variant="warning">Breaking change</Badge>
136 </div>
137 <p className="text-sm text-foreground-light mt-1">
138 {audience.upper} will no longer be able to read or write to this {objectType} via
139 Briven APIs (GraphQL or Data API), even if RLS policies allow it.
140 </p>
141 </div>
142 </div>
143
144 <div className="flex gap-3">
145 <EyeOff className="text-foreground-light shrink-0 mt-0.5" size={20} strokeWidth={1.5} />
146 <div>
147 <p className="text-sm text-foreground">Schema hidden from GraphQL</p>
148 <p className="text-sm text-foreground-light mt-1">
149 This {objectType} will no longer appear in the GraphQL schema. {audience.upper}{' '}
150 won't be able to discover its name, columns, or relationships.
151 </p>
152 </div>
153 </div>
154 </div>
155
156 <Admonition
157 type="warning"
158 title="When to keep access"
159 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.`}
160 className="mt-6"
161 />
162
163 <p className="text-sm text-foreground-light mt-6">
164 The following statement will be executed:
165 </p>
166 <pre className="mt-2 px-3 py-2 rounded bg-surface-200 text-xs font-mono whitespace-pre-wrap break-all">
167 {revokeSql}
168 </pre>
169 </ConfirmationModal>
170 </>
171 )
172}
173
174export const GraphqlExposureCallout = ({ projectRef }: { projectRef: string }) => {
175 return (
176 <Admonition
177 type="default"
178 title="Why this appears"
179 description={
180 <p>
181 These warnings are triggered by GraphQL exposing your table schemas. If you're not using
182 GraphQL, disable it from the{' '}
183 <InlineLink href={`/project/${projectRef}/database/extensions`}>
184 Database extensions page
185 </InlineLink>
186 .
187 </p>
188 }
189 />
190 )
191}