diff --git a/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx new file mode 100644 index 00000000000..efd0c307c51 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx @@ -0,0 +1,145 @@ +import { ident } from '@supabase/pg-meta/src/pg-format' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { storageKeys } from 'data/storage/keys' +import { useState, type ReactNode } from 'react' +import { toast } from 'sonner' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { CodeBlock } from 'ui-patterns/CodeBlock' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' + +import { executeSql } from '@/data/sql/execute-sql-query' +import { usePublicBucketsWithSelectPoliciesQuery } from '@/data/storage/public-buckets-with-select-policies-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +function generatePolicyRemovalSql(policyName: string) { + return `DROP POLICY IF EXISTS ${ident(policyName)} ON storage.objects;` +} + +export interface PublicBucketWarningProps { + projectRef: string + bucketId: string +} + +export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarningProps): ReactNode { + const queryClient = useQueryClient() + const { data: project } = useSelectedProjectQuery() + + const { data } = usePublicBucketsWithSelectPoliciesQuery({ + projectRef, + connectionString: project?.connectionString, + bucketId, + }) + const policyToRemove = data?.[0] + + const { mutate: removePolicy, isPending: isRemovingPolicy } = useMutation({ + mutationFn: async (policyName: string) => { + await executeSql({ + projectRef, + connectionString: project?.connectionString, + sql: generatePolicyRemovalSql(policyName), + }) + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId), + }), + queryClient.invalidateQueries({ + queryKey: databasePoliciesKeys.list(projectRef, 'storage'), + }), + ]) + setShowModal(false) + toast.success('Policy removed successfully') + }, + onError: (error) => { + console.error('Failed to remove policy', error) + toast.error(`Failed to remove policy: ${error.message}`) + }, + }) + + const [showModal, setShowModal] = useState(false) + + return policyToRemove ? ( + removePolicy(policyToRemove.policyname)} + isModalVisible={showModal} + onShowModal={() => setShowModal(true)} + onHideModal={() => setShowModal(false)} + /> + ) : ( + + ) +} + +type PublicBucketWarningViewProps_NoPolicyToRemove = { + _tag: 'no-policy-to-remove' +} + +type PublicBucketWarningViewProps_PolicyToRemove = { + _tag: 'policy-to-remove' + policyName: string + isRemovingPolicy: boolean + onRemovePolicy: () => void + isModalVisible: boolean + onShowModal: () => void + onHideModal: () => void +} + +type PublicBucketWarningViewProps = + | PublicBucketWarningViewProps_NoPolicyToRemove + | PublicBucketWarningViewProps_PolicyToRemove + +function PublicBucketWarningView(props: PublicBucketWarningViewProps): ReactNode { + if (props._tag === 'no-policy-to-remove') { + return null + } + + const { policyName, isRemovingPolicy, onRemovePolicy, isModalVisible, onShowModal, onHideModal } = + props + + return ( + <> + + Remove policy + + } + /> + +
+

+ This will drop the SELECT policy that makes the bucket's contents + listable. Object URLs will continue to work. +

+
+ +
+
+
+ + ) +} diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 02c7fdf95db..1747d8d97c7 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -32,6 +32,8 @@ export const storageKeys = { vectorBucketsIndexes: (projectRef: string | undefined, vectorBucketName: string | undefined) => ['projects', projectRef, 'vector-buckets', vectorBucketName, 'indexes'] as const, archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const, + publicBucketsWithSelectPolicies: (projectRef: string | undefined, bucketId: string | undefined) => + ['projects', projectRef, 'public-buckets-with-select-policies', bucketId] as const, icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) => [projectRef, 'warehouse', warehouse, 'namespaces'] as const, icebergNamespace: ({ diff --git a/apps/studio/data/storage/public-buckets-with-select-policies-query.ts b/apps/studio/data/storage/public-buckets-with-select-policies-query.ts new file mode 100644 index 00000000000..2fd5a74dfe6 --- /dev/null +++ b/apps/studio/data/storage/public-buckets-with-select-policies-query.ts @@ -0,0 +1,85 @@ +import { literal } from '@supabase/pg-meta/src/pg-format' +import { useQuery } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import type { ResponseError, UseCustomQueryOptions } from 'types' + +import { storageKeys } from './keys' + +export type PublicBucketsWithSelectPoliciesVariables = { + projectRef?: string + connectionString?: string | null + bucketId: string +} + +export type PublicBucketSelectPolicy = { + bucket_id: string + bucket_name: string + policyname: string +} + +/** + * For the given public bucket, checks whether any SELECT policy on storage.objects + * references this bucket's ID in its qual expression. This combination means anyone + * can enumerate all objects in the bucket, which is usually unintentional — public + * buckets don't require SELECT policies for object access by URL. + * + * Scoped to a single bucket so the query is a point-lookup rather than a full scan. + */ +async function getPublicBucketsWithSelectPolicies({ + projectRef, + connectionString, + bucketId, +}: PublicBucketsWithSelectPoliciesVariables) { + const { result } = await executeSql({ + projectRef, + connectionString, + sql: ` + SELECT b.id AS bucket_id, b.name AS bucket_name, p.policyname + FROM storage.buckets b + JOIN pg_policies p + ON p.schemaname = 'storage' + AND p.tablename = 'objects' + AND p.cmd = 'SELECT' + WHERE b.public = true + AND b.id = ${literal(bucketId)} + AND p.qual ~* ('bucket_id\\s*=\\s*' || quote_literal(b.id)) + `, + }) + + return result +} + +export type PublicBucketsWithSelectPoliciesData = Awaited< + ReturnType +> +export type PublicBucketsWithSelectPoliciesError = ResponseError + +export const usePublicBucketsWithSelectPoliciesQuery = < + TData = PublicBucketsWithSelectPoliciesData, +>( + { projectRef, connectionString, bucketId }: PublicBucketsWithSelectPoliciesVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + PublicBucketsWithSelectPoliciesData, + PublicBucketsWithSelectPoliciesError, + TData + > = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery( + { + queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId), + queryFn: () => getPublicBucketsWithSelectPolicies({ projectRef, connectionString, bucketId }), + enabled: enabled && typeof projectRef !== 'undefined' && isActive, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + ...options, + } + ) +} diff --git a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx index 1042f7c71c3..273f566a2e4 100644 --- a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx @@ -1,15 +1,4 @@ import { useParams } from 'common' -import { DeleteBucketModal } from 'components/interfaces/Storage/DeleteBucketModal' -import { EditBucketModal } from 'components/interfaces/Storage/EditBucketModal' -import { EmptyBucketModal } from 'components/interfaces/Storage/EmptyBucketModal' -import { useSelectedBucket } from 'components/interfaces/Storage/FilesBuckets/useSelectedBucket' -import { PUBLIC_BUCKET_TOOLTIP } from 'components/interfaces/Storage/Storage.constants' -import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError' -import { StorageExplorer } from 'components/interfaces/Storage/StorageExplorer/StorageExplorer' -import { useBucketPolicyCount } from 'components/interfaces/Storage/useBucketPolicyCount' -import DefaultLayout from 'components/layouts/DefaultLayout' -import { PageLayout } from 'components/layouts/PageLayout/PageLayout' -import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' import { ChevronDown, FolderOpen, Settings, Shield, Trash2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' @@ -30,6 +19,18 @@ import { TooltipTrigger, } from 'ui' +import { DeleteBucketModal } from '@/components/interfaces/Storage/DeleteBucketModal' +import { EditBucketModal } from '@/components/interfaces/Storage/EditBucketModal' +import { EmptyBucketModal } from '@/components/interfaces/Storage/EmptyBucketModal' +import { useSelectedBucket } from '@/components/interfaces/Storage/FilesBuckets/useSelectedBucket' +import { PublicBucketWarning } from '@/components/interfaces/Storage/PublicBucketWarning' +import { PUBLIC_BUCKET_TOOLTIP } from '@/components/interfaces/Storage/Storage.constants' +import StorageBucketsError from '@/components/interfaces/Storage/StorageBucketsError' +import { StorageExplorer } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer' +import { useBucketPolicyCount } from '@/components/interfaces/Storage/useBucketPolicyCount' +import DefaultLayout from '@/components/layouts/DefaultLayout' +import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' +import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import { StorageExplorerStateContextProvider } from '@/state/storage-explorer' const BucketPage: NextPageWithLayout = () => { @@ -150,8 +151,11 @@ const BucketPage: NextPageWithLayout = () => { } > -
- +
+ {ref && bucketId && } +
+ +