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