PublicBucketWarning.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { ident, safeSql } from '@supabase/pg-meta/src/pg-format'
  2. import { useMutation, useQueryClient } from '@tanstack/react-query'
  3. import { LOCAL_STORAGE_KEYS } from 'common'
  4. import { useEffect, useState, type ReactNode } from 'react'
  5. import { toast } from 'sonner'
  6. import { Button } from 'ui'
  7. import { Admonition } from 'ui-patterns/admonition'
  8. import { CodeBlock } from 'ui-patterns/CodeBlock'
  9. import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
  10. import { databasePoliciesKeys } from '@/data/database-policies/keys'
  11. import { executeSql } from '@/data/sql/execute-sql-query'
  12. import { storageKeys } from '@/data/storage/keys'
  13. import { usePublicBucketsWithSelectPoliciesQuery } from '@/data/storage/public-buckets-with-select-policies-query'
  14. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  15. import { useTrack } from '@/lib/telemetry/track'
  16. const DISMISS_DURATION_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
  17. function isDismissed(projectRef: string, bucketId: string): boolean {
  18. try {
  19. const raw = localStorage.getItem(
  20. LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId)
  21. )
  22. if (!raw) return false
  23. const { dismissedAt } = JSON.parse(raw) as { dismissedAt: string }
  24. return Date.now() - new Date(dismissedAt).getTime() < DISMISS_DURATION_MS
  25. } catch {
  26. return false
  27. }
  28. }
  29. function persistDismiss(projectRef: string, bucketId: string): void {
  30. localStorage.setItem(
  31. LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId),
  32. JSON.stringify({ dismissedAt: new Date().toISOString() })
  33. )
  34. }
  35. function generatePolicyRemovalSql(policyName: string) {
  36. return safeSql`DROP POLICY IF EXISTS ${ident(policyName)} ON storage.objects;`
  37. }
  38. export interface PublicBucketWarningProps {
  39. projectRef: string
  40. bucketId: string
  41. }
  42. export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarningProps): ReactNode {
  43. const queryClient = useQueryClient()
  44. const { data: project } = useSelectedProjectQuery()
  45. const { data } = usePublicBucketsWithSelectPoliciesQuery({
  46. projectRef,
  47. connectionString: project?.connectionString,
  48. bucketId,
  49. })
  50. const policyToRemove = data?.[0]
  51. const matchingPolicyCount = data?.length ?? 0
  52. const track = useTrack()
  53. const { mutate: removePolicy, isPending: isRemovingPolicy } = useMutation({
  54. mutationFn: async (policyName: string) => {
  55. await executeSql({
  56. projectRef,
  57. connectionString: project?.connectionString,
  58. sql: generatePolicyRemovalSql(policyName),
  59. })
  60. },
  61. onSuccess: async () => {
  62. await Promise.all([
  63. queryClient.invalidateQueries({
  64. queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId),
  65. }),
  66. queryClient.invalidateQueries({
  67. queryKey: databasePoliciesKeys.list(projectRef, 'storage'),
  68. }),
  69. ])
  70. track('storage_public_bucket_select_policy_removed', { bucketId })
  71. setShowModal(false)
  72. toast.success(
  73. matchingPolicyCount > 1
  74. ? `Policy removed successfully. ${matchingPolicyCount - 1} matching ${
  75. matchingPolicyCount - 1 === 1 ? 'policy' : 'policies'
  76. } remaining.`
  77. : 'Policy removed successfully'
  78. )
  79. },
  80. onError: (error) => {
  81. console.error('Failed to remove policy', error)
  82. toast.error(`Failed to remove policy: ${error.message}`)
  83. },
  84. })
  85. const [showModal, setShowModal] = useState(false)
  86. const [dismissed, setDismissed] = useState(() => isDismissed(projectRef, bucketId))
  87. useEffect(() => {
  88. setDismissed(isDismissed(projectRef, bucketId))
  89. setShowModal(false)
  90. }, [bucketId, projectRef])
  91. function handleDismiss() {
  92. persistDismiss(projectRef, bucketId)
  93. track('storage_public_bucket_select_policy_warning_dismiss_button_clicked', { bucketId })
  94. setDismissed(true)
  95. }
  96. return policyToRemove && !dismissed ? (
  97. <PublicBucketWarningView
  98. _tag="policy-to-remove"
  99. policyName={policyToRemove.policyname}
  100. policyCount={matchingPolicyCount}
  101. isRemovingPolicy={isRemovingPolicy}
  102. onRemovePolicy={() => removePolicy(policyToRemove.policyname)}
  103. isModalVisible={showModal}
  104. onShowModal={() => setShowModal(true)}
  105. onHideModal={() => setShowModal(false)}
  106. onDismiss={handleDismiss}
  107. />
  108. ) : (
  109. <PublicBucketWarningView _tag="no-policy-to-remove" />
  110. )
  111. }
  112. type PublicBucketWarningViewProps_NoPolicyToRemove = {
  113. _tag: 'no-policy-to-remove'
  114. }
  115. type PublicBucketWarningViewProps_PolicyToRemove = {
  116. _tag: 'policy-to-remove'
  117. policyName: string
  118. policyCount: number
  119. isRemovingPolicy: boolean
  120. onRemovePolicy: () => void
  121. isModalVisible: boolean
  122. onShowModal: () => void
  123. onHideModal: () => void
  124. onDismiss: () => void
  125. }
  126. type PublicBucketWarningViewProps =
  127. | PublicBucketWarningViewProps_NoPolicyToRemove
  128. | PublicBucketWarningViewProps_PolicyToRemove
  129. function PublicBucketWarningView(props: PublicBucketWarningViewProps): ReactNode {
  130. if (props._tag === 'no-policy-to-remove') {
  131. return null
  132. }
  133. const {
  134. policyName,
  135. policyCount,
  136. isRemovingPolicy,
  137. onRemovePolicy,
  138. isModalVisible,
  139. onShowModal,
  140. onHideModal,
  141. onDismiss,
  142. } = props
  143. const hasMultiplePolicies = policyCount > 1
  144. return (
  145. <>
  146. <Admonition
  147. type="warning"
  148. layout="horizontal"
  149. title="Clients can list all files in this bucket"
  150. description={
  151. hasMultiplePolicies
  152. ? `${policyCount} broad SELECT policies on storage.objects allow clients to retrieve a full list of files. Public buckets don’t need these policies and they may expose more data than intended.`
  153. : 'A broad SELECT policy on storage.objects allows clients to retrieve a full list of files. Public buckets don’t need this and it may expose more data than intended.'
  154. }
  155. actions={
  156. <div className="flex gap-2">
  157. <Button type="default" size="tiny" onClick={onDismiss}>
  158. Dismiss
  159. </Button>
  160. <Button type="warning" size="tiny" onClick={onShowModal}>
  161. Remove policy
  162. </Button>
  163. </div>
  164. }
  165. />
  166. <ConfirmationModal
  167. visible={isModalVisible}
  168. variant="destructive"
  169. title={
  170. hasMultiplePolicies
  171. ? `Remove SELECT policy (1 of ${policyCount})`
  172. : 'Remove SELECT policy'
  173. }
  174. confirmLabel="Remove policy"
  175. loading={isRemovingPolicy}
  176. onCancel={onHideModal}
  177. onConfirm={onRemovePolicy}
  178. >
  179. <div className="flex flex-col gap-3">
  180. <p className="text-sm text-foreground-light">
  181. This will drop {hasMultiplePolicies ? 'one' : 'the'}{' '}
  182. <code className="text-code-inline">SELECT</code>
  183. {
  184. ' policy that makes the bucket’s contents listable. Object URLs will continue to work.'
  185. }
  186. {hasMultiplePolicies
  187. ? ` ${policyCount - 1} matching ${
  188. policyCount - 1 === 1 ? 'policy' : 'policies'
  189. } will remain after this.`
  190. : null}
  191. </p>
  192. <div className="-mx-4 md:-mx-5 -mb-4 border-t">
  193. <CodeBlock
  194. hideLineNumbers
  195. language="sql"
  196. value={generatePolicyRemovalSql(policyName)}
  197. wrapperClassName="[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0 [&_pre>*]:whitespace-pre-wrap!"
  198. className="[&_code]:text-foreground"
  199. />
  200. </div>
  201. </div>
  202. </ConfirmationModal>
  203. </>
  204. )
  205. }