Files
supabase/apps/studio/state/postgres-sandbox/sandbox.constants.ts
Joshen Lim 9dc3998fa0 RLS Tester sandbox environment (#45839)
## Context

Resolves FE-3221

Heavily inspired by what @filipecabaco has done previously here:
https://github.com/supabase/supabase/pull/45360

This PR explores the use of pglite to set up a sandbox for RLS testing,
which will pave the way for testing mutation based queries so to ensure
no disruption to the actual database. Sandbox can be set up within the
RLS tester panel as such:
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/0cfdf8e4-dd99-4dee-ac00-39a32b375c07"
/>

Which the sandbox will mimic the project's database to the bare minimum
required
- entities from the `public` schema are copied over (types, tables,
functions, policies)
- `auth` schema is pseudo setup with `SANDBOX_SETUP_STATEMENTS`
- Enough to support role impersonation + querying tables with references
to the auth schema (e.g users table)
- data is seeded up to 100 rows for each table
- More info RE limitations in the last section below

Once sandbox is ready, you'll see this UI where you can either leave the
sandbox, or re-sync the sandbox from the actual database
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/d07ce55f-5bc8-4722-8ce9-898b9b458f9b"
/>

Changes are currently feature flagged, so won't be available publicly
just yet until things are ironed out and ready

## To test
- [ ] Verify that setting up sandbox works
- [ ] Verify that you can query your sandbox, and queries do not touch
the actual database (can verify that we're not sending HTTP requests to
the /query endpoint)
- [ ] Verify correctness of RLS tester as well, should match correctness
with testing against actual DB
- [ ] Verify that re-syncing sandbox picks up changes
- Can test by updating your policies that will affect the output of your
select query
  - e.g SELECT for `authenticated`, change from just `true` to `false`
- [ ] RLS tester should work as per normal (against actual DB) with the
feature flag off with no additional overhead

Let me know of any edge cases you might run into while testing

## Known quirks that will be addressed subsequently
Leaving these for now just to not bloat this PR further
- Pglite schema needs to be re-synced if updating RLS policies while
testing, to ensure that pglite gets the updated policies. Will think
about how to make this more seamless
- Sandbox has its own limitations, will need to add a dialog to inform
users how the sandbox works and what limitations to note of
- e.g only the auth schema is mimicked - so policies that reference
storage helpers won't work (although i think auth is probably the main
use case and the rest might be niche)
  - We can slowly expand tho where required
- Eventually we'll also move forward with figuring out testing mutation
queries with this sandbox

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

* **New Features**
* RLS tester gains an isolated Postgres sandbox with schema/seed import,
start/refresh/exit controls, and pre-populated auth data.
* Sandbox management UI with setup, loading, active, and error states;
refresh and destroy actions.

* **Bug Fixes**
* Role impersonation now keeps the PostgREST role set to anon while the
tester sheet is open.

* **Chores**
* Content Security Policy updated to allow sandbox/connectivity
endpoints.

* **Style**
  * Minor sheet styling adjustment (top border).

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45839)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-18 16:22:12 +07:00

163 lines
6.3 KiB
TypeScript

// 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