""" Verify the database's security posture against a live Postgres. Run this after applying db/schema.sql -- against a local instance, or against the real Supabase project once it exists: python db/verify_rls.py # uses SUPABASE_DB_URL python db/verify_rls.py --db-url postgresql://… # explicit WHY THIS EXISTS --------------- db/schema.sql was once believed correct because it parsed cleanly. It was not: the commodity_code_map seed violated a foreign key on an empty database, which aborted the script partway and left only 6 of 12 tables created and RLS applied to NONE of them. Parsing proves syntax; only executing proves behaviour. So this checks the properties that actually matter, and fails loudly: 1. Every expected table exists. 2. Every table has rowsecurity = true. 3. No table is FORCE'd (which would lock the backend out of its own data unless its role happens to hold BYPASSRLS). 4. The PostgREST roles (anon, authenticated) cannot read the sensitive tables -- the actual attack this defends against, since the anon key is public and ships to every browser. Exit code is 0 only if all checks pass, so it works in CI. """ from __future__ import annotations import argparse import os import sys EXPECTED_TABLES = [ "kabupaten", "commodity", "surplus_deficit", "weather_forecast", "historical_prices", "commodity_code_map", "policy_docs", "price_history", "forecasts", "subscriber", "wa_usage_daily", "payment_order", ] # Tables whose rows describe an identifiable person. A read here by a public # role is a breach, not a nuisance. SENSITIVE = ["subscriber", "wa_usage_daily", "payment_order"] GREEN, RED, YELLOW, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[0m" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--db-url", default=os.getenv("SUPABASE_DB_URL", "")) args = ap.parse_args() if not args.db_url: print("No database URL. Pass --db-url or set SUPABASE_DB_URL.", file=sys.stderr) return 2 try: from sqlalchemy import create_engine, text except ImportError: print("sqlalchemy not installed. Run: pip install -r requirements.txt", file=sys.stderr) return 2 engine = create_engine(args.db_url) failures: list[str] = [] notes: list[str] = [] with engine.connect() as conn: # --- 1 + 2 + 3: tables exist, RLS on, FORCE off --------------------- rows = conn.execute(text(""" SELECT c.relname, c.relrowsecurity AS rls, c.relforcerowsecurity AS forced FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r' """)).fetchall() present = {r[0]: (r[1], r[2]) for r in rows} print("table exists RLS FORCE") print("-" * 46) for tbl in EXPECTED_TABLES: if tbl not in present: print(f"{tbl:<22}{RED}MISSING{RESET}") failures.append(f"{tbl}: table does not exist") continue rls, forced = present[tbl] rls_s = f"{GREEN}on{RESET}" if rls else f"{RED}OFF{RESET}" force_s = f"{RED}ON{RESET}" if forced else f"{GREEN}off{RESET}" print(f"{tbl:<22}{GREEN}yes{RESET} {rls_s} {force_s}") if not rls: failures.append(f"{tbl}: RLS is OFF -- readable with the public anon key") if forced: failures.append( f"{tbl}: FORCE ROW LEVEL SECURITY is ON -- the backend will " f"see zero rows unless its role holds BYPASSRLS" ) extra = sorted(set(present) - set(EXPECTED_TABLES)) for tbl in extra: rls, _ = present[tbl] if not rls: failures.append(f"{tbl}: unexpected table with RLS OFF") print(f"{tbl:<22}{YELLOW}extra{RESET} {RED}OFF{RESET}") # --- 4: can the public roles actually read? ------------------------- print() for role in ("anon", "authenticated"): exists = conn.execute( text("SELECT 1 FROM pg_roles WHERE rolname = :r"), {"r": role} ).fetchone() if not exists: notes.append(f"role '{role}' does not exist here (fine outside Supabase)") continue for tbl in SENSITIVE: if tbl not in present: continue # Roll back whatever this does; we only want the verdict. trans = conn.begin_nested() try: conn.execute(text(f'SET LOCAL ROLE "{role}"')) n = conn.execute(text(f"SELECT count(*) FROM {tbl}")).scalar() # Reaching here means the read was permitted. Zero rows is # still a pass: RLS filtered everything out. verdict = f"{GREEN}denied (0 rows){RESET}" if n == 0 else \ f"{RED}READ {n} ROWS{RESET}" if n: failures.append(f"{role} can read {n} row(s) from {tbl}") except Exception: verdict = f"{GREEN}denied (no privilege){RESET}" finally: trans.rollback() print(f"{role:<15} -> {tbl:<18} {verdict}") print() for n in notes: print(f"note: {n}") if failures: print(f"\n{RED}FAILED{RESET} — {len(failures)} problem(s):") for f in failures: print(f" - {f}") return 1 print(f"{GREEN}PASS{RESET} — all tables present, RLS on, FORCE off, " f"public roles cannot read sensitive tables.") return 0 if __name__ == "__main__": sys.exit(main())