From 2b68bcdd648b4a87bc6bfdb72d1d2f8ab8007c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20Caba=C3=A7o?= Date: Fri, 8 May 2026 11:40:45 +0100 Subject: [PATCH] improve overall seeding approach and fix some of the console noise --- .../Auth/RLSPlayground/RLSPlayground.tsx | 52 ++++---- .../rls-sandbox/project-schema-ddl-query.ts | 113 ++++++++---------- apps/studio/lib/rls-sandbox/apply-schema.ts | 51 ++++---- apps/studio/lib/rls-sandbox/pglite.worker.ts | 1 + apps/studio/lib/rls-sandbox/sandbox-core.ts | 85 ++++++++++++- 5 files changed, 190 insertions(+), 112 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/RLSPlayground/RLSPlayground.tsx b/apps/studio/components/interfaces/Auth/RLSPlayground/RLSPlayground.tsx index fc5829cce87..9d8f441b839 100644 --- a/apps/studio/components/interfaces/Auth/RLSPlayground/RLSPlayground.tsx +++ b/apps/studio/components/interfaces/Auth/RLSPlayground/RLSPlayground.tsx @@ -1,6 +1,6 @@ import { useMonaco } from '@monaco-editor/react' import { DatabaseZap, Download, Plus, RefreshCw, ShieldCheck, Sparkles } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, cn, Modal, TextArea_Shadcn_ } from 'ui' import { Admonition } from 'ui-patterns' @@ -28,12 +28,12 @@ import { useTableColumnsQuery } from '@/data/database/table-columns-query' import { useProjectSchemaDDLQuery } from '@/data/rls-sandbox/project-schema-ddl-query' import { useProjectSeedDataQuery } from '@/data/rls-sandbox/project-seed-data-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' import { getErrorMessage } from '@/lib/get-error-message' import type { TileConfig, TileResult } from '@/lib/rls-sandbox/sandbox-core' import { useSandbox } from '@/lib/rls-sandbox/SandboxProvider' const SEED_ROW_LIMIT = 100 -const DDL_SCHEMAS = ['public'] export interface LocalTile { id: string @@ -92,28 +92,12 @@ export function RLSPlayground() { onSuccess: ({ sql }) => setAiGeneratedSQL(sql), }) - const { data: schemaDDL, refetch: refetchSchema } = useProjectSchemaDDLQuery( - { projectRef: project?.ref, connectionString: project?.connectionString, schemas: DDL_SCHEMAS }, - { enabled: false } - ) - - const { refetch: refetchSeed } = useProjectSeedDataQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - tables: schemaDDL?.rlsStatuses ?? [], - rowLimit: SEED_ROW_LIMIT, - }, - { enabled: false } - ) - const { data: usersData } = useUsersInfiniteQuery( { projectRef: project?.ref, connectionString: project?.connectionString }, { enabled: !!project?.ref } ) const users = usersData?.pages.flatMap((p) => p.result) ?? [] - // ── Postgres IntelliSense ────────────────────────────────────────────────── const monaco = useMonaco() const pgInfoRef = useRef(null) @@ -134,6 +118,23 @@ export function RLSPlayground() { connectionString: project?.connectionString, }) + const ddlSchemas = useMemo(() => schemas?.map((s) => s.name) ?? [], [schemas]) + + const { data: schemaDDL, refetch: refetchSchema } = useProjectSchemaDDLQuery( + { projectRef: project?.ref, connectionString: project?.connectionString, schemas: ddlSchemas }, + { enabled: false } + ) + + const { refetch: refetchSeed } = useProjectSeedDataQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + tables: schemaDDL?.rlsStatuses ?? [], + rowLimit: SEED_ROW_LIMIT, + }, + { enabled: false } + ) + const isPgInfoReady = isKeywordsSuccess && isFunctionsSuccess && isSchemasSuccess && isTableColumnsSuccess if (isPgInfoReady) { @@ -161,8 +162,11 @@ export function RLSPlayground() { }, [isPgInfoReady, monaco]) // ────────────────────────────────────────────────────────────────────────── + const syncInProgress = useRef(false) + const syncSchema = useCallback(async () => { - if (!core) return + if (!core || syncInProgress.current) return + syncInProgress.current = true setSchemaSyncStatus('syncing') try { const { data } = await refetchSchema() @@ -173,9 +177,13 @@ export function RLSPlayground() { } catch (err) { setSchemaSyncStatus('error') toast.error(`Schema sync failed: ${getErrorMessage(err) ?? ''}`) + } finally { + syncInProgress.current = false } }, [core, refetchSchema]) + const onAutoSync = useStaticEffectEvent(syncSchema) + const seedFromProject = useCallback(async () => { if (!core) return setSeedStatus('syncing') @@ -298,10 +306,10 @@ export function RLSPlayground() { const isSandboxReady = sandboxStatus === 'ready' useEffect(() => { - if (isSandboxReady && schemaSyncStatus === 'idle') { - syncSchema() + if (isSandboxReady && ddlSchemas.length > 0 && schemaSyncStatus === 'idle') { + onAutoSync() } - }, [isSandboxReady, schemaSyncStatus, syncSchema]) + }, [isSandboxReady, ddlSchemas.length, schemaSyncStatus, onAutoSync]) return ( <> diff --git a/apps/studio/data/rls-sandbox/project-schema-ddl-query.ts b/apps/studio/data/rls-sandbox/project-schema-ddl-query.ts index 3da084fb9f5..52431215821 100644 --- a/apps/studio/data/rls-sandbox/project-schema-ddl-query.ts +++ b/apps/studio/data/rls-sandbox/project-schema-ddl-query.ts @@ -1,9 +1,10 @@ -import { getEntityDefinitionsSql } from '@supabase/pg-meta' +import pgMeta, { getEntityDefinitionsSql } from '@supabase/pg-meta' import type { PostgresPolicy } from '@supabase/postgres-meta' import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' -import { getDatabasePolicies } from '@/data/database-policies/database-policies-query' import { executeSql } from '@/data/sql/execute-sql-query' +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' import { quoteLiteral } from '@/lib/pg-format' import type { UseCustomQueryOptions } from '@/types' @@ -14,11 +15,10 @@ export interface RlsTableStatus { rls_forced: boolean } -export interface CustomRole { - name: string -} +export type CustomRole = z.infer export interface ProjectSchemaDDL { + schemas: string[] typeDefinitions: string[] entityDefinitions: string[] functionDefinitions: string[] @@ -27,7 +27,13 @@ export interface ProjectSchemaDDL { customRoles: CustomRole[] } -const SYSTEM_ROLES = [ +// 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', @@ -47,17 +53,12 @@ const SYSTEM_ROLES = [ 'pg_signal_backend', 'dashboard_user', 'pgbouncer', - 'authenticator', -] +]) -const CUSTOM_ROLES_SQL = ` - SELECT rolname AS name - FROM pg_roles - WHERE rolname NOT IN (${SYSTEM_ROLES.map(quoteLiteral).join(',')}) - AND rolname NOT LIKE 'pg_%' - AND rolname NOT LIKE 'supabase_%' - ORDER BY rolname -` +const pgMetaRolesList = pgMeta.roles.list() +const pgMetaFunctionsZod = pgMeta.functions.list().zod +const pgMetaPoliciesZod = pgMeta.policies.list().zod +const pgMetaTablesZod = pgMeta.tables.list().zod function toSqlList(schemas: string[]) { return schemas.map(quoteLiteral).join(', ') @@ -95,37 +96,6 @@ function getTypeDefinitionsSql(schemas: string[]) { ` } -function getFunctionDefinitionsSql(schemas: string[]) { - return ` - SELECT pg_get_functiondef(p.oid) AS definition - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - JOIN pg_language l ON l.oid = p.prolang - LEFT JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e' - WHERE n.nspname IN (${toSqlList(schemas)}) - AND p.prokind = 'f' - AND l.lanname IN ('sql', 'plpgsql') - AND p.prorettype <> 'pg_catalog.trigger'::regtype - AND d.objid IS NULL - ORDER BY n.nspname, p.proname - ` -} - -function getRlsStatusSql(schemas: string[]) { - return ` - SELECT - n.nspname AS schema, - c.relname AS table, - c.relrowsecurity AS rls_enabled, - c.relforcerowsecurity AS rls_forced - FROM pg_class c - JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE c.relkind = 'r' - AND n.nspname IN (${toSqlList(schemas)}) - ORDER BY n.nspname, c.relname - ` -} - type Variables = { projectRef?: string connectionString?: string | null @@ -136,7 +106,12 @@ async function getProjectSchemaDDL( { projectRef, connectionString, schemas }: Variables, signal?: AbortSignal ): Promise { - const entitySql = getEntityDefinitionsSql({ schemas }) + 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([ @@ -144,25 +119,23 @@ async function getProjectSchemaDDL( { projectRef, connectionString, sql: entitySql, queryKey: ['rls-sandbox-ddl'] }, signal ), - getDatabasePolicies({ projectRef, connectionString }, signal), executeSql( - { - projectRef, - connectionString, - sql: getRlsStatusSql(schemas), - queryKey: ['rls-sandbox-rls'], - }, + { projectRef, connectionString, sql: policiesSql, queryKey: ['rls-sandbox-policies'] }, signal ), executeSql( - { projectRef, connectionString, sql: CUSTOM_ROLES_SQL, queryKey: ['rls-sandbox-roles'] }, + { projectRef, connectionString, sql: tablesSql, queryKey: ['rls-sandbox-rls'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: pgMetaRolesList.sql, queryKey: ['rls-sandbox-roles'] }, signal ), executeSql( { projectRef, connectionString, - sql: getFunctionDefinitionsSql(schemas), + sql: functionsSql, queryKey: ['rls-sandbox-functions'], }, signal @@ -171,24 +144,36 @@ async function getProjectSchemaDDL( { projectRef, connectionString, - sql: getTypeDefinitionsSql(schemas), + sql: getTypeDefinitionsSql(userSchemas), queryKey: ['rls-sandbox-types'], }, signal ), ]) + const roles = (rolesResult.result as z.infer).filter( + (r) => !SYSTEM_ROLES.has(r.name) && !r.name.startsWith('pg_') && !r.name.startsWith('supabase_') + ) + + const functions = (functionsResult.result as z.infer).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: (functionsResult.result as { definition: string }[]).map( - (r) => r.definition - ), - policies: (policiesResult ?? []) as PostgresPolicy[], - rlsStatuses: rlsResult.result as RlsTableStatus[], - customRoles: rolesResult.result as CustomRole[], + functionDefinitions: functions.map((f) => f.complete_statement), + policies: policiesResult.result as z.infer as PostgresPolicy[], + rlsStatuses: (rlsResult.result as z.infer).map((t) => ({ + schema: t.schema, + table: t.name, + rls_enabled: t.rls_enabled, + rls_forced: t.rls_forced, + })), + customRoles: roles, } } diff --git a/apps/studio/lib/rls-sandbox/apply-schema.ts b/apps/studio/lib/rls-sandbox/apply-schema.ts index 8d5c4486e8a..2cdc044cba8 100644 --- a/apps/studio/lib/rls-sandbox/apply-schema.ts +++ b/apps/studio/lib/rls-sandbox/apply-schema.ts @@ -65,6 +65,7 @@ async function applyDDLWithRetries(sandbox: Executor, ddlStatements: string[]): export async function applySchema( sandbox: Executor, { + schemas, typeDefinitions, entityDefinitions, functionDefinitions, @@ -73,6 +74,17 @@ export async function applySchema( customRoles, }: ProjectSchemaDDL ): Promise { + for (const schema of schemas) { + if (schema === 'public') continue + const safe = schema.replace(/"/g, '""') + await tryExec(sandbox, `CREATE SCHEMA IF NOT EXISTS "${safe}"`, `schema ${schema}`) + await tryExec( + sandbox, + `GRANT USAGE ON SCHEMA "${safe}" TO anon, authenticated, service_role`, + `grant schema ${schema}` + ) + } + if (customRoles.length > 0) { const checks = customRoles .map(({ name }) => { @@ -87,28 +99,25 @@ export async function applySchema( await applyDDLWithRetries(sandbox, typeDefinitions) await applyDDLWithRetries(sandbox, entityDefinitions) - await tryExec( - sandbox, - `GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role`, - 'grant schema usage' - ) + for (const schema of [...new Set(rlsStatuses.map((t) => t.schema))]) { + const safe = schema.replace(/"/g, '""') + await tryExec( + sandbox, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA "${safe}" TO anon, authenticated, service_role`, + `grant tables in schema ${schema}` + ) + } - if (rlsStatuses.length > 0) { - const grants = rlsStatuses - .map( - (t) => - `GRANT SELECT, INSERT, UPDATE, DELETE ON "${t.schema}"."${t.table}" TO anon, authenticated, service_role` - ) - .join('; ') - await tryExec(sandbox, grants, 'grant roles on all tables') - - const rlsEnables = rlsStatuses - .filter((t) => t.rls_enabled) - .map((t) => `ALTER TABLE "${t.schema}"."${t.table}" ENABLE ROW LEVEL SECURITY`) - .join('; ') - if (rlsEnables) { - await tryExec(sandbox, rlsEnables, 'enable RLS on all tables') - } + 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 "${schema}"."${table}" ${actions.join(', ')}`, + `RLS on ${schema}.${table}` + ) } // Disable check_function_bodies so functions referencing not-yet-created objects don't abort. diff --git a/apps/studio/lib/rls-sandbox/pglite.worker.ts b/apps/studio/lib/rls-sandbox/pglite.worker.ts index ab08903f83d..638461cc3f3 100644 --- a/apps/studio/lib/rls-sandbox/pglite.worker.ts +++ b/apps/studio/lib/rls-sandbox/pglite.worker.ts @@ -8,6 +8,7 @@ worker({ return new PGlite({ dataDir: 'memory://', extensions: { pgcrypto, uuid_ossp }, + debug: 0, }) }, }) diff --git a/apps/studio/lib/rls-sandbox/sandbox-core.ts b/apps/studio/lib/rls-sandbox/sandbox-core.ts index 27549f9cb8c..8e1130a52ec 100644 --- a/apps/studio/lib/rls-sandbox/sandbox-core.ts +++ b/apps/studio/lib/rls-sandbox/sandbox-core.ts @@ -23,7 +23,6 @@ export interface SandboxCore { destroy(): Promise } -// All superuser-required setup runs as the PGlite bootstrap user. // ALTER ROLE postgres SUPERUSER succeeds because the bootstrap connection owns the cluster. // Each statement is individual so a single failure cannot abort the rest. const SANDBOX_SETUP_STATEMENTS = [ @@ -38,6 +37,18 @@ const SANDBOX_SETUP_STATEMENTS = [ 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`, @@ -56,10 +67,76 @@ const SANDBOX_SETUP_STATEMENTS = [ `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`, ] -// ── Singleton ──────────────────────────────────────────────────────────────── - let instance: SandboxCore | null = null let initPromise: Promise | null = null @@ -80,8 +157,6 @@ export function destroySandboxCore(): Promise { return current?.destroy() ?? Promise.resolve() } -// ── Boot ───────────────────────────────────────────────────────────────────── - async function boot(): Promise { const webWorker = new Worker(new URL('./pglite.worker.ts', import.meta.url), { type: 'module' }) const pg = await PGliteWorker.create(webWorker)