Files
supabase/apps/studio/components/interfaces/Storage/Storage.utils.ts
Joshen Lim 1de298ff31 Reinstate https://github.com/supabase/supabase/pull/45143 into latest master (#47433)
## Context

Previous PR was [here](https://github.com/supabase/supabase/pull/45143)
but it got stale with lots of conflicts so figured it'll be easier redo
it off the latest master

Moves policies page from Auth to Database under an Access Control
section along with Roles. This moves all existing files, applies
redirects, and updates urls to point to the new route

<img width="274" height="412" alt="image"
src="https://github.com/user-attachments/assets/7952c185-64ae-4355-ba36-45397efe1787"
/>

<img width="453" height="471" alt="image"
src="https://github.com/user-attachments/assets/04b3dcb3-48a5-4049-9893-d01109fb46a9"
/>


## To test
- [ ] Verify that policies now live under Database correctly

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a quick navigation shortcut to open **Database > Policies
(RLS)**.
* **Bug Fixes**
* Updated Policies and RLS-related links across the product to open the
**Database policies** area (menus, command palette, context actions,
alerts, and link-outs).
* Added a permanent redirect from the old **auth policies** URL to the
new **database policies** URL.
* **Documentation**
* Updated RLS Dashboard and security checklist instructions to reference
**Database > Policies**.
* **Tests**
  * Adjusted automated tests to validate the new Policies route.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-30 18:49:33 +08:00

265 lines
8.1 KiB
TypeScript

import { difference } from 'lodash'
import { useRouter } from 'next/router'
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants'
import type { StoragePolicyFormField } from './Storage.types'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import { WrapperMeta } from '@/components/interfaces/Integrations/Wrappers/Wrappers.types'
import { convertKVStringArrayToJson } from '@/components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { FDW } from '@/data/fdw/fdws-query'
import { Bucket } from '@/data/storage/buckets-query'
import { getDecryptedValues } from '@/data/vault/vault-secret-decrypted-value-query'
import { createWrappedSymbol } from '@/lib/helpers'
const shortHash = (str: string) => {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash &= hash // Convert to 32bit integer
}
return new Uint32Array([hash])[0].toString(36)
}
export type PoliciesByBucket = { name: string | Symbol; policies: Policy[] }[]
/**
* Formats the policies from the objects table in the storage schema
* to be consumable for the storage policies dashboard.
*
* @param policies All policies from a table in a schema
*/
export const formatPoliciesForStorage = (
buckets: Bucket[],
policies: Policy[]
): PoliciesByBucket => {
if (policies.length === 0) return []
/**
* Format policies from storage objects to:
* - Include bucket name
* - Strip away ${bucketName}_{idx} suffix
* - Strip away bucket_id from definitions
* Note, if the policy definition has no bucket_id, we skip the formatting
*/
const formattedPolicies = formatStoragePolicies(buckets, policies)
const policiesByBucket = groupPoliciesByBucket(formattedPolicies)
return policiesByBucket
}
/**
* Policy that belongs to a bucket which is not loaded yet (might not have been
* paginated to yet, or might have been deleted)
*/
export const UNKNOWN_BUCKET_SYMBOL = createWrappedSymbol('unknown-bucket', 'Unknown')
/**
* Policy that is not associated with a specific bucket
*/
export const UNGROUPED_POLICY_SYMBOL = createWrappedSymbol('ungrouped-policy', 'Ungrouped')
const formatStoragePolicies = (buckets: Bucket[], policies: Policy[]) => {
const availableBuckets = buckets.map((bucket) => bucket.name)
const formattedPolicies = policies.map((policy) => {
const { definition: policyDefinition, check: policyCheck } = policy
const bucketName =
policyDefinition !== null
? extractBucketNameFromDefinition(policyDefinition)
: extractBucketNameFromDefinition(policyCheck)
if (bucketName) {
const isBucketLoaded = availableBuckets.includes(bucketName)
return {
...policy,
bucket: isBucketLoaded ? bucketName : UNKNOWN_BUCKET_SYMBOL,
}
}
return { ...policy, bucket: UNGROUPED_POLICY_SYMBOL }
})
return formattedPolicies
}
export const extractBucketNameFromDefinition = (definition: string | null) => {
if (!definition) return null
const definitionSegments = definition?.split(' AND ') ?? []
const [bucketDefinition] = definitionSegments.filter((segment: string) =>
segment.includes('bucket_id')
)
return bucketDefinition ? bucketDefinition.split("'")[1] : null
}
const groupPoliciesByBucket = (policies: (Policy & { bucket: string | Symbol })[]) => {
const policiesByBucket = new Map<string | Symbol, Policy[]>()
policies.forEach((policy) => {
if (!policiesByBucket.has(policy.bucket)) {
policiesByBucket.set(policy.bucket, [])
}
policiesByBucket.get(policy.bucket)?.push(policy)
})
return Array.from(policiesByBucket).map(([bucketName, policies]) => ({
name: bucketName,
policies,
}))
}
export const createPayloadsForAddPolicy = (
bucketName = '',
policyFormFields: StoragePolicyFormField,
addSuffixToPolicyName = true
) => {
const { name: policyName, definition, allowedOperations, roles } = policyFormFields
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : ''
return allowedOperations.map((operation: any, idx: number) => {
return createPayloadForNewPolicy(
idx,
bucketName,
policyName,
formattedDefinition,
operation,
roles,
addSuffixToPolicyName
)
})
}
const createPayloadForNewPolicy = (
idx: number,
bucketName: string,
policyName: string,
definition: string,
operation: string,
roles: string[],
addSuffixToPolicyName: boolean
) => {
const hashedBucketName = shortHash(bucketName)
return {
name: addSuffixToPolicyName ? `${policyName} ${hashedBucketName}_${idx}` : policyName,
definition: operation === 'INSERT' ? undefined : `(${definition})`,
action: 'PERMISSIVE',
check: operation === 'INSERT' ? `(${definition})` : undefined,
command: operation,
schema: 'storage',
table: 'objects',
roles: roles.length > 0 ? roles : undefined,
}
}
// Used in the policy editor to highlight which library methods are allowed depending on which operations are allowed
export const deriveAllowedClientLibraryMethods = (allowedOperations = []) => {
return Object.keys(STORAGE_CLIENT_LIBRARY_MAPPINGS).filter((method) => {
const requiredOperations = (STORAGE_CLIENT_LIBRARY_MAPPINGS as any)[method]
if (difference(requiredOperations, allowedOperations).length === 0) {
return method
}
})
}
// Create policy SQL statements on save based on configuration.
// Used purely for previewing in the review step, not actually fired
const createSQLStatementForCreatePolicy = (
idx: number,
bucketName: string,
policyName: string,
definition: string,
operation: string,
selectedRoles: string[],
addSuffixToPolicyName: boolean
) => {
const hashedBucketName = shortHash(bucketName)
const formattedPolicyName = addSuffixToPolicyName
? `${policyName} ${hashedBucketName}_${idx}`
: policyName
const description = `Add policy for the ${operation} operation under the policy "${policyName}"`
const roles = selectedRoles.length === 0 ? ['public'] : selectedRoles
const statement = `
CREATE POLICY "${formattedPolicyName}"
ON storage.objects
FOR ${operation}
TO ${roles.join(', ')}
${operation === 'INSERT' ? 'WITH CHECK' : 'USING'} (${definition});
`
.replace(/\s+/g, ' ')
.trim()
return { description, statement }
}
export const createSQLPolicies = (
bucketName: string,
policyFormFields: StoragePolicyFormField,
addSuffixToPolicyName = true
) => {
const { name: policyName, definition, allowedOperations, roles } = policyFormFields
const policies = allowedOperations.map((operation: any, idx: number) =>
createSQLStatementForCreatePolicy(
idx,
bucketName,
policyName,
definition || '',
operation,
roles,
addSuffixToPolicyName
)
)
return policies
}
export const applyBucketIdToTemplateDefinition = (definition: string, bucketId: any) => {
return definition.replace('{bucket_id}', `'${bucketId}'`)
}
export const useStorageV2Page = () => {
const router = useRouter()
return router.pathname.split('/')[4] as undefined | 'files' | 'analytics' | 'vectors' | 's3'
}
export const getDecryptedParameters = async ({
ref,
connectionString,
wrapper,
wrapperMeta,
}: {
ref?: string
connectionString?: string
wrapper: FDW
wrapperMeta: WrapperMeta
}) => {
const wrapperServerOptions = wrapperMeta.server.options
const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? [])
const paramsToBeDecrypted = Object.fromEntries(
new Map(
Object.entries(serverOptions).filter(([key, _value]) => {
return wrapperServerOptions.find((option) => option.name === key)?.encrypted
})
)
)
const decryptedValues = await getDecryptedValues({
projectRef: ref,
connectionString: connectionString,
ids: Object.values(paramsToBeDecrypted),
})
const paramsWithDecryptedValues = Object.fromEntries(
new Map(
Object.entries(paramsToBeDecrypted).map(([name, id]) => {
const decryptedValue = decryptedValues[id]
return [name, decryptedValue]
})
)
)
return {
...serverOptions,
...paramsWithDecryptedValues,
}
}