Clean up RLS Tester artifacts (#47866)

## Context

As per PR title - we're pausing the development of the RLS Tester
feature preview while we re-evaluate its direction. Have also updated
the GH discussion
[here](https://github.com/orgs/supabase/discussions/45233) RE this! 🙏

Removes the RLS Tester UI + Sandbox functionality

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

## Summary by CodeRabbit

* **Removed Features**
* Removed the RLS Tester feature preview, banner, and database policy
testing workflow.
* The related SQL testing, role selection, policy summaries, sandbox
management, and result views are no longer available.
* **Bug Fixes**
* Improved accessibility on the database policies page by adding a label
to the clear-filter button.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-07-13 17:01:05 +08:00
1 parent dbdbe1540b
commit 1d29b4c5b4
35 files changed
+4 -3316

No files matched your search

@@ -133,11 +133,6 @@ export const useIsSqlEditorManualSaveEnabled = () => {
return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
}
export const useIsRLSTesterEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]
}
export const useIsMarketplaceEnabled = () => {
const { flags } = useFeaturePreviewContext()
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
@@ -34,12 +34,9 @@ import { IntegrationsLayoutPreview } from './IntegrationsLayoutPreview'
import { JitDbAccessPreview } from './JitDbAccessPreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
import { RLSTesterPreview } from './RLSTesterPreview'
import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { IS_PLATFORM } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
@@ -52,7 +49,6 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: <UnifiedLogsPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: <JitDbAccessPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]: <RLSTesterPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: <SqlEditorManualSavePreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: <IntegrationsLayoutPreview />,
}
@@ -70,12 +66,6 @@ export const FeaturePreviewModal = () => {
const featurePreviewContext = useFeaturePreviewContext()
const track = useTrack()
const { dismissBanner } = useBannerStack()
const [, setIsDismissedRlsTesterBanner] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.RLS_TESTER_BANNER_DISMISSED(ref ?? ''),
false
)
const { flags, onUpdateFlag } = featurePreviewContext
const allFeaturePreviews = (
IS_PLATFORM ? featurePreviews : featurePreviews.filter((x) => !x.isPlatformOnly)
@@ -94,11 +84,6 @@ export const FeaturePreviewModal = () => {
const isEnabling = !isSelectedFeatureEnabled
if (selectedFeature.key === LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER) {
dismissBanner('rls-tester-banner')
setIsDismissedRlsTesterBanner(true)
}
onUpdateFlag(selectedFeature.key, isEnabling)
track(isEnabling ? 'feature_preview_enabled' : 'feature_preview_disabled', {
feature: selectedFeature.key,
@@ -1,38 +0,0 @@
import { useParams } from 'common'
import Image from 'next/image'
import { InlineLink } from '@/components/ui/InlineLink'
import { BASE_PATH } from '@/lib/constants'
export const RLSTesterPreview = () => {
const { ref } = useParams()
return (
<div className="flex flex-col gap-2">
<p className="text-foreground-light text-sm mb-4">
Verify if your RLS policies have been set up properly by running queries as a specific user.
While role impersonation isn't a new feature on the dashboard, we've built a dedicated UI
for this which will also show what policies are evaluated for the query.
</p>
<Image
src={`${BASE_PATH}/img/previews/rls-tester-preview.png`}
width={1296}
height={900}
quality={100}
alt="rls-tester-preview"
className="rounded-sm border"
/>
<div className="space-y-2 mt-4!">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
Show the "Test" button on the{' '}
<InlineLink href={`/project/${ref}/database/policies`}>
Database Policies page
</InlineLink>
</li>
</ul>
</div>
</div>
)
}
@@ -30,16 +30,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
return useMemo(
() =>
[
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER,
name: 'RLS Tester',
discussionsUrl: 'https://github.com/orgs/supabase/discussions/45233',
enabled: true,
isNew: true,
isPlatformOnly: false,
isDefaultOptIn: false,
getRoute: (ref?: string) => `/project/${ref}/database/policies`,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
name: 'Updated Logs interface',
@@ -1,44 +0,0 @@
import { UntrustedSqlFragment } from '@supabase/pg-meta'
import { Loader2 } from 'lucide-react'
import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
export const InferredSQLViewer = ({
sql,
isLoading = false,
}: {
sql: UntrustedSqlFragment | undefined
isLoading?: boolean
}) => {
return (
<>
<div className="flex items-center justify-between px-4 py-2">
<div className="flex items-center gap-x-2">
<p className="text-sm">Inferred SQL:</p>
{isLoading && <Loader2 size={14} className="animate-spin text-foreground-lighter" />}
</div>
<div className="flex items-center gap-x-2">
<Tooltip>
<TooltipTrigger>
<Badge variant="warning">Generated</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" className="w-64 text-center">
This query is inferred from client library code with the help of the Assistant and may
not guarantee correctness.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="h-44 relative">
{isLoading && !sql ? (
<div className="flex h-full items-center justify-center bg-surface-100 text-foreground-lighter">
<Loader2 size={20} className="animate-spin" />
</div>
) : (
<CodeEditor isReadOnly id="inferred-sql" language="pgsql" value={sql ?? ''} />
)}
</div>
</>
)
}
@@ -1,207 +0,0 @@
import { Check, ChevronDown, Edit, X } from 'lucide-react'
import { useMemo } from 'react'
import { cn, Collapsible, CollapsibleContent, CollapsibleTrigger, WarningIcon } from 'ui'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { type ParseSQLQueryOperations } from '@/data/misc/parse-query-mutation'
interface RLSTableCardProps {
table: { schema: string; name: string; isRLSEnabled: boolean }
operation: ParseSQLQueryOperations
role?: string
policies: Policy[]
hasError: boolean
handleSelectEditPolicy: (policy: Policy) => void
}
export const RLSTableCard = ({
table,
operation,
role,
policies,
hasError,
handleSelectEditPolicy,
}: RLSTableCardProps) => {
const { schema, name, isRLSEnabled } = table
const trueOnlyPolicy = policies.find((x) => x.definition === 'true')
const falseOnlyPolicy = policies.find((x) => x.definition === 'false')
const noPolicies = isRLSEnabled && policies.length === 0
const tableAccessDescription = useMemo(() => {
if (!isRLSEnabled) {
return (
<p>
RLS is disabled and all data is publicly accessible. We highly recommend enabling RLS and
adding policies to restrict access.
</p>
)
}
if (noPolicies) {
return (
<p>
RLS is enabled but no policies exist for the{' '}
<code className="text-code-inline">{role}</code> role on this table -{' '}
{operation === 'SELECT'
? 'no data will be returned'
: `no data will be ${operation?.toLowerCase()}${operation?.toLowerCase().endsWith('e') ? 'd' : 'ed'}`}
.
</p>
)
}
if (trueOnlyPolicy) {
return (
<>
<p>
The policy "{trueOnlyPolicy.name}" for the{' '}
<code className="text-code-inline">{role}</code> role on this table evaluates to{' '}
<code className="text-code-inline">true</code>, so all data from this query is
accessible to this user.
</p>
<TableAccessPolicySummary
policies={policies}
operation={operation}
handleSelectEditPolicy={handleSelectEditPolicy}
/>
</>
)
}
if (falseOnlyPolicy) {
return (
<>
<p>
The policy "{falseOnlyPolicy.name}" for the{' '}
<code className="text-code-inline">{role}</code> role on this table evaluates to{' '}
<code className="text-code-inline">false</code>, so no data from this query is
accessible to this user.
</p>
<TableAccessPolicySummary
policies={policies}
operation={operation}
handleSelectEditPolicy={handleSelectEditPolicy}
/>
</>
)
}
return (
<>
<p>
{policies.length} {policies.length > 1 ? 'policies apply' : 'policy applies'} for the{' '}
<code className="text-code-inline">{role}</code> role on this table.{' '}
{operation === 'SELECT'
? `Only rows that match ${policies.length > 1 ? 'these conditions' : 'this condition'} are returned.`
: `The ${operation} operation will only be successful if the conditions are matched.`}
</p>
<TableAccessPolicySummary
policies={policies}
operation={operation}
handleSelectEditPolicy={handleSelectEditPolicy}
/>
</>
)
}, [
isRLSEnabled,
noPolicies,
trueOnlyPolicy,
falseOnlyPolicy,
policies,
role,
operation,
handleSelectEditPolicy,
])
return (
<Collapsible
className={cn('border rounded-sm', !isRLSEnabled && 'bg-warning-300 border-warning-500')}
>
<CollapsibleTrigger className="flex items-center justify-between px-3 py-2 w-full [&[data-state=open]>div>svg]:-rotate-180!">
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-x-2">
{!isRLSEnabled ? (
<WarningIcon />
) : (hasError && operation === 'INSERT') || noPolicies || falseOnlyPolicy ? (
<X size={16} className="text-destructive" />
) : (
<Check size={16} className="text-brand" />
)}
<p className={cn('text-xs font-mono', !isRLSEnabled && 'font-medium text-foreground')}>
{schema}.{name}
</p>
</div>
</div>
<div className="flex items-center gap-x-2">
{operation === 'SELECT' && (
<p
className={cn(
'text-xs text-foreground-light w-max',
!isRLSEnabled && 'text-foreground'
)}
>
{noPolicies || falseOnlyPolicy
? 'Returns no rows'
: !isRLSEnabled || !!trueOnlyPolicy
? 'Returns all rows'
: null}
</p>
)}
<ChevronDown className="transition-transform duration-200" strokeWidth={1.5} size={14} />
</div>
</CollapsibleTrigger>
<CollapsibleContent
className={cn(
'border-t p-3 text-sm text-foreground-light',
!isRLSEnabled && 'border-warning-500'
)}
>
{tableAccessDescription}
</CollapsibleContent>
</Collapsible>
)
}
const TableAccessPolicySummary = ({
policies,
operation,
handleSelectEditPolicy,
}: {
policies: Policy[]
operation: ParseSQLQueryOperations
handleSelectEditPolicy: (policy: Policy) => void
}) => {
return (
<div className="border rounded-sm mt-4">
<p className="text-xs font-mono text-foreground-light uppercase border-b px-3 py-2">
{policies.length} {policies.length > 1 ? 'policies' : 'policy'} applied
</p>
<ul>
{policies.map((policy) => (
<li key={policy.id} className="px-3 py-2 flex justify-between items-center">
<div>
<p>{policy.name}</p>
<p className="text-foreground-lighter">
{operation === 'SELECT' ? 'Show rows' : `Allow ${operation?.toLocaleLowerCase()}s`}{' '}
where:{' '}
<code className="text-code-inline text-foreground">
{policy.definition ?? policy.check}
</code>
</p>
</div>
<ButtonTooltip
variant="text"
icon={<Edit />}
className="w-7"
tooltip={{ content: { side: 'bottom', text: 'Edit policy' } }}
onClick={() => {
handleSelectEditPolicy(policy)
}}
/>
</li>
))}
</ul>
</div>
)
}
@@ -1,15 +0,0 @@
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import { type User } from '@/data/auth/users-infinite-query'
import { type ParseSQLQueryResponse } from '@/data/misc/parse-query-mutation'
export type ParseQueryResults = {
tables: {
schema: string
table: string
tablePolicies: Array<Policy>
isRLSEnabled: boolean
}[]
operation: ParseSQLQueryResponse['operation']
role?: string
user?: User
}
@@ -1,13 +0,0 @@
import { ListTodo } from 'lucide-react'
export const RLSTesterEmptyState = () => {
return (
<div className="flex flex-col items-center justify-center h-64">
<ListTodo className="mb-2 text-foreground-light" />
<p className="text-foreground-light text-sm">Test summary and results will be shown here</p>
<p className="text-foreground-lighter text-sm">
Verify that the results match what your RLS policies allow
</p>
</div>
)
}
@@ -1,197 +0,0 @@
import { Badge, cn, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { Results } from '../../SQLEditor/UtilityPanel/Results'
import { RLSTableCard } from './RLSTableCard'
import { ParseQueryResults } from './RLSTester.types'
import { deriveRLSTestState } from './RLSTesterResults.utils'
import { useTestQueryRLS } from './useTestQueryRLS'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import { type QueryResponseError } from '@/data/sql/execute-sql-mutation'
interface RLSTesterResultsProps {
results: Object[]
autoLimit: boolean
parseQueryResults: ParseQueryResults
executeSqlError: Error | QueryResponseError | null | undefined
handleSelectEditPolicy: (policy: Policy) => void
}
export const RLSTesterResults = ({
results,
autoLimit,
parseQueryResults,
executeSqlError,
handleSelectEditPolicy,
}: RLSTesterResultsProps) => {
const { limit } = useTestQueryRLS()
const {
isServiceRole,
tableWithRLSEnabledButNoPolicies,
tableWithRLSEnabledWithPolicyFalse,
tableWithRLSEnabledWithPoliciesDontApply,
noAccessToData,
} = deriveRLSTestState(parseQueryResults)
const { operation, role } = parseQueryResults
const rlsBlockInsert = executeSqlError && operation === 'INSERT'
const noAccess = noAccessToData || rlsBlockInsert
return (
<div className="p-5 pt-4">
<div className="flex items-center gap-x-2 mb-2">
<p className="text-sm">Summary</p>
{noAccess ? (
<Badge variant="destructive">No access</Badge>
) : (
<Badge variant="success">{results.length > 0 ? 'Can access' : 'Has access'}</Badge>
)}
</div>
<Tabs defaultValue="policies">
<TabsList className="gap-x-3">
<TabsTrigger value="policies" className="px-2">
Policies applied
</TabsTrigger>
<TabsTrigger value="data" className="px-2" disabled={operation !== 'SELECT'}>
Data preview
</TabsTrigger>
</TabsList>
{!!parseQueryResults && (
<div className="border rounded-sm flex items-center justify-between px-3 py-1.5 mt-3">
<div className="flex items-center gap-x-2">
<p className="text-xs text-foreground-light">Ran as</p>
{!parseQueryResults.role ? (
<code className="text-code-inline">postgres</code>
) : parseQueryResults.user ? (
<p className="text-sm truncate max-w-52">{parseQueryResults.user.email}</p>
) : parseQueryResults.role === 'anon' ? (
<p className="text-xs">an Anonymous user</p>
) : null}
</div>
{parseQueryResults.role === 'anon' && (
<p className="text-foreground-light text-xs">Not logged in user</p>
)}
{!!parseQueryResults.user && (
<code className="text-code-inline">ID: {parseQueryResults.user.id}</code>
)}
</div>
)}
<TabsContent value="policies" className="mt-0">
{!isServiceRole &&
(!!tableWithRLSEnabledButNoPolicies ? (
<Admonition showIcon={false} type="default" className="rounded-sm mt-2">
<p className="mb-0.5! text-foreground">
This user{' '}
{operation === 'SELECT'
? 'has no access to any rows'
: `is unable to ${operation?.toLowerCase()} any rows`}{' '}
from this query
</p>
<p className="text-foreground-light">
The table{' '}
<code className="text-code-inline">
{tableWithRLSEnabledButNoPolicies.schema}.
{tableWithRLSEnabledButNoPolicies.table}
</code>{' '}
has RLS enabled but no policies set up for the{' '}
<code className="text-code-inline break-keep!">{parseQueryResults.role}</code>{' '}
role.
</p>
</Admonition>
) : tableWithRLSEnabledWithPolicyFalse ? (
<Admonition showIcon={false} type="default" className="rounded-sm mt-2">
<p className="mb-0.5! text-foreground">
This user has no access to any rows from this query
</p>
<p className="text-foreground-light">
The table{' '}
<code className="text-code-inline">
{tableWithRLSEnabledWithPolicyFalse.schema}.
{tableWithRLSEnabledWithPolicyFalse.table}
</code>{' '}
has a policy that evaluates to
<code className="text-code-inline break-keep!">false</code> for the{' '}
<code className="text-code-inline break-keep!">{parseQueryResults.role}</code>{' '}
role.
</p>
</Admonition>
) : rlsBlockInsert &&
parseQueryResults.user &&
tableWithRLSEnabledWithPoliciesDontApply ? (
<Admonition showIcon={false} type="default" className="rounded-sm mt-2">
<p className="mb-0.5! text-foreground">
This user is unable to {operation?.toLowerCase()} any rows from this query
</p>
<p className="text-foreground-light">
The table{' '}
<code className="text-code-inline">
{tableWithRLSEnabledWithPoliciesDontApply.schema}.
{tableWithRLSEnabledWithPoliciesDontApply.table}
</code>{' '}
has a policy for the{' '}
<code className="text-code-inline break-keep!">{parseQueryResults.role}</code>{' '}
role, but its condition wasn't satisfied for this specific request.
</p>
</Admonition>
) : null)}
{isServiceRole && (
<Admonition showIcon={false} type="default" className="rounded-sm mt-2">
<p className="mb-0.5! text-foreground">
The <code className="text-code-inline">postgres</code> role has access to all rows
for this query
</p>
<p className="text-foreground-light">
The <code className="text-code-inline">postgres</code> role has admin privileges and
bypasses all RLS policies.
</p>
</Admonition>
)}
<div className="flex flex-col gap-y-2 mt-4">
<p className="text-sm">Table access</p>
{!isServiceRole && (
<div className="flex flex-col gap-y-2">
{parseQueryResults?.tables.map((x) => {
const { schema, table, tablePolicies, isRLSEnabled } = x
return (
<RLSTableCard
key={`${schema}.${table}`}
table={{ schema, name: table, isRLSEnabled }}
role={role}
operation={operation}
policies={tablePolicies}
hasError={!!executeSqlError}
handleSelectEditPolicy={handleSelectEditPolicy}
/>
)
})}
</div>
)}
</div>
</TabsContent>
<TabsContent value="data" className="mt-2">
<div
className={cn(
'grow flex flex-col border overflow-hidden',
results.length === 0 ? 'rounded-sm h-32' : 'rounded-t h-56'
)}
>
<Results rows={results} />
</div>
{results.length > 0 && (
<p className="border border-t-0 rounded-b font-mono text-xs text-foreground-light p-2">
{results.length} row{results.length > 1 ? 's' : ''}
{autoLimit && results.length >= limit && ` (Limited to only ${limit} rows)`}
</p>
)}
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,25 +0,0 @@
import type { ParseQueryResults } from './RLSTester.types'
export function deriveRLSTestState(parseQueryResults: ParseQueryResults | undefined) {
const isServiceRole = parseQueryResults?.role === undefined
const tableWithRLSEnabledButNoPolicies = parseQueryResults?.tables.find(
(x) => x.isRLSEnabled && x.tablePolicies.length === 0
)
const tableWithRLSEnabledWithPolicyFalse = parseQueryResults?.tables.find(
(x) => x.isRLSEnabled && x.tablePolicies.some((y) => y.definition === 'false')
)
const tableWithRLSEnabledWithPoliciesDontApply = parseQueryResults?.tables.find(
(x) => x.isRLSEnabled && x.tablePolicies.length !== 0
)
const noAccessToData =
!isServiceRole && (!!tableWithRLSEnabledButNoPolicies || !!tableWithRLSEnabledWithPolicyFalse)
return {
isServiceRole,
tableWithRLSEnabledButNoPolicies,
tableWithRLSEnabledWithPolicyFalse,
tableWithRLSEnabledWithPoliciesDontApply,
noAccessToData,
}
}
@@ -1,387 +0,0 @@
import {
acceptUntrustedSql,
safeSql,
type SafeSqlFragment,
type UntrustedSqlFragment,
} from '@supabase/pg-meta'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@ui/components/shadcn/ui/select'
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { Code, ExternalLink } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import {
Button,
DialogSectionSeparator,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { InferredSQLViewer } from './InferredSQLViewer'
import { type ParseQueryResults } from './RLSTester.types'
import { RLSTesterEmptyState } from './RLSTesterEmptyState'
import { RLSTesterResults } from './RLSTesterResults'
import { RoleSelector } from './RoleSelector'
import { SandboxManagement } from './SandboxManagement'
import { UserSelector } from './UserSelector'
import { UserSqlEditor } from './UserSqlEditor'
import { useTestQueryRLS, type TestQueryBlockedReason } from './useTestQueryRLS'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
import { useTrack } from '@/lib/telemetry/track'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { PostgresSandboxProvider, usePostgresSandbox } from '@/state/postgres-sandbox/sandbox'
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
interface RLSTesterSheetProps {
handleSelectEditPolicy: (policy: Policy) => void
}
export const RLSTesterSheet = (props: RLSTesterSheetProps) => {
return (
<PostgresSandboxProvider>
<RLSTesterSheetContents {...props} />
</PostgresSandboxProvider>
)
}
const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => {
const track = useTrack()
const aiSnap = useAiAssistantStateSnapshot()
const { openSidebar } = useSidebarManagerSnapshot()
const { setRole } = useRoleImpersonationStateSnapshot()
const { startSandbox, status, isSyncing } = usePostgresSandbox()
const sandboxEnabled = useFlag('rlsTesterSandbox')
const sandboxIsStarting = status === 'loading'
const [open, setOpen] = useState(false)
const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon')
const [blockedReason, setBlockedReason] = useState<TestQueryBlockedReason>()
const [format, setFormat] = useState<'sql' | 'lib'>('sql')
const [inferredSQL, setInferredSQL] = useState<UntrustedSqlFragment>()
const [value, setValue] = useState<SafeSqlFragment>(safeSql``)
const [results, setResults] = useState<Object[] | null>(null)
const [autoLimit, setAutoLimit] = useState(false)
const [parseQueryResults, setParseQueryResults] = useState<ParseQueryResults>()
const {
testQuery,
inferSQLFromLib,
isLoading,
isInferring,
executeSqlError,
parseQueryError,
parseClientCodeError,
} = useTestQueryRLS()
const isErrorDueToRLS =
executeSqlError?.message.includes('violates row-level security policy') ?? false
const mutationOperation = blockedReason?.type === 'mutation' ? blockedReason.operation : undefined
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleValueChange = (sql: SafeSqlFragment) => {
setValue(sql)
if (format !== 'lib') return
if (debounceRef.current !== null) clearTimeout(debounceRef.current)
if (!sql) return
debounceRef.current = setTimeout(() => inferSQLFromLib(sql, setInferredSQL), 1500)
}
const executionCallbacks = {
option: selectedOption,
acknowledgeMutation: blockedReason?.type === 'mutation',
onExecuteSQL: ({ result, isAutoLimit }: { result: Object[] | null; isAutoLimit: boolean }) => {
setResults(result)
setAutoLimit(isAutoLimit)
},
onParseQuery: setParseQueryResults,
onValidationBlocked: setBlockedReason,
}
const onRunQuery = async () => {
setBlockedReason(undefined)
if (format === 'lib') {
if (!inferredSQL) return
const blocked = await testQuery({
value: acceptUntrustedSql(inferredSQL),
...executionCallbacks,
})
if (!blocked) track('rls_tester_run_query_clicked', { type: 'inferred' })
} else {
const blocked = await testQuery({ value, ...executionCallbacks })
if (!blocked) track('rls_tester_run_query_clicked', { type: 'raw' })
}
}
const assistantSql = format === 'lib' && inferredSQL ? acceptUntrustedSql(inferredSQL) : value
const getDebugPrompt = ({ includeSql = false }: { includeSql?: boolean } = {}) => {
const prompt = `Help me fix my RLS policy based on the attached SQL snippet that gave the following error: \n\n${executeSqlError?.message}\n\nEvaluate if the problem might be query first, before checking my RLS policies.`
return includeSql ? `${prompt}\n\nSQL Query:\n\`\`\`sql\n${assistantSql}\n\`\`\`` : prompt
}
const onDebugWithAssistant = () => {
const prompt = getDebugPrompt()
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({
name: 'Debug RLS policies',
sqlSnippets: [assistantSql],
initialInput: prompt,
})
setOpen(false)
}
useEffect(() => {
setRole({ type: 'postgrest', role: 'anon' })
return () => {
// Flip back to service role
setRole(undefined)
}
// [Joshen] Intentional - to only reset back to service role when navigating away
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="default" icon={<Code />}>
Test
</Button>
</SheetTrigger>
<SheetContent className="w-[600px]! flex flex-col gap-y-0">
<SheetHeader>
<SheetTitle className="flex items-center gap-x-4">
<span>What data can my users access?</span>
<FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER} />
</SheetTitle>
<SheetDescription>
See what data a user is allowed to read or modify based on your RLS policies
</SheetDescription>
</SheetHeader>
<div className="grow overflow-y-auto flex flex-col">
{sandboxEnabled && <SandboxManagement />}
<SheetSection className="px-0 py-0 border-t">
<div className="flex flex-col p-5 pt-4 gap-y-4">
<RoleSelector onSelectRole={setSelectedOption} />
{selectedOption === 'authenticated' && <UserSelector />}
</div>
<DialogSectionSeparator />
<div className="flex items-center justify-between px-5 py-2">
<p className="text-sm">Query</p>
<div className="flex items-center gap-x-2">
<Select
value={format}
onValueChange={(x) => {
const newFormat = x as 'sql' | 'lib'
setFormat(newFormat)
if (newFormat !== 'lib') {
setInferredSQL(undefined)
if (debounceRef.current !== null) clearTimeout(debounceRef.current)
}
}}
>
<SelectTrigger size="tiny">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Query format</SelectLabel>
<SelectItem value="sql">SQL</SelectItem>
<SelectItem value="lib">Client library</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="h-40 relative">
<UserSqlEditor
id="rls-tester"
value={value}
placeholder={
format === 'sql'
? safeSql`select * from table;`
: safeSql`SQL will be inferred from client library code`
}
onChange={handleValueChange}
actions={{
runQuery: {
enabled: open,
callback: () => {
if (!isInferring && !isLoading) onRunQuery()
},
},
}}
/>
</div>
</SheetSection>
{format === 'lib' && (
<div>
<DialogSectionSeparator />
<InferredSQLViewer sql={inferredSQL} isLoading={isInferring} />
</div>
)}
<DialogSectionSeparator />
{blockedReason?.type === 'multiple-statements' ? (
<div className="p-4">
<Admonition
type="warning"
title="Only a single SQL statement is supported"
description="Remove any additional statements and run the query again."
/>
</div>
) : blockedReason?.type === 'unsupported-operation' ? (
<div className="p-4">
<Admonition
type="warning"
title={`${blockedReason.operation} queries are not supported by the RLS Tester yet`}
description="Support for testing UPDATE and DELETE statements will be available soon."
/>
</div>
) : parseQueryError ? (
<div className="p-4">
<Admonition
type="warning"
title="Error parsing query"
description={parseQueryError.message}
/>
</div>
) : parseClientCodeError ? (
<div className="p-4">
<Admonition
type="warning"
title="Error parsing client code"
description={parseClientCodeError.message}
/>
</div>
) : executeSqlError && !isErrorDueToRLS ? (
<div className="p-4">
<Admonition
type="warning"
title="Error running SQL query"
description={executeSqlError.message}
actions={[
<AiAssistantDropdown
key="ai-assistant"
label="Ask Assistant"
telemetrySource="rls_tester"
buildPrompt={() => getDebugPrompt({ includeSql: true })}
onOpenAssistant={onDebugWithAssistant}
/>,
]}
/>
</div>
) : isLoading ? (
<div className="p-4">
<GenericSkeletonLoader />
</div>
) : results === null && !isErrorDueToRLS ? (
<RLSTesterEmptyState />
) : !!parseQueryResults ? (
<RLSTesterResults
results={results ?? []}
parseQueryResults={parseQueryResults}
autoLimit={autoLimit}
executeSqlError={executeSqlError}
handleSelectEditPolicy={handleSelectEditPolicy}
/>
) : null}
</div>
<SheetFooter className="sm:justify-between">
<Button asChild variant="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/orgs/supabase/discussions/45233"
>
Give feedback
</a>
</Button>
<div className="flex items-center gap-x-2">
<Button variant="default" disabled={isLoading} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="primary"
loading={isInferring || isLoading}
disabled={(format === 'lib' && !inferredSQL) || sandboxIsStarting || isSyncing}
onClick={onRunQuery}
>
Run query
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
<ConfirmationModal
visible={!!mutationOperation}
variant="warning"
size="medium"
loading={isLoading}
title="Confirm to run this query"
confirmLabel="Run query"
onConfirm={onRunQuery}
onCancel={() => setBlockedReason(undefined)}
alert={{
title: `This ${mutationOperation} query will run against your actual database`,
description: 'Your database may be directly modified as a result. Are you sure?',
}}
>
{sandboxEnabled && (
<>
<p className="text-sm">
We highly recommend using the sandbox to set up an ephemeral database environment for
testing insert, update, or delete queries.
</p>
<Button
variant="default"
className="mt-2"
onClick={() => {
startSandbox()
setBlockedReason(undefined)
}}
>
Set up sandbox
</Button>
</>
)}
</ConfirmationModal>
</>
)
}
@@ -1,38 +0,0 @@
import { RadioGroupStacked, RadioGroupStackedItem } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
interface RoleSelectorProps {
onSelectRole: (value: 'anon' | 'authenticated') => void
}
export const RoleSelector = ({ onSelectRole }: RoleSelectorProps) => {
const { role, setRole } = useRoleImpersonationStateSnapshot()
return (
<FormItemLayout isReactForm={false} label="Test query as" layout="horizontal">
<RadioGroupStacked defaultValue={role?.role ?? 'anon'}>
<RadioGroupStackedItem
value="anon"
id="anon"
label="Anonymous user"
description="Not logged in"
onClick={() => {
onSelectRole('anon')
setRole({ type: 'postgrest', role: 'anon' })
}}
/>
<RadioGroupStackedItem
value="authenticated"
id="authenticated"
label="Authenticated user"
description="A logged in user"
onClick={() => {
onSelectRole('authenticated')
}}
/>
</RadioGroupStacked>
</FormItemLayout>
)
}
@@ -1,105 +0,0 @@
import { Box, Loader2, LogOut, RefreshCw } from 'lucide-react'
import { Badge, Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox'
export const SandboxManagement = () => {
const { status, error, isSyncing, startSandbox, destroySandbox, syncSandbox } =
usePostgresSandbox()
if (status === 'idle') {
return (
<Admonition
type="default"
layout="horizontal"
className="min-h-min border-none [&>div>div>div>div>p]:!mb-0 [&>div>div]:gap-x-2"
actions={[
<Button key="sandbox" variant="default" onClick={() => startSandbox()}>
Set up sandbox
</Button>,
]}
>
<div className="flex items-center gap-x-2">
<p className="text-foreground !m-0">Run queries in a sandbox</p>
<Badge variant="success">Recommended</Badge>
</div>
<p className="text-foreground-light !m-0">
Ensure that queries do not affect your actual database
</p>
</Admonition>
)
}
if (status === 'loading') {
return (
<Admonition
showIcon={false}
type="default"
className="min-h-min border-none py-2 [&>div>div]:flex [&>div>div]:items-center [&>div>div]:justify-between"
>
<div className="flex items-center gap-x-3">
<div className="bg w-6 h-6 rounded border border-border flex items-center justify-center">
<Loader2 size={14} className="animate-spin" />
</div>
<p className="text-xs !mb-0 font-mono uppercase tracking-tight">Setting up sandbox</p>
</div>
</Admonition>
)
}
if (status === 'error') {
return (
<Admonition
type="warning"
layout="horizontal"
title="Unable to set up sandbox"
description={error ?? 'Please try again'}
className="min-h-min border-none"
actions={[
<Button key="set-up" variant="default" onClick={() => startSandbox()}>
Retry set up
</Button>,
]}
/>
)
}
return (
<Admonition
showIcon={false}
type="default"
layout="horizontal"
className="min-h-min border-none py-2 [&>div>div>div>div>p]:!mb-0 [&>div>div]:gap-x-2"
actions={[
<ButtonTooltip
key="destroy"
variant="default"
icon={<LogOut />}
className="w-7"
disabled={isSyncing}
tooltip={{ content: { side: 'bottom', text: 'Exit sandbox' } }}
onClick={() => destroySandbox()}
/>,
<ButtonTooltip
key="refresh"
variant="default"
icon={<RefreshCw />}
className="w-7"
loading={isSyncing}
tooltip={{ content: { side: 'bottom', text: 'Refresh schema' } }}
onClick={() => syncSandbox()}
/>,
]}
>
<div className="flex items-center gap-x-3">
<div className="bg-brand-300 w-6 h-6 rounded border border-brand-500 flex items-center justify-center">
<Box size={14} className="text-brand" />
</div>
<p className="text-xs text-foreground font-mono uppercase tracking-tight">Sandbox active</p>
<p className="text-xs text-foreground-lighter ">Your database is never modified</p>
</div>
</Admonition>
)
}
@@ -1,166 +0,0 @@
import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce } from '@uidotdev/usehooks'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
cn,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
copyToClipboard,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
import { ResponseError } from '@/types'
export const UserSelector = () => {
const { data: project } = useSelectedProjectQuery()
const state = useRoleImpersonationStateSnapshot()
const [open, setOpen] = useState(false)
const [searchText, setSearchText] = useState('')
const debouncedSearchText = useDebounce(searchText, 300)
const { data, error, isSuccess, isPending, isError } = useUsersInfiniteQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
keywords: debouncedSearchText.trim().toLocaleLowerCase(),
},
{ placeholderData: keepPreviousData }
)
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
const impersonatingUser =
state.role?.type === 'postgrest' &&
state.role.role === 'authenticated' &&
state.role.userType === 'native'
? state.role.user
: undefined
const onSelectUser = async (user: User) => {
try {
await state.setRole({
type: 'postgrest',
role: 'authenticated',
userType: 'native',
user,
aal: 'aal1',
})
} catch (error) {
toast.error(`Failed to impersonate user: ${(error as ResponseError).message}`)
}
}
return (
<FormItemLayout
isReactForm={false}
layout="horizontal"
label="Select which user to test as"
description={
impersonatingUser ? (
<p>
ID:{' '}
<code
className="text-code-inline cursor-pointer"
onClick={() => {
copyToClipboard(impersonatingUser?.id ?? '')
toast('Copied ID to clipboard')
}}
>
{impersonatingUser.id}
</code>
</p>
) : undefined
}
>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
block
variant="default"
role="combobox"
size="small"
aria-expanded={open}
className={cn('justify-between', !impersonatingUser && 'text-foreground-lighter')}
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
>
{impersonatingUser?.email ?? 'Select a user'}
</Button>
</PopoverTrigger>
<PopoverContent sameWidthAsTrigger className="p-0" side="bottom" align="start">
<Command shouldFilter={false}>
<CommandInput
showResetIcon
placeholder="Search for a user"
className="text-xs"
value={searchText}
onValueChange={setSearchText}
/>
{isError ? (
<Admonition showIcon={false} type="warning" className="border-0 rounded-none text-xs">
Failed to fetch users: {error.message}
</Admonition>
) : (
<CommandEmpty>No user found</CommandEmpty>
)}
<CommandList>
{isPending && (
<div className="p-2">
<GenericSkeletonLoader />
</div>
)}
{isSuccess && (
<CommandGroup>
<ScrollArea className={users.length > 7 ? 'h-full md:h-[210px]' : ''}>
{users.map((user) => {
return (
<CommandItem
key={user.id}
value={user.email}
className="cursor-pointer w-full"
onSelect={() => {
onSelectUser(user)
setOpen(false)
}}
>
<div className="w-full flex items-center justify-between">
<p className="space-x-3">
<span className="text-foreground-light">{user.email}</span>
<code className="text-code-inline text-foreground-lighter!">
{user.id?.slice(0, 8)}
</code>
</p>
{impersonatingUser?.id === user.id && <Check size={16} />}
</div>
</CommandItem>
)
})}
</ScrollArea>
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</FormItemLayout>
)
}
@@ -1,28 +0,0 @@
import { rawSql, type SafeSqlFragment } from '@supabase/pg-meta'
import type { ComponentProps } from 'react'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
interface UserSqlEditorProps {
id: string
value: SafeSqlFragment
placeholder?: SafeSqlFragment
actions?: ComponentProps<typeof CodeEditor>['actions']
onChange: (sql: SafeSqlFragment) => void
}
/**
* Wraps CodeEditor for user-authored SQL. The rawSql boundary lives here — any
* text the user types is immediately promoted to SafeSqlFragment so callers
* never handle plain strings.
*/
export const UserSqlEditor = ({ value, onChange, ...props }: UserSqlEditorProps) => {
return (
<CodeEditor
language="pgsql"
value={value}
onInputChange={(val) => onChange(rawSql(val ?? ''))}
{...props}
/>
)
}
@@ -1,200 +0,0 @@
import type { SafeSqlFragment } from '@supabase/pg-meta'
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import type { ParseQueryResults } from '@/components/interfaces/Database/RLSTester/RLSTester.types'
import { RLSTesterResults } from '@/components/interfaces/Database/RLSTester/RLSTesterResults'
import { render } from '@/tests/helpers'
vi.mock('@/components/interfaces/Database/RLSTester/useTestQueryRLS', () => ({
useTestQueryRLS: () => ({ limit: 100 }),
}))
vi.mock('@/components/interfaces/Database/RLSTester/RLSTableCard', () => ({
RLSTableCard: () => <div data-testid="rls-table-card" />,
}))
vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/Results', () => ({
Results: () => <div data-testid="results" />,
}))
const sql = (s: string) => s as unknown as SafeSqlFragment
const makePolicy = (definition: string | null = null): Policy =>
({ definition: definition !== null ? sql(definition) : null }) as Policy
const makeTable = (
overrides?: Partial<ParseQueryResults['tables'][number]>
): ParseQueryResults['tables'][number] => ({
schema: 'public',
table: 'items',
isRLSEnabled: true,
tablePolicies: [],
...overrides,
})
const defaultProps = {
results: [],
autoLimit: false,
executeSqlError: undefined,
handleSelectEditPolicy: vi.fn(),
}
describe('RLSTesterResults', () => {
describe('access badge', () => {
it('shows "No access" badge when table has RLS enabled but no policies', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [makeTable()],
operation: 'SELECT',
role: 'anon',
}}
/>
)
expect(screen.getByText('No access')).toBeInTheDocument()
})
it('shows "No access" badge when a policy definition is false', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [makeTable({ tablePolicies: [makePolicy('false')] })],
operation: 'SELECT',
role: 'anon',
}}
/>
)
expect(screen.getByText('No access')).toBeInTheDocument()
})
it('shows "Has access" badge when results are empty and user has access', () => {
render(
<RLSTesterResults
{...defaultProps}
results={[]}
parseQueryResults={{
tables: [makeTable({ tablePolicies: [makePolicy('auth.uid() = user_id')] })],
operation: 'SELECT',
role: 'authenticated',
}}
/>
)
expect(screen.getByText('Has access')).toBeInTheDocument()
})
it('shows "Can access" badge when results are returned', () => {
render(
<RLSTesterResults
{...defaultProps}
results={[{ id: 1 }]}
parseQueryResults={{
tables: [makeTable({ tablePolicies: [makePolicy('auth.uid() = user_id')] })],
operation: 'SELECT',
role: 'authenticated',
}}
/>
)
expect(screen.getByText('Can access')).toBeInTheDocument()
})
})
describe('policy admonitions', () => {
it('shows service role admonition for postgres role', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [makeTable()],
operation: 'SELECT',
role: undefined,
}}
/>
)
expect(screen.getByText(/bypasses all RLS policies/)).toBeInTheDocument()
})
it('shows "no policies" admonition when RLS is enabled but no policies exist', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [makeTable({ table: 'profiles', tablePolicies: [] })],
operation: 'SELECT',
role: 'anon',
}}
/>
)
expect(screen.getByText(/no policies set up/)).toBeInTheDocument()
expect(screen.getByText(/public.profiles/)).toBeInTheDocument()
})
it('shows "policy false" admonition when a policy evaluates to false', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [makeTable({ table: 'secrets', tablePolicies: [makePolicy('false')] })],
operation: 'SELECT',
role: 'anon',
}}
/>
)
expect(screen.getByText(/evaluates to/)).toBeInTheDocument()
expect(screen.getByText(/public.secrets/)).toBeInTheDocument()
})
})
describe('"Ran as" section', () => {
it('shows postgres for service role', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [],
operation: 'SELECT',
role: undefined,
}}
/>
)
expect(screen.getAllByText('postgres').length).toBeGreaterThan(0)
})
it('shows "an Anonymous user" for anon role', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [],
operation: 'SELECT',
role: 'anon',
}}
/>
)
expect(screen.getByText('an Anonymous user')).toBeInTheDocument()
expect(screen.getByText('Not logged in user')).toBeInTheDocument()
})
it('shows user email and ID when a user is present', () => {
render(
<RLSTesterResults
{...defaultProps}
parseQueryResults={{
tables: [],
operation: 'SELECT',
role: 'authenticated',
user: {
id: 'user-123',
email: 'alice@example.com',
} as any,
}}
/>
)
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
expect(screen.getByText('ID: user-123')).toBeInTheDocument()
})
})
})
@@ -1,192 +0,0 @@
import type { SafeSqlFragment } from '@supabase/pg-meta'
import { describe, expect, it } from 'vitest'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import type { ParseQueryResults } from '@/components/interfaces/Database/RLSTester/RLSTester.types'
import { deriveRLSTestState } from '@/components/interfaces/Database/RLSTester/RLSTesterResults.utils'
const sql = (s: string) => s as unknown as SafeSqlFragment
const makePolicy = (definition: string | null = null): Policy =>
({ definition: definition !== null ? sql(definition) : null }) as Policy
const makeTable = (
overrides?: Partial<ParseQueryResults['tables'][number]>
): ParseQueryResults['tables'][number] => ({
schema: 'public',
table: 'items',
isRLSEnabled: true,
tablePolicies: [],
...overrides,
})
const makeResults = (overrides?: Partial<ParseQueryResults>): ParseQueryResults => ({
tables: [],
operation: 'SELECT',
role: 'anon',
...overrides,
})
describe('deriveRLSTestState', () => {
describe('isServiceRole', () => {
it('is true when parseQueryResults is undefined', () => {
const { isServiceRole } = deriveRLSTestState(undefined)
expect(isServiceRole).toBe(true)
})
it('is true when role is undefined (postgres / service role)', () => {
const { isServiceRole } = deriveRLSTestState(makeResults({ role: undefined }))
expect(isServiceRole).toBe(true)
})
it('is false when role is anon', () => {
const { isServiceRole } = deriveRLSTestState(makeResults({ role: 'anon' }))
expect(isServiceRole).toBe(false)
})
it('is false when role is authenticated', () => {
const { isServiceRole } = deriveRLSTestState(makeResults({ role: 'authenticated' }))
expect(isServiceRole).toBe(false)
})
})
describe('noAccessToData', () => {
it('is false when parseQueryResults is undefined', () => {
const { noAccessToData } = deriveRLSTestState(undefined)
expect(noAccessToData).toBe(false)
})
it('is false for service role even when tables have no policies', () => {
const { noAccessToData } = deriveRLSTestState(
makeResults({ role: undefined, tables: [makeTable()] })
)
expect(noAccessToData).toBe(false)
})
it('is false when RLS is disabled on table', () => {
const { noAccessToData } = deriveRLSTestState(
makeResults({ tables: [makeTable({ isRLSEnabled: false, tablePolicies: [] })] })
)
expect(noAccessToData).toBe(false)
})
it('is true when RLS is enabled and table has no policies', () => {
const { noAccessToData } = deriveRLSTestState(
makeResults({ tables: [makeTable({ isRLSEnabled: true, tablePolicies: [] })] })
)
expect(noAccessToData).toBe(true)
})
it('is true when RLS is enabled and a policy definition is false', () => {
const { noAccessToData } = deriveRLSTestState(
makeResults({
tables: [makeTable({ tablePolicies: [makePolicy('false')] })],
})
)
expect(noAccessToData).toBe(true)
})
it('is false when RLS is enabled and policies are valid (not false)', () => {
const { noAccessToData } = deriveRLSTestState(
makeResults({
tables: [makeTable({ tablePolicies: [makePolicy('auth.uid() = user_id')] })],
})
)
expect(noAccessToData).toBe(false)
})
it('is false when all tables have RLS disabled regardless of policy state', () => {
const { noAccessToData } = deriveRLSTestState(
makeResults({
tables: [
makeTable({ isRLSEnabled: false, tablePolicies: [] }),
makeTable({
table: 'other',
isRLSEnabled: false,
tablePolicies: [makePolicy('false')],
}),
],
})
)
expect(noAccessToData).toBe(false)
})
})
describe('tableWithRLSEnabledButNoPolicies', () => {
it('is undefined when no tables', () => {
const { tableWithRLSEnabledButNoPolicies } = deriveRLSTestState(makeResults({ tables: [] }))
expect(tableWithRLSEnabledButNoPolicies).toBeUndefined()
})
it('is undefined when RLS disabled', () => {
const { tableWithRLSEnabledButNoPolicies } = deriveRLSTestState(
makeResults({ tables: [makeTable({ isRLSEnabled: false })] })
)
expect(tableWithRLSEnabledButNoPolicies).toBeUndefined()
})
it('is undefined when table has policies', () => {
const { tableWithRLSEnabledButNoPolicies } = deriveRLSTestState(
makeResults({ tables: [makeTable({ tablePolicies: [makePolicy('true')] })] })
)
expect(tableWithRLSEnabledButNoPolicies).toBeUndefined()
})
it('returns the matching table when RLS enabled with no policies', () => {
const table = makeTable({ table: 'profiles', tablePolicies: [] })
const { tableWithRLSEnabledButNoPolicies } = deriveRLSTestState(
makeResults({ tables: [table] })
)
expect(tableWithRLSEnabledButNoPolicies).toEqual(table)
})
it('returns the first matching table among multiple', () => {
const first = makeTable({ table: 'profiles', tablePolicies: [] })
const second = makeTable({ table: 'posts', tablePolicies: [] })
const { tableWithRLSEnabledButNoPolicies } = deriveRLSTestState(
makeResults({ tables: [first, second] })
)
expect(tableWithRLSEnabledButNoPolicies).toEqual(first)
})
})
describe('tableWithRLSEnabledWithPolicyFalse', () => {
it('is undefined when no tables', () => {
const { tableWithRLSEnabledWithPolicyFalse } = deriveRLSTestState(makeResults({ tables: [] }))
expect(tableWithRLSEnabledWithPolicyFalse).toBeUndefined()
})
it('is undefined when RLS disabled even with false policy', () => {
const { tableWithRLSEnabledWithPolicyFalse } = deriveRLSTestState(
makeResults({
tables: [makeTable({ isRLSEnabled: false, tablePolicies: [makePolicy('false')] })],
})
)
expect(tableWithRLSEnabledWithPolicyFalse).toBeUndefined()
})
it('is undefined when no policy has definition of false', () => {
const { tableWithRLSEnabledWithPolicyFalse } = deriveRLSTestState(
makeResults({
tables: [makeTable({ tablePolicies: [makePolicy('auth.uid() = user_id')] })],
})
)
expect(tableWithRLSEnabledWithPolicyFalse).toBeUndefined()
})
it('returns the table when a policy definition is exactly "false"', () => {
const table = makeTable({ table: 'secrets', tablePolicies: [makePolicy('false')] })
const { tableWithRLSEnabledWithPolicyFalse } = deriveRLSTestState(
makeResults({ tables: [table] })
)
expect(tableWithRLSEnabledWithPolicyFalse).toEqual(table)
})
it('is undefined when policy definition is null (no definition)', () => {
const { tableWithRLSEnabledWithPolicyFalse } = deriveRLSTestState(
makeResults({ tables: [makeTable({ tablePolicies: [makePolicy(null)] })] })
)
expect(tableWithRLSEnabledWithPolicyFalse).toBeUndefined()
})
})
})
@@ -1,265 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import {
filterTablePolicies,
getTestQueryBlockedReason,
} from '@/components/interfaces/Database/RLSTester/useTestQueryRLS.utils'
const makePolicy = (overrides: Partial<Policy>): Policy =>
({
schema: 'public',
table: 'items',
roles: ['anon'],
command: 'SELECT',
...overrides,
}) as Policy
const base = {
policies: [] as Policy[],
schema: 'public',
table: 'items',
role: 'anon',
operation: 'SELECT' as const,
}
describe('filterTablePolicies', () => {
describe('schema / table matching', () => {
it('excludes policies from a different schema', () => {
const policy = makePolicy({ schema: 'private', table: 'items' })
expect(filterTablePolicies({ ...base, policies: [policy] })).toHaveLength(0)
})
it('excludes policies from a different table', () => {
const policy = makePolicy({ schema: 'public', table: 'other' })
expect(filterTablePolicies({ ...base, policies: [policy] })).toHaveLength(0)
})
it('includes a policy matching schema and table', () => {
const policy = makePolicy({ schema: 'public', table: 'items' })
expect(filterTablePolicies({ ...base, policies: [policy] })).toHaveLength(1)
})
})
describe('role matching', () => {
it('includes policy when role is in the policy roles array', () => {
const policy = makePolicy({ roles: ['anon', 'authenticated'] })
expect(filterTablePolicies({ ...base, role: 'anon', policies: [policy] })).toHaveLength(1)
})
it('excludes policy when role is not in the policy roles array', () => {
const policy = makePolicy({ roles: ['authenticated'] })
expect(filterTablePolicies({ ...base, role: 'anon', policies: [policy] })).toHaveLength(0)
})
it('includes policy when the only role is "public" (applies to all roles)', () => {
const policy = makePolicy({ roles: ['public'] })
expect(filterTablePolicies({ ...base, role: 'anon', policies: [policy] })).toHaveLength(1)
})
it('excludes "public" role shortcut when policy has multiple roles including public', () => {
const policy = makePolicy({ roles: ['public', 'authenticated'] })
expect(filterTablePolicies({ ...base, role: 'anon', policies: [policy] })).toHaveLength(0)
})
it('handles undefined role (service role) — matches nothing unless public', () => {
const rolePolicy = makePolicy({ roles: ['anon'] })
const publicPolicy = makePolicy({ roles: ['public'] })
const result = filterTablePolicies({
...base,
role: undefined,
policies: [rolePolicy, publicPolicy],
})
expect(result).toHaveLength(1)
expect(result[0]).toBe(publicPolicy)
})
})
describe('command matching', () => {
it('includes policy when command matches the operation', () => {
const policy = makePolicy({ command: 'SELECT' })
expect(
filterTablePolicies({ ...base, operation: 'SELECT', policies: [policy] })
).toHaveLength(1)
})
it('excludes policy when command does not match the operation', () => {
const policy = makePolicy({ command: 'INSERT' })
expect(
filterTablePolicies({ ...base, operation: 'SELECT', policies: [policy] })
).toHaveLength(0)
})
it('includes policy with command ALL regardless of operation', () => {
const policy = makePolicy({ command: 'ALL' })
expect(
filterTablePolicies({ ...base, operation: 'SELECT', policies: [policy] })
).toHaveLength(1)
})
it('includes ALL command policy for non-SELECT operations too', () => {
const policy = makePolicy({ command: 'ALL' })
expect(
filterTablePolicies({ ...base, operation: 'INSERT', policies: [policy] })
).toHaveLength(1)
})
it('does not include a SELECT-only policy when operation is INSERT', () => {
const policy = makePolicy({ command: 'SELECT' })
expect(
filterTablePolicies({ ...base, operation: 'INSERT', policies: [policy] })
).toHaveLength(0)
})
})
describe('combined filters', () => {
it('returns only policies that satisfy all conditions', () => {
const match = makePolicy({
schema: 'public',
table: 'items',
roles: ['anon'],
command: 'ALL',
})
const wrongSchema = makePolicy({
schema: 'private',
table: 'items',
roles: ['anon'],
command: 'ALL',
})
const wrongRole = makePolicy({
schema: 'public',
table: 'items',
roles: ['authenticated'],
command: 'ALL',
})
const wrongCommand = makePolicy({
schema: 'public',
table: 'items',
roles: ['anon'],
command: 'INSERT',
})
const result = filterTablePolicies({
...base,
policies: [match, wrongSchema, wrongRole, wrongCommand],
})
expect(result).toHaveLength(1)
expect(result[0]).toBe(match)
})
})
})
describe('getTestQueryBlockedReason', () => {
const blockedBase = {
statementCount: 1,
operation: 'SELECT' as const,
hasSandbox: false,
acknowledgeMutation: false,
}
describe('multiple statements', () => {
it('blocks when statementCount is greater than 1', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, statementCount: 2 })).toStrictEqual({
type: 'multiple-statements',
})
})
it('takes priority over an unsupported operation', () => {
expect(
getTestQueryBlockedReason({ ...blockedBase, statementCount: 2, operation: 'DELETE' })
).toStrictEqual({ type: 'multiple-statements' })
})
it('takes priority over an unacknowledged mutation', () => {
expect(
getTestQueryBlockedReason({ ...blockedBase, statementCount: 2, operation: 'INSERT' })
).toStrictEqual({ type: 'multiple-statements' })
})
it('does not block when statementCount is exactly 1', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, statementCount: 1 })).toBeUndefined()
})
it('does not block when statementCount is 0', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, statementCount: 0 })).toBeUndefined()
})
})
describe('unsupported operations', () => {
it('blocks UPDATE', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'UPDATE' })).toStrictEqual({
type: 'unsupported-operation',
operation: 'UPDATE',
})
})
it('blocks DELETE', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'DELETE' })).toStrictEqual({
type: 'unsupported-operation',
operation: 'DELETE',
})
})
it('blocks UPDATE even with a sandbox available', () => {
expect(
getTestQueryBlockedReason({ ...blockedBase, operation: 'UPDATE', hasSandbox: true })
).toStrictEqual({ type: 'unsupported-operation', operation: 'UPDATE' })
})
it('blocks DELETE even when already acknowledged', () => {
expect(
getTestQueryBlockedReason({
...blockedBase,
operation: 'DELETE',
acknowledgeMutation: true,
})
).toStrictEqual({ type: 'unsupported-operation', operation: 'DELETE' })
})
})
describe('INSERT mutation warning', () => {
it('blocks an unacknowledged INSERT with no sandbox', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'INSERT' })).toStrictEqual({
type: 'mutation',
operation: 'INSERT',
})
})
it('does not block an INSERT when a sandbox is available', () => {
expect(
getTestQueryBlockedReason({ ...blockedBase, operation: 'INSERT', hasSandbox: true })
).toBeUndefined()
})
it('does not block an INSERT once acknowledged', () => {
expect(
getTestQueryBlockedReason({
...blockedBase,
operation: 'INSERT',
acknowledgeMutation: true,
})
).toBeUndefined()
})
it('does not require acknowledgement when a sandbox is available', () => {
expect(
getTestQueryBlockedReason({
...blockedBase,
operation: 'INSERT',
hasSandbox: true,
acknowledgeMutation: false,
})
).toBeUndefined()
})
})
describe('unblocked operations', () => {
it('does not block SELECT', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, operation: 'SELECT' })).toBeUndefined()
})
it('does not block when operation is undefined', () => {
expect(getTestQueryBlockedReason({ ...blockedBase, operation: undefined })).toBeUndefined()
})
})
})
@@ -1,261 +0,0 @@
import { safeSql, type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useState } from 'react'
import { toast } from 'sonner'
import { checkIfAppendLimitRequired, suffixWithLimit } from '../../SQLEditor/SQLEditor.utils'
import { type ParseQueryResults } from './RLSTester.types'
import {
filterTablePolicies,
getTestQueryBlockedReason,
type TestQueryBlockedReason,
} from './useTestQueryRLS.utils'
import { useParseClientCodeMutation } from '@/data/ai/parse-client-code-mutation'
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
import { useCheckTableRLSStatusMutation } from '@/data/database/table-check-rls-mutation'
import {
useParseSQLQueryMutation,
type ParseSQLQueryOperations,
} from '@/data/misc/parse-query-mutation'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox'
import {
isRoleImpersonationEnabled,
useGetImpersonatedRoleState,
useImpersonatedUser,
useRoleImpersonationStateSnapshot,
} from '@/state/role-impersonation-state'
import { type ResponseError } from '@/types'
const limit = 100
export type { TestQueryBlockedReason }
// [Joshen] Pre-requisite work for identifying UPDATE / DELETE failures due to RLS - not yet wired
// in since those operations are currently blocked (see TestQueryBlockedReason's 'unsupported-
// operation'). Exported so it isn't flagged as unused until the follow-up PR wires it back in.
export const wrapReturnRowsAffected = (sql: SafeSqlFragment) => {
return safeSql`
DO $$
DECLARE
row_count integer;
BEGIN
${sql}${(sql.endsWith(';') ? '' : ';') as SafeSqlFragment}
GET DIAGNOSTICS row_count = ROW_COUNT;
-- store it somewhere you can read back
PERFORM set_config('rls_tester.rows_affected', row_count::text, true);
END $$;
SELECT current_setting('rls_tester.rows_affected', true);
`
}
/**
* [Joshen] Testing a SQL query for its RLS access involves 3 async steps
* 0. (Optional) Inferring client library code to SQL query via the AI Assistant
* 1. Parsing the provided SQL query to retrieve its operation type + tables involved
* 2. Checking for tables involved if they've got RLS enabled
* 3. Actually running the query to retrieve the results
*
* Errors should all be handled as part of the UI instead of toasts, hence the empty onError
* handlers to mute the default error handlers within the react query mutationhooks
*/
export const useTestQueryRLS = () => {
const { data: project } = useSelectedProjectQuery()
const { role } = useRoleImpersonationStateSnapshot()
const { sandbox } = usePostgresSandbox()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const impersonatedRoleState = getImpersonatedRoleState()
const user = useImpersonatedUser()
const [isLoading, setIsLoading] = useState(false)
const [sandboxError, setSandboxError] = useState<Error>()
const { data: policies = [] } = useDatabasePoliciesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { mutateAsync: executeSql, error: executeSqlMutationError } = useExecuteSqlMutation({
onError: () => {},
})
const executeSqlError = sandbox ? sandboxError : executeSqlMutationError
const {
mutateAsync: parseClientCode,
isPending: isInferring,
error: parseClientCodeError,
} = useParseClientCodeMutation({
onError: () => {},
})
const inferSQLFromLib = async (
value: string,
onInferSQL: (unchecked_sql: UntrustedSqlFragment) => void
) => {
const { unchecked_sql, valid } = await parseClientCode({ code: value })
if (valid && unchecked_sql != null) {
onInferSQL(unchecked_sql)
} else {
toast.error('Client library code provided is not valid')
}
}
const { mutateAsync: parseQuery, error: parseQueryError } = useParseSQLQueryMutation({
onError: () => {},
})
const { mutateAsync: getTableRLSStatus, error: getTableRLSStatusError } =
useCheckTableRLSStatusMutation({
onError: () => {},
})
/**
* Returns true if the query was blocked (multiple statements, or an unacknowledged mutation)
* and did not run, false if it ran (successfully or not)
*/
const testQuery = async ({
value,
option,
acknowledgeMutation = false,
onExecuteSQL,
onParseQuery,
onValidationBlocked,
}: {
value: SafeSqlFragment
option: 'anon' | 'authenticated'
acknowledgeMutation?: boolean
onExecuteSQL: ({
result,
operation,
isAutoLimit,
}: {
result: Object[] | null
operation: ParseSQLQueryOperations
isAutoLimit: boolean
}) => void
onParseQuery: (results?: ParseQueryResults) => void
onValidationBlocked: (reason: TestQueryBlockedReason) => void
}): Promise<boolean> => {
if (!project) {
console.error('Project is required')
return true
}
if (option === 'authenticated' && !user) {
toast('Select which user to test as before running the query')
return true
}
try {
setIsLoading(true)
setSandboxError(undefined)
const { appendAutoLimit } = checkIfAppendLimitRequired(value, limit)
const formattedSql = suffixWithLimit(value, limit)
const data = await parseQuery({ sql: formattedSql })
const blockedReason = getTestQueryBlockedReason({
statementCount: data.statementCount,
operation: data.operation,
hasSandbox: !!sandbox,
acknowledgeMutation,
})
if (blockedReason) {
onValidationBlocked(blockedReason)
return true
}
const formattedTables = data.tables.map((x) => {
const [schema, table] = x.includes('.') ? x.split('.') : ['public', x]
return { schema, table }
})
const response = await getTableRLSStatus({
projectRef: project?.ref,
connectionString: project?.connectionString,
tables: formattedTables,
})
const tables = response
.map(({ table, schema, rls_enabled }) => {
const tablePolicies = filterTablePolicies({
policies,
schema,
table,
role: role?.role,
operation: data.operation,
})
return {
table,
schema,
isRLSEnabled: rls_enabled,
tablePolicies,
}
})
.sort((a, b) => {
const aFirst = a.isRLSEnabled && a.tablePolicies.length === 0
const bFirst = b.isRLSEnabled && b.tablePolicies.length === 0
return Number(bFirst) - Number(aFirst)
})
const autoLimit = appendAutoLimit ? limit : undefined
// UPDATE/DELETE are blocked above, so wrapReturnRowsAffected isn't wired in here yet -
// it's kept for the follow-up PR that adds proper UPDATE/DELETE support
const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState)
try {
const { result } = sandbox
? await sandbox.run({ sql }).catch((e) => {
setSandboxError(e instanceof Error ? e : new Error(String(e)))
throw e
})
: await executeSql({
sql,
autoLimit,
projectRef: project.ref,
connectionString: project.connectionString,
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
isStatementTimeoutDisabled: true,
handleError: (e) => {
throw e
},
queryKey: ['rls-tester'],
})
onExecuteSQL({ result, operation: data.operation, isAutoLimit: !!autoLimit })
onParseQuery({ tables, operation: data.operation, role: role?.role, user })
} catch (error) {
const isRLSInsertError = Boolean(
(error as ResponseError)?.message?.includes('new row violates row-level security policy')
)
onExecuteSQL({ result: null, operation: data.operation, isAutoLimit: false })
if (isRLSInsertError) {
onParseQuery({ tables, operation: data.operation, role: role?.role, user })
} else {
onParseQuery(undefined)
}
}
} catch (error) {
onExecuteSQL({ result: null, operation: undefined, isAutoLimit: false })
onParseQuery(undefined)
} finally {
setIsLoading(false)
}
return false
}
return {
limit,
testQuery,
inferSQLFromLib,
isLoading,
isInferring,
executeSqlError,
parseQueryError,
parseClientCodeError,
getTableRLSStatusError,
}
}
@@ -1,56 +0,0 @@
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import type { ParseSQLQueryResponse } from '@/data/misc/parse-query-mutation'
export type TestQueryBlockedReason =
| { type: 'multiple-statements' }
| { type: 'unsupported-operation'; operation: 'UPDATE' | 'DELETE' }
| { type: 'mutation'; operation: 'INSERT' }
/**
* Decides whether a query should be blocked from running, and why. Checked in this order:
* multiple statements first (regardless of operation), then unsupported operations (UPDATE/
* DELETE aren't testable yet - RLS blocks them silently instead of raising an error), then
* INSERT mutations against the real database that haven't been acknowledged yet.
*/
export function getTestQueryBlockedReason({
statementCount,
operation,
hasSandbox,
acknowledgeMutation,
}: {
statementCount: number
operation: ParseSQLQueryResponse['operation']
hasSandbox: boolean
acknowledgeMutation: boolean
}): TestQueryBlockedReason | undefined {
if (statementCount > 1) return { type: 'multiple-statements' }
if (operation === 'UPDATE' || operation === 'DELETE') {
return { type: 'unsupported-operation', operation }
}
if (operation === 'INSERT' && !hasSandbox && !acknowledgeMutation) {
return { type: 'mutation', operation }
}
return undefined
}
export function filterTablePolicies({
policies,
schema,
table,
role,
operation,
}: {
policies: Policy[]
schema: string
table: string
role: string | undefined
operation: ParseSQLQueryResponse['operation']
}): Policy[] {
return policies.filter(
(x) =>
x.schema === schema &&
x.table === table &&
(x.roles.includes(role ?? '') || (x.roles.length === 1 && x.roles[0] === 'public')) &&
(x.command === 'ALL' || x.command === operation)
)
}
@@ -5,7 +5,6 @@ export const BANNER_ID = {
INDEX_ADVISOR: 'index-advisor-banner',
TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner',
RLS_EVENT_TRIGGER: 'rls-event-trigger-banner',
RLS_TESTER: 'rls-tester-banner',
FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner',
TOS_UPDATE: 'tos-update-banner',
UNIFIED_LOGS: 'unified-logs-banner',
@@ -1,113 +0,0 @@
import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams } from 'common/hooks'
import { AnimatePresence, motion } from 'framer-motion'
import { Check, Loader2, Terminal } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge, Button, cn } from 'ui'
import { BannerCard } from '../BannerCard'
import { useBannerStack } from '../BannerStackProvider'
import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
const text = 'select * from colors'
export const BannerRlsTester = () => {
const { ref } = useParams()
const { selectFeaturePreview } = useFeaturePreviewModal()
const [runQueryAnimate, setRunQueryAnimate] = useState(false)
const [showSummary, setShowSummary] = useState(false)
const { dismissBanner } = useBannerStack()
const [, setIsDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.RLS_TESTER_BANNER_DISMISSED(ref ?? ''),
false
)
useEffect(() => {
setTimeout(() => setRunQueryAnimate(true), 2400)
}, [])
useEffect(() => {
if (runQueryAnimate) {
setTimeout(() => setShowSummary(true), 1700)
}
}, [runQueryAnimate])
return (
<BannerCard
onDismiss={() => {
setIsDismissed(true)
dismissBanner('rls-tester-banner')
}}
>
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2 items-start">
<Badge variant="success" className="-ml-0.5 uppercase inline-flex items-center mb-2">
Preview
</Badge>
<div className={cn('transition-all bg-surface-100 w-full border rounded-md')}>
<div className="flex items-center gap-x-2 p-2">
{runQueryAnimate && !showSummary ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Terminal size={12} />
)}
<p
className="uppercase tracking-tight text-xs font-mono overflow-hidden whitespace-nowrap border-r-2 border-overlay"
style={{
width: `${text.length}ch`,
animation: `typewriter 2s steps(${text.length}) forwards, blink-caret 0.75s step-end infinite`,
}}
>
{text}
</p>
</div>
<AnimatePresence>
{showSummary && (
<motion.div
initial={{ height: 0 }}
animate={{ height: '50px' }}
exit={{ height: 0 }}
transition={{
type: 'spring',
stiffness: 420,
damping: 30,
mass: 0.4,
}}
className="border-t text-xs p-2"
>
<div className="flex items-center gap-x-2">
<Check size={12} strokeWidth={3} className="text-brand" />
<p>
Can access <code className="text-code-inline">public.colors</code>
</p>
</div>
<div>
<p className="text-foreground-light mt-0.5 ml-5">2 policies applied</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex flex-col gap-y-1 mb-2">
<p className="text-sm font-medium">Row Level Security (RLS) Tester</p>
<p className="text-xs text-foreground-lighter text-balance">
Verify your RLS policies are correct by running queries as a specific user
</p>
</div>
<Button
variant="default"
className="w-min"
onClick={() => selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER)}
>
Enable feature preview
</Button>
</div>
</BannerCard>
)
}
@@ -1,180 +0,0 @@
import pgMeta, {
getEntityDefinitionsSql,
joinSqlFragments,
literal,
safeSql,
type PGPolicy,
} from '@supabase/pg-meta'
import { z } from 'zod'
import { executeSql } from '@/data/sql/execute-sql-mutation'
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
export interface RlsTableStatus {
schema: string
table: string
rls_enabled: boolean
rls_forced: boolean
}
export interface CustomRole {
name: string
}
export interface DatabaseSchemaDDL {
schemas: string[]
typeDefinitions: string[]
entityDefinitions: string[]
functionDefinitions: string[]
policies: PGPolicy[]
rlsStatuses: RlsTableStatus[]
customRoles: CustomRole[]
}
const pgMetaRolesList = pgMeta.roles.list()
const pgMetaFunctionsZod = pgMeta.functions.list().zod
const pgMetaPoliciesZod = pgMeta.policies.list().zod
const pgMetaTablesZod = pgMeta.tables.list().zod
// Extension-owned / platform-specific schemas whose DDL depends on C extensions,
// custom operators, and platform functions that PGlite cannot replicate.
// We skip entity/function/type DDL for these but still fetch their policies —
// those may reference user tables we do load.
const SUPABASE_INTERNAL_SCHEMAS = new Set([...INTERNAL_SCHEMAS, '_realtime'])
const SYSTEM_ROLES = new Set([
'postgres',
'anon',
'authenticated',
'service_role',
'supabase_admin',
'supabase_auth_admin',
'supabase_storage_admin',
'supabase_replication_admin',
'supabase_read_only_user',
'pg_monitor',
'pg_read_all_settings',
'pg_read_all_stats',
'pg_stat_scan_tables',
'pg_read_server_files',
'pg_write_server_files',
'pg_execute_server_program',
'pg_signal_backend',
'dashboard_user',
'pgbouncer',
])
function getTypeDefinitionsSql(schemas: string[]) {
return safeSql`
SELECT
CASE t.typtype
WHEN 'e' THEN
'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) ||
' AS ENUM (' ||
(SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder)
FROM pg_enum e WHERE e.enumtypid = t.oid) ||
')'
WHEN 'c' THEN
'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) ||
' AS (' ||
(SELECT string_agg(quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod), ', ' ORDER BY a.attnum)
FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped) ||
')'
WHEN 'd' THEN
'CREATE DOMAIN ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) ||
' AS ' || pg_catalog.format_type(t.typbasetype, t.typtypmod)
END AS definition
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
LEFT JOIN pg_class c ON c.oid = t.typrelid
LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e'
WHERE n.nspname IN (${joinSqlFragments(schemas.map(literal), ', ')})
AND t.typtype IN ('e', 'c', 'd')
AND d.objid IS NULL
AND (t.typtype != 'c' OR c.relkind = 'c')
ORDER BY t.typtype, n.nspname, t.typname
`
}
type Variables = {
projectRef?: string
connectionString?: string | null
schemas: string[]
}
export async function getDatabaseSchemaDDL(
{ projectRef, connectionString, schemas }: Variables,
signal?: AbortSignal
): Promise<DatabaseSchemaDDL> {
const userSchemas = schemas.filter((s) => !SUPABASE_INTERNAL_SCHEMAS.has(s))
const entitySql = getEntityDefinitionsSql({ schemas: userSchemas })
const functionsSql = pgMeta.functions.list({ includedSchemas: userSchemas }).sql
const policiesSql = pgMeta.policies.list({ includedSchemas: schemas }).sql
const tablesSql = pgMeta.tables.list({ includedSchemas: userSchemas }).sql
const [entityResult, policiesResult, rlsResult, rolesResult, functionsResult, typesResult] =
await Promise.all([
executeSql(
{ projectRef, connectionString, sql: entitySql, queryKey: ['rls-sandbox-ddl'] },
signal
),
executeSql(
{ projectRef, connectionString, sql: policiesSql, queryKey: ['rls-sandbox-policies'] },
signal
),
executeSql(
{ projectRef, connectionString, sql: tablesSql, queryKey: ['rls-sandbox-rls'] },
signal
),
executeSql(
{ projectRef, connectionString, sql: pgMetaRolesList.sql, queryKey: ['rls-sandbox-roles'] },
signal
),
executeSql(
{
projectRef,
connectionString,
sql: functionsSql,
queryKey: ['rls-sandbox-functions'],
},
signal
),
executeSql(
{
projectRef,
connectionString,
sql: getTypeDefinitionsSql(userSchemas),
queryKey: ['rls-sandbox-types'],
},
signal
),
])
const roles = (rolesResult.result as z.infer<typeof pgMetaRolesList.zod>).filter(
(r) => !SYSTEM_ROLES.has(r.name) && !r.name.startsWith('pg_') && !r.name.startsWith('supabase_')
)
const functions = (functionsResult.result as z.infer<typeof pgMetaFunctionsZod>).filter(
(f) => (f.language === 'sql' || f.language === 'plpgsql') && f.return_type !== 'trigger'
)
return {
schemas: userSchemas,
typeDefinitions: (typesResult.result as { definition: string }[]).map((r) => r.definition),
entityDefinitions: (entityResult.result[0]?.data?.definitions ?? []).map(
(d: { sql: string }) => d.sql
),
functionDefinitions: functions.map((f) => f.complete_statement),
policies: policiesResult.result as z.infer<typeof pgMetaPoliciesZod> as PGPolicy[],
rlsStatuses: (rlsResult.result as z.infer<typeof pgMetaTablesZod>).map((t) => ({
schema: t.schema,
table: t.name,
rls_enabled: t.rls_enabled,
rls_forced: t.rls_forced,
})),
customRoles: roles,
}
}
export type DatabaseSchemaDDLData = Awaited<ReturnType<typeof getDatabaseSchemaDDL>>
@@ -1,88 +0,0 @@
import { ident, joinSqlFragments, literal, safeSql } from '@supabase/pg-meta'
import { RlsTableStatus } from './get-schema-ddl'
import { executeSql } from '@/data/sql/execute-sql-mutation'
export interface TableSeedData {
schema: string
table: string
rows: Record<string, unknown>[]
}
// Each entry can optionally restrict which columns are fetched. Used by the
// sandbox to avoid pulling secrets (e.g. auth.users encrypted_password / tokens)
// into the browser-side PGlite instance.
export type SeedTableEntry = RlsTableStatus & { columns?: readonly string[] }
type Variables = {
projectRef?: string
connectionString?: string | null
tables: SeedTableEntry[]
rowLimit: number
}
async function fetchTableSeed(
{
projectRef,
connectionString,
schema,
table,
columns,
rowLimit,
}: Omit<Variables, 'tables'> & {
schema: string
table: string
columns?: readonly string[]
},
signal?: AbortSignal
): Promise<TableSeedData> {
try {
const projection =
columns && columns.length > 0 ? joinSqlFragments(columns.map(ident), ', ') : safeSql`*`
const { result } = await executeSql(
{
projectRef,
connectionString,
sql: safeSql`SELECT ${projection} FROM ${ident(schema)}.${ident(table)} LIMIT ${literal(Number(rowLimit))}`,
queryKey: ['rls-sandbox-seed', schema, table],
},
signal
)
return { schema, table, rows: (result ?? []) as Record<string, unknown>[] }
} catch {
return { schema, table, rows: [] }
}
}
const SEED_CONCURRENCY = 8
export async function getProjectSeedData(
{ projectRef, connectionString, tables, rowLimit }: Variables,
signal?: AbortSignal
): Promise<TableSeedData[]> {
const results: TableSeedData[] = []
const queue = tables.slice()
const workers = Array.from({ length: Math.min(SEED_CONCURRENCY, queue.length) }, async () => {
while (queue.length > 0) {
const entry = queue.shift()
if (!entry) break
results.push(
await fetchTableSeed(
{
projectRef,
connectionString,
schema: entry.schema,
table: entry.table,
columns: entry.columns,
rowLimit,
},
signal
)
)
}
})
await Promise.all(workers)
return results.filter((t) => t.rows.length > 0)
}
export type ProjectSeedDataData = TableSeedData[]
-2
View File
@@ -51,8 +51,6 @@
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electric-sql/pglite": "0.4.5",
"@electric-sql/pglite-tools": "^0.3.4",
"@graphiql/react": "^0.37.3",
"@graphiql/toolkit": "^0.11.3",
"@hcaptcha/react-hcaptcha": "^1.12.0",
@@ -1,5 +1,5 @@
import { ident, safeSql } from '@supabase/pg-meta'
import type { PGTable } from '@supabase/pg-meta'
import { ident, safeSql } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { Search, X } from 'lucide-react'
@@ -21,7 +21,6 @@ import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { Policies } from '@/components/interfaces/Database/Policies/Policies'
import { getGeneralPolicyTemplates } from '@/components/interfaces/Database/Policies/Policies.constants'
import { PoliciesDataProvider } from '@/components/interfaces/Database/Policies/PoliciesDataContext'
@@ -30,15 +29,12 @@ import {
generatePolicyUpdateSQL,
type Policy,
} from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
import { RLSTesterSheet } from '@/components/interfaces/Database/RLSTester/RLSTesterSheet'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { getExposedSchemas } from '@/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.utils'
import { AlertError } from '@/components/ui/AlertError'
import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice'
import { BannerRlsTester } from '@/components/ui/BannerStack/Banners/BannerRlsTester'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { DocsButton } from '@/components/ui/DocsButton'
import { NoPermission } from '@/components/ui/NoPermission'
import { SchemaSelector } from '@/components/ui/SchemaSelector'
@@ -121,7 +117,6 @@ const DatabasePoliciesPage: NextPageWithLayout = () => {
)
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const rlsTesterEnabled = useIsRLSTesterEnabled()
const { openSidebar } = useSidebarManagerSnapshot()
const {
@@ -150,18 +145,12 @@ const DatabasePoliciesPage: NextPageWithLayout = () => {
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setSearchString(''))
const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] })
const { addBanner, dismissBanner } = useBannerStack()
const [isAutoEnableRLSMinimized] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? ''),
false
)
const [isRlsTesterBannerDismissed] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.RLS_TESTER_BANNER_DISMISSED(projectRef ?? ''),
false
)
const {
data: policies = [],
isPending: isLoadingPolicies,
@@ -249,25 +238,6 @@ const DatabasePoliciesPage: NextPageWithLayout = () => {
const handleResetSearch = useCallback(() => setSearchString(''), [setSearchString])
useEffect(() => {
if (rlsTesterEnabled) return
if (!isRlsTesterBannerDismissed) {
addBanner({
id: 'rls-tester-banner',
isDismissed: false,
content: <BannerRlsTester />,
priority: 3,
})
} else {
dismissBanner('rls-tester-banner')
}
return () => {
dismissBanner('rls-tester-banner')
}
}, [addBanner, dismissBanner, isRlsTesterBannerDismissed, rlsTesterEnabled])
useEffect(() => {
if (selectedIdToEdit && isPoliciesSuccess && !selectedPolicyToEdit) {
toast(`Policy ID ${selectedIdToEdit} cannot be found`)
@@ -294,7 +264,6 @@ const DatabasePoliciesPage: NextPageWithLayout = () => {
<PageHeaderAside>
{isAutoEnableRLSMinimized && <AutoEnableRLSNotice iconOnly />}
<DocsButton href={`${DOCS_URL}/learn/auth-deep-dive/auth-row-level-security`} />
{rlsTesterEnabled && <RLSTesterSheet handleSelectEditPolicy={handleSelectEditPolicy} />}
</PageHeaderAside>
</PageHeaderMeta>
</PageHeader>
@@ -343,6 +312,7 @@ const DatabasePoliciesPage: NextPageWithLayout = () => {
<Button
size="tiny"
variant="text"
aria-label="Clear filter"
className="p-0 h-5 w-5"
icon={<X />}
onClick={() => setSearchString('')}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

@@ -1,13 +0,0 @@
import { PGlite } from '@electric-sql/pglite'
import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto'
import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp'
import { worker } from '@electric-sql/pglite/worker'
worker({
async init() {
return new PGlite({
dataDir: 'memory://',
extensions: { pgcrypto, uuid_ossp },
})
},
})
@@ -1,162 +0,0 @@
// ALTER ROLE postgres SUPERUSER succeeds because the bootstrap connection owns the cluster.
// Each statement is individual so a single failure cannot abort the rest.
export const SANDBOX_SETUP_STATEMENTS = [
`ALTER ROLE postgres SUPERUSER`,
`DO $$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN
CREATE ROLE anon NOLOGIN;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN
CREATE ROLE authenticated NOLOGIN;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN
CREATE ROLE service_role NOLOGIN;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN
CREATE ROLE authenticator NOLOGIN;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'dashboard_user') THEN
CREATE ROLE dashboard_user NOLOGIN;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pgbouncer') THEN
CREATE ROLE pgbouncer NOLOGIN;
END IF;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN
CREATE ROLE supabase_admin NOLOGIN;
END IF;
END $$`,
`ALTER ROLE service_role BYPASSRLS`,
`GRANT anon TO postgres WITH ADMIN OPTION`,
`GRANT authenticated TO postgres WITH ADMIN OPTION`,
`GRANT service_role TO postgres WITH ADMIN OPTION`,
`GRANT CONNECT ON DATABASE postgres TO anon, authenticated, service_role`,
`CREATE SCHEMA IF NOT EXISTS auth`,
`GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role`,
`GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role`,
// Read both the per-claim setting (request.jwt.claim.<name>) and the JSON blob
// (request.jwt.claims) so these work whether the caller uses Studio's role
// impersonation (sets the JSON blob) or PostgREST-style per-claim settings.
// Mirrors how the real Supabase auth.* helpers are defined.
`CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS
$fn$ SELECT COALESCE(
NULLIF(current_setting('request.jwt.claim.sub', true), ''),
(NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
)::uuid $fn$`,
`CREATE OR REPLACE FUNCTION auth.role() RETURNS text LANGUAGE sql STABLE AS
$fn$ SELECT COALESCE(
NULLIF(current_setting('request.jwt.claim.role', true), ''),
(NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role'),
'anon'
) $fn$`,
`CREATE OR REPLACE FUNCTION auth.email() RETURNS text LANGUAGE sql STABLE AS
$fn$ SELECT COALESCE(
NULLIF(current_setting('request.jwt.claim.email', true), ''),
(NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')
) $fn$`,
`GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated, service_role`,
`GRANT EXECUTE ON FUNCTION auth.role() TO anon, authenticated, service_role`,
`GRANT EXECUTE ON FUNCTION auth.email() TO anon, authenticated, service_role`,
// Minimal auth table stubs — enough for FK references and policy expressions.
// Projects commonly have FKs to auth.users from public schema tables (e.g. profiles),
// so without this stub those tables fail to create and their policies can't be tested.
`CREATE TABLE IF NOT EXISTS auth.users (
instance_id uuid,
id uuid NOT NULL PRIMARY KEY,
aud varchar(255),
role varchar(255),
email varchar(255),
encrypted_password varchar(255),
email_confirmed_at timestamptz,
invited_at timestamptz,
confirmation_token varchar(255),
confirmation_sent_at timestamptz,
recovery_token varchar(255),
recovery_sent_at timestamptz,
email_change_token_new varchar(255),
email_change varchar(255),
email_change_sent_at timestamptz,
last_sign_in_at timestamptz,
raw_app_meta_data jsonb,
raw_user_meta_data jsonb,
is_super_admin boolean,
created_at timestamptz,
updated_at timestamptz,
phone text DEFAULT NULL,
phone_confirmed_at timestamptz,
phone_change text DEFAULT '',
phone_change_token varchar(255) DEFAULT '',
phone_change_sent_at timestamptz,
confirmed_at timestamptz,
email_change_token_current varchar(255) DEFAULT '',
email_change_confirm_status smallint DEFAULT 0,
banned_until timestamptz,
reauthentication_token varchar(255) DEFAULT '',
reauthentication_sent_at timestamptz,
is_sso_user boolean NOT NULL DEFAULT false,
deleted_at timestamptz,
is_anonymous boolean NOT NULL DEFAULT false
)`,
`CREATE TABLE IF NOT EXISTS auth.sessions (
id uuid NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at timestamptz,
updated_at timestamptz,
factor_id uuid,
aal text,
not_after timestamptz,
refreshed_at timestamp,
user_agent text,
ip inet,
tag text
)`,
`CREATE TABLE IF NOT EXISTS auth.mfa_factors (
id uuid NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
friendly_name text,
factor_type text NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
secret text,
phone text,
last_challenged_at timestamptz,
web_authn_credential jsonb,
web_authn_aaguid uuid
)`,
`GRANT SELECT, INSERT, UPDATE, DELETE ON auth.users, auth.sessions, auth.mfa_factors TO anon, authenticated, service_role`,
]
// Seeded alongside public tables so FK references from public → auth.users
// resolve to real rows. rls flags are ignored here — auth.users is set up by
// SANDBOX_SETUP_STATEMENTS, this entry is only used by the seed step.
//
// Columns are an explicit allow-list: enough to evaluate realistic RLS
// policies (id for FK matching, role/email/metadata for claim-style checks)
// while keeping secrets out of the browser-side PGlite instance — no
// encrypted_password, no *_token columns.
export const AUTH_USERS_SEED_TABLE = {
schema: 'auth',
table: 'users',
rls_enabled: false,
rls_forced: false,
columns: [
'id',
'aud',
'role',
'email',
'phone',
'email_confirmed_at',
'phone_confirmed_at',
'last_sign_in_at',
'confirmed_at',
'raw_app_meta_data',
'raw_user_meta_data',
'is_super_admin',
'is_sso_user',
'is_anonymous',
'banned_until',
'deleted_at',
'created_at',
'updated_at',
],
} as const
@@ -1,72 +0,0 @@
import { PGliteWorker } from '@electric-sql/pglite/worker'
import { SANDBOX_SETUP_STATEMENTS } from './sandbox.constants'
import { applySchema, applySeed } from './sandbox.utils'
import { type DatabaseSchemaDDLData } from '@/data/rls-tester/get-schema-ddl'
import { type TableSeedData } from '@/data/rls-tester/get-seed-data'
import { getErrorMessage } from '@/lib/get-error-message'
type RLSTestResult = Record<string, unknown>[]
export interface SandboxCore {
setSchema(data: DatabaseSchemaDDLData): Promise<void>
setSeed(tables: TableSeedData[]): Promise<void>
destroy(): Promise<void>
run: (props: { sql: string }) => Promise<{ result: RLSTestResult }>
}
let instance: SandboxCore | null = null
let initPromise: Promise<SandboxCore> | null = null
export const getSandboxCore = async () => {
if (instance) return instance
if (!initPromise) {
initPromise = boot().finally(() => {
initPromise = null
})
}
return initPromise
}
const boot = async (): Promise<SandboxCore> => {
const webWorker = new Worker(new URL('./pglite.worker.ts', import.meta.url), { type: 'module' })
const pg = await PGliteWorker.create(webWorker)
for (const sql of SANDBOX_SETUP_STATEMENTS) {
try {
await pg.exec(sql)
} catch (err) {
console.warn('[Postgres sandbox] setup:', (err as Error).message, `— ${sql.slice(0, 60)}`)
}
}
function makeExecutor() {
return { execSql: (sql: string) => pg.exec(sql).then(() => undefined as void) }
}
async function setSchema(data: DatabaseSchemaDDLData): Promise<void> {
await applySchema(makeExecutor(), data)
}
async function setSeed(tables: TableSeedData[]): Promise<void> {
await applySeed(makeExecutor(), tables)
}
const run = async ({ sql }: { sql: string }) => {
try {
const results = await pg.exec(sql)
return { result: results.at(-1)?.rows ?? [] }
} catch (error) {
await pg.exec('ROLLBACK').catch(() => {})
throw error instanceof Error ? error : new Error(getErrorMessage(error) ?? String(error))
}
}
const destroy = async () => {
webWorker.terminate()
instance = null
}
instance = { run, destroy, setSchema, setSeed }
return instance
}
@@ -1,143 +0,0 @@
import { noop } from 'lodash'
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { AUTH_USERS_SEED_TABLE } from './sandbox.constants'
import { getSandboxCore, type SandboxCore } from './sandbox.core'
import { getDatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl'
import { getProjectSeedData, TableSeedData } from '@/data/rls-tester/get-seed-data'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { getErrorMessage } from '@/lib/get-error-message'
type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error'
const SandboxContext = createContext<{
status: SandboxStatus
error?: string
sandbox: SandboxCore | null
isSyncing: boolean
startSandbox: () => void
destroySandbox: () => Promise<void>
syncSandbox: () => Promise<void>
}>({
status: 'idle',
error: undefined,
sandbox: null,
isSyncing: false,
startSandbox: noop,
destroySandbox: async () => {},
syncSandbox: async () => {},
})
export const PostgresSandboxProvider = ({ children }: PropsWithChildren) => {
const { data: project } = useSelectedProjectQuery()
const [start, setStart] = useState<boolean>(false)
const [error, setError] = useState<string>()
const [sandbox, setSandbox] = useState<SandboxCore | null>(null)
const [status, setStatus] = useState<SandboxStatus>('idle')
const [isSyncing, setIsSyncing] = useState(false)
const destroySandbox = async () => {
if (isSyncing) return
if (!sandbox) return console.error('Sandbox is not set up')
await sandbox.destroy()
setSandbox(null)
setStatus('idle')
setError(undefined)
setStart(false)
}
// Internal — takes the target explicitly so the boot path can pass the
// freshly booted core before React state has caught up. Callers outside
// the provider use `syncSandbox()` which sources the target from state.
const applyToCore = async (target: SandboxCore) => {
setIsSyncing(true)
try {
const schemaDDL = await getDatabaseSchemaDDL({
projectRef: project?.ref,
connectionString: project?.connectionString,
schemas: ['public'],
})
const seedData: TableSeedData[] = await getProjectSeedData({
projectRef: project?.ref,
connectionString: project?.connectionString,
tables: [AUTH_USERS_SEED_TABLE, ...(schemaDDL.rlsStatuses ?? [])],
rowLimit: 100,
})
await target.setSchema(schemaDDL)
await target.setSeed(seedData)
} catch (e) {
const message = getErrorMessage(e) ?? String(e)
if (sandbox) {
// Refresh path — sandbox is still usable with the previous schema/data.
toast.error(`Failed to refresh sandbox: ${message}`)
} else {
// Boot path — propagate so the outer .catch sets status='error' and
// the SandboxManagement error branch renders.
throw e
}
} finally {
setIsSyncing(false)
}
}
const syncSandbox = async () => {
if (isSyncing) return
if (!sandbox) return console.error('Sandbox has not been loaded')
await applyToCore(sandbox)
}
useEffect(() => {
if (!start) return
let cancelled = false
setStatus('loading')
getSandboxCore()
.then(async (core) => {
if (cancelled) return
await applyToCore(core)
setSandbox(core)
setStatus('ready')
})
.catch((error) => {
if (cancelled) return
setError(getErrorMessage(error) ?? '')
setStatus('error')
setStart(false)
})
return () => {
cancelled = true
}
// applyToCore intentionally omitted: this effect should fire once when
// `start` flips, not every time the helper identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [start])
return (
<SandboxContext.Provider
value={{
status,
error,
sandbox,
isSyncing,
startSandbox: () => setStart(true),
destroySandbox,
syncSandbox,
}}
>
{children}
</SandboxContext.Provider>
)
}
export const usePostgresSandbox = () => useContext(SandboxContext)
@@ -1,210 +0,0 @@
import { ident, literal, type PGPolicy } from '@supabase/pg-meta'
import { DatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl'
import { TableSeedData } from '@/data/rls-tester/get-seed-data'
import { getErrorMessage } from '@/lib/get-error-message'
interface Executor {
execSql(sql: string): Promise<void>
}
function buildPolicySQL(policy: PGPolicy): string {
const name = ident(policy.name)
const target = `${ident(policy.schema)}.${ident(policy.table)}`
const permissiveness = policy.action === 'RESTRICTIVE' ? 'AS RESTRICTIVE' : ''
const command = policy.command === 'ALL' ? '' : `FOR ${policy.command}`
const roles = policy.roles?.length ? `TO ${policy.roles.map(ident).join(', ')}` : ''
const using = policy.definition ? `USING (${policy.definition})` : ''
const withCheck = policy.check ? `WITH CHECK (${policy.check})` : ''
const drop = `DROP POLICY IF EXISTS ${name} ON ${target}`
const create = [
`CREATE POLICY ${name}`,
`ON ${target}`,
permissiveness,
command,
roles,
using,
withCheck,
]
.filter(Boolean)
.join(' ')
return `${drop}; ${create}`
}
async function tryExec(sandbox: Executor, sql: string, label: string): Promise<void> {
try {
await sandbox.execSql(sql)
} catch (err) {
console.warn(`[rls-sandbox] skipped ${label}:`, getErrorMessage(err) ?? err)
}
}
// Retry items until no further progress can be made — handles ordering
// dependencies (e.g. table A references type B that hasn't been created yet).
// Each pass attempts every pending item; survivors carry forward. When a full
// pass makes zero progress, surviving items are reported as unresolved.
async function runUntilFixpoint<T>(
items: T[],
attempt: (item: T) => Promise<void>,
onUnresolved: (item: T, error: unknown) => void
): Promise<void> {
let pending = items.slice()
while (pending.length > 0) {
const failed: Array<{ item: T; error: unknown }> = []
for (const item of pending) {
try {
await attempt(item)
} catch (error) {
failed.push({ item, error })
}
}
if (failed.length === pending.length) {
for (const { item, error } of failed) onUnresolved(item, error)
break
}
pending = failed.map((f) => f.item)
}
}
async function applyDDLWithRetries(sandbox: Executor, ddlStatements: string[]): Promise<void> {
await runUntilFixpoint(
ddlStatements,
(ddl) => sandbox.execSql(ddl),
(ddl, error) =>
console.warn(
`[rls-sandbox] skipped DDL: ${ddl.slice(0, 80).replace(/\s+/g, ' ')} — ${getErrorMessage(error) ?? String(error)}`
)
)
}
export async function applySchema(
sandbox: Executor,
{
schemas,
typeDefinitions,
entityDefinitions,
functionDefinitions,
policies,
rlsStatuses,
customRoles,
}: DatabaseSchemaDDL
): Promise<void> {
// Reset each user schema so re-syncs pick up renames/drops/column changes and
// CREATE statements don't collide with the previous run's objects.
for (const schema of schemas) {
const schemaId = ident(schema)
await tryExec(sandbox, `DROP SCHEMA IF EXISTS ${schemaId} CASCADE`, `drop schema ${schema}`)
await tryExec(sandbox, `CREATE SCHEMA ${schemaId}`, `create schema ${schema}`)
await tryExec(
sandbox,
`GRANT USAGE ON SCHEMA ${schemaId} TO anon, authenticated, service_role`,
`grant schema ${schema}`
)
}
if (customRoles.length > 0) {
const checks = customRoles
.map(
({ name }) =>
`IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${literal(name)}) THEN CREATE ROLE ${ident(name)} NOLOGIN; END IF;`
)
.join('\n')
await tryExec(sandbox, `DO $$ BEGIN\n${checks}\nEND $$`, 'custom roles')
}
await applyDDLWithRetries(sandbox, typeDefinitions)
await applyDDLWithRetries(sandbox, entityDefinitions)
for (const schema of [...new Set(rlsStatuses.map((t) => t.schema))]) {
await tryExec(
sandbox,
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} TO anon, authenticated, service_role`,
`grant tables in schema ${schema}`
)
}
for (const { schema, table, rls_enabled, rls_forced } of rlsStatuses) {
const actions: string[] = []
if (rls_enabled) actions.push('ENABLE ROW LEVEL SECURITY')
if (rls_forced) actions.push('FORCE ROW LEVEL SECURITY')
if (actions.length === 0) continue
await tryExec(
sandbox,
`ALTER TABLE ${ident(schema)}.${ident(table)} ${actions.join(', ')}`,
`RLS on ${schema}.${table}`
)
}
// Disable check_function_bodies so functions referencing not-yet-created objects don't abort.
// Postgres resolves policy→function references at query time, not at CREATE POLICY time.
await tryExec(sandbox, `SET check_function_bodies = off`, 'set check_function_bodies')
for (const fn of functionDefinitions) {
await tryExec(sandbox, fn, `function ${fn.slice(0, 60).replace(/\s+/g, ' ')}`)
}
await tryExec(sandbox, `RESET check_function_bodies`, 'reset check_function_bodies')
for (const policy of policies) {
await tryExec(
sandbox,
buildPolicySQL(policy),
`policy ${policy.schema}.${policy.table} "${policy.name}"`
)
}
}
function serializeValue(val: unknown): string {
if (val === null || val === undefined) return 'NULL'
if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE'
if (typeof val === 'number') return String(val)
if (val instanceof Date) return `'${val.toISOString()}'`
if (Array.isArray(val)) return `ARRAY[${val.map(serializeValue).join(', ')}]`
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb`
return `'${String(val).replace(/'/g, "''")}'`
}
function buildInsertSQL(schema: string, table: string, rows: Record<string, unknown>[]): string {
if (rows.length === 0) throw new Error(`buildInsertSQL requires at least one row`)
const columns = Object.keys(rows[0])
const colList = columns.map((c) => ident(c)).join(', ')
const valuesList = rows
.map((row) => `(${columns.map((c) => serializeValue(row[c])).join(', ')})`)
.join(',\n ')
return `INSERT INTO ${ident(schema)}.${ident(table)} (${colList}) VALUES\n ${valuesList};`
}
export async function applySeed(sandbox: Executor, tables: TableSeedData[]): Promise<void> {
// Disable FK triggers so we can delete and re-insert in any order.
// Requires superuser (ALTER ROLE postgres SUPERUSER in SANDBOX_SETUP_STATEMENTS).
// Falls back gracefully if the privilege is not available.
let triggersDisabled = false
try {
await sandbox.execSql(`SET session_replication_role = replica`)
triggersDisabled = true
} catch {
// postgres not yet a superuser in this PGlite build — proceed without it
}
try {
// Always clear before inserting so re-seed reflects the latest data.
for (const { schema, table } of tables) {
try {
await sandbox.execSql(`DELETE FROM ${ident(schema)}.${ident(table)}`)
} catch {
// table may not exist yet — ignore
}
}
// Retry loop handles any remaining FK ordering constraints.
await runUntilFixpoint(
tables.filter((t) => t.rows.length > 0),
(entry) => sandbox.execSql(buildInsertSQL(entry.schema, entry.table, entry.rows)),
(entry) =>
console.warn(`[rls-sandbox] seed skipped ${entry.schema}.${entry.table}: unresolved FK`)
)
} finally {
if (triggersDisabled) {
await sandbox.execSql(`SET session_replication_role = DEFAULT`)
}
}
}
@@ -25,7 +25,6 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_PG_DELTA_DIFF: 'supabase-ui-pg-delta-diff',
UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
UI_PREVIEW_JIT_DB_ACCESS: 'supabase-ui-jit-db-access',
UI_PREVIEW_RLS_TESTER: 'supabase-ui-rls-tester',
UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE: 'supabase-ui-sql-editor-manual-save',
UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace',
@@ -103,8 +102,6 @@ export const LOCAL_STORAGE_KEYS = {
PROJECT_SECURITY_DISMISSED_AT: (ref: string) => `project-security-dismissed-at-${ref}`,
RLS_TESTER_BANNER_DISMISSED: (ref: string) => `rls-tester-banner-dismissed-${ref}`,
// Observability banner dismissed
OBSERVABILITY_BANNER_DISMISSED: (ref: string) => `observability-banner-dismissed-${ref}`,
ORGANIZATION_MARKETPLACE_BANNER_DISMISSED: (orgSlug: string, managedBy: string) =>
-14
View File
@@ -2817,7 +2817,6 @@ export type AiAssistantSource =
| 'log_explorer'
| 'error_code'
| 'advisor_signal_detail'
| 'rls_tester'
/**
* User copied an AI prompt to clipboard instead of using the built-in assistant.
@@ -3546,18 +3545,6 @@ export interface HeaderLocalVersionPopoverOpenedEvent {
groups: Partial<TelemetryGroups>
}
/**
* User ran a query in the RLS tester feature preview.
*
* @group Events
* @source studio
*/
export interface RlsTesterRunQueryClickedEvent {
action: 'rls_tester_run_query_clicked'
properties: { type: 'raw' | 'inferred' }
groups: Partial<TelemetryGroups>
}
/**
* @hidden
*/
@@ -3759,4 +3746,3 @@ export type TelemetryEvent =
| HeaderUserDropdownOpenedEvent
| HeaderLocalDropdownOpenedEvent
| HeaderLocalVersionPopoverOpenedEvent
| RlsTesterRunQueryClickedEvent
+2 -16
View File
@@ -921,12 +921,6 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.6)
'@electric-sql/pglite':
specifier: 0.4.5
version: 0.4.5
'@electric-sql/pglite-tools':
specifier: ^0.3.4
version: 0.3.5(@electric-sql/pglite@0.4.5)
'@graphiql/react':
specifier: ^0.37.3
version: 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.13.14)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(immer@10.1.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
@@ -3578,11 +3572,6 @@ packages:
'@effect-ts/system@0.57.5':
resolution: {integrity: sha512-/crHGujo0xnuHIYNc1VgP0HGJGFSoSqq88JFXe6FmFyXPpWt8Xu39LyLg7rchsxfXFeEdA9CrIZvLV5eswXV5g==}
'@electric-sql/pglite-tools@0.3.5':
resolution: {integrity: sha512-4him0RnIyqrSqk0zzeBJKJ++VVFk6bFCwKSVV7DP3T8fOQgRSVZShlrHfAChLG2uA/T4JdQ8b/15CxBn+E34TQ==}
peerDependencies:
'@electric-sql/pglite': 0.4.5
'@electric-sql/pglite@0.4.5':
resolution: {integrity: sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==}
@@ -19004,11 +18993,8 @@ snapshots:
'@effect-ts/system@0.57.5': {}
'@electric-sql/pglite-tools@0.3.5(@electric-sql/pglite@0.4.5)':
dependencies:
'@electric-sql/pglite': 0.4.5
'@electric-sql/pglite@0.4.5': {}
'@electric-sql/pglite@0.4.5':
optional: true
'@emnapi/core@1.10.0':
dependencies: