mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
-200
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
-192
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
-265
@@ -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[]
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
Generated
+2
-16
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user