Files
supabase/apps/studio/components/interfaces/Storage/Storage.utils.ts
Charis 0433eeb5f5 feat(studio): mark sql provenance for safety (#45336)
Mark provenance of SQL via the branded types SafeSqlFragment and
UntrustedSqlFragment. Only SafeSqlFragment should be executed;
UntrustedSqlFragments require some kind of implicit user approval (show
on screen + user has to click something) before they are promoted to
SafeSqlFragment.

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

* **New Features**
* Editor and RLS tester show loading states for inferred/generated SQL
and include a dedicated user SQL editor for safer edits.

* **Refactor**
* Platform-wide SQL handling tightened: snippets and AI-generated SQL
are treated as untrusted/display-only until promoted, improving safety
and consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-04 13:08:06 -04: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/Auth/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,
}
}