mirror of
https://github.com/supabase/supabase.git
synced 2026-06-15 08:05:21 +08:00
## Context Resolves FE-3221 Heavily inspired by what @filipecabaco has done previously here: https://github.com/supabase/supabase/pull/45360 This PR explores the use of pglite to set up a sandbox for RLS testing, which will pave the way for testing mutation based queries so to ensure no disruption to the actual database. Sandbox can be set up within the RLS tester panel as such: <img width="500" alt="image" src="https://github.com/user-attachments/assets/0cfdf8e4-dd99-4dee-ac00-39a32b375c07" /> Which the sandbox will mimic the project's database to the bare minimum required - entities from the `public` schema are copied over (types, tables, functions, policies) - `auth` schema is pseudo setup with `SANDBOX_SETUP_STATEMENTS` - Enough to support role impersonation + querying tables with references to the auth schema (e.g users table) - data is seeded up to 100 rows for each table - More info RE limitations in the last section below Once sandbox is ready, you'll see this UI where you can either leave the sandbox, or re-sync the sandbox from the actual database <img width="500" alt="image" src="https://github.com/user-attachments/assets/d07ce55f-5bc8-4722-8ce9-898b9b458f9b" /> Changes are currently feature flagged, so won't be available publicly just yet until things are ironed out and ready ## To test - [ ] Verify that setting up sandbox works - [ ] Verify that you can query your sandbox, and queries do not touch the actual database (can verify that we're not sending HTTP requests to the /query endpoint) - [ ] Verify correctness of RLS tester as well, should match correctness with testing against actual DB - [ ] Verify that re-syncing sandbox picks up changes - Can test by updating your policies that will affect the output of your select query - e.g SELECT for `authenticated`, change from just `true` to `false` - [ ] RLS tester should work as per normal (against actual DB) with the feature flag off with no additional overhead Let me know of any edge cases you might run into while testing ## Known quirks that will be addressed subsequently Leaving these for now just to not bloat this PR further - Pglite schema needs to be re-synced if updating RLS policies while testing, to ensure that pglite gets the updated policies. Will think about how to make this more seamless - Sandbox has its own limitations, will need to add a dialog to inform users how the sandbox works and what limitations to note of - e.g only the auth schema is mimicked - so policies that reference storage helpers won't work (although i think auth is probably the main use case and the rest might be niche) - We can slowly expand tho where required - Eventually we'll also move forward with figuring out testing mutation queries with this sandbox <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * RLS tester gains an isolated Postgres sandbox with schema/seed import, start/refresh/exit controls, and pre-populated auth data. * Sandbox management UI with setup, loading, active, and error states; refresh and destroy actions. * **Bug Fixes** * Role impersonation now keeps the PostgREST role set to anon while the tester sheet is open. * **Chores** * Content Security Policy updated to allow sandbox/connectivity endpoints. * **Style** * Minor sheet styling adjustment (top border). <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45839) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
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'
|
|
|
|
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 } from './useTestQueryRLS'
|
|
import type { Policy } from '@/components/interfaces/Auth/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 } 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 sandboxEnabled = useFlag('rlsTesterSandbox')
|
|
|
|
const [open, setOpen] = useState(false)
|
|
const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon')
|
|
|
|
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 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,
|
|
onExecuteSQL: ({ result, isAutoLimit }: { result: Object[] | null; isAutoLimit: boolean }) => {
|
|
setResults(result)
|
|
setAutoLimit(isAutoLimit)
|
|
},
|
|
onParseQuery: setParseQueryResults,
|
|
}
|
|
|
|
const onRunQuery = async () => {
|
|
if (format === 'lib') {
|
|
if (!inferredSQL) return
|
|
await testQuery({ value: acceptUntrustedSql(inferredSQL), ...executionCallbacks })
|
|
track('rls_tester_run_query_clicked', { type: 'inferred' })
|
|
} else {
|
|
await testQuery({ value, ...executionCallbacks })
|
|
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(() => {
|
|
if (open) {
|
|
setRole({ type: 'postgrest', role: 'anon' })
|
|
} else {
|
|
// Flip back to service role
|
|
setRole(undefined)
|
|
}
|
|
}, [open, setRole])
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button type="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 see?</span>
|
|
<FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER} />
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
See what data a user is allowed to read 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 />
|
|
|
|
{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 && (
|
|
<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>
|
|
)
|
|
)}
|
|
|
|
{results === null ? (
|
|
!parseQueryError && !parseClientCodeError && !executeSqlError && <RLSTesterEmptyState />
|
|
) : !!parseQueryResults ? (
|
|
<RLSTesterResults
|
|
results={results}
|
|
parseQueryResults={parseQueryResults}
|
|
autoLimit={autoLimit}
|
|
handleSelectEditPolicy={handleSelectEditPolicy}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
<SheetFooter className="sm:justify-between">
|
|
<Button asChild type="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 type="default" disabled={isLoading} onClick={() => setOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
loading={isInferring || isLoading}
|
|
disabled={format === 'lib' && !inferredSQL}
|
|
onClick={onRunQuery}
|
|
>
|
|
Run query
|
|
</Button>
|
|
</div>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|