GraphqlExposureLintCTA.tsx 6.7 KB

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