Spaces:
Running
Running
Enable Row-Level Security on public tables in Supabase at startup
Browse files- MANUAL_TESTING.md +3 -0
- backend/app/db.py +29 -0
- backend/app/main.py +10 -1
MANUAL_TESTING.md
CHANGED
|
@@ -164,6 +164,9 @@ Checklist before giving the URL to real users:
|
|
| 164 |
- [ ] `CCR_COOKIE_SECURE=1` (HTTPS only)
|
| 165 |
- [ ] `CCR_DATA_DIR` on a persistent volume (default /tmp is ephemeral)
|
| 166 |
- [ ] `CCR_ANON_TTL_HOURS=24` (default in the image)
|
|
|
|
|
|
|
|
|
|
| 167 |
- [ ] Smoke test: sections 2, 4, 6, 7 above
|
| 168 |
|
| 169 |
## 12. Admin page (/admin)
|
|
|
|
| 164 |
- [ ] `CCR_COOKIE_SECURE=1` (HTTPS only)
|
| 165 |
- [ ] `CCR_DATA_DIR` on a persistent volume (default /tmp is ephemeral)
|
| 166 |
- [ ] `CCR_ANON_TTL_HOURS=24` (default in the image)
|
| 167 |
+
- [ ] Supabase: RLS shows enabled on all public tables (the app enables it
|
| 168 |
+
automatically at startup; the Supabase dashboard linter should report
|
| 169 |
+
zero `rls_disabled_in_public` issues)
|
| 170 |
- [ ] Smoke test: sections 2, 4, 6, 7 above
|
| 171 |
|
| 172 |
## 12. Admin page (/admin)
|
backend/app/db.py
CHANGED
|
@@ -92,6 +92,35 @@ def _default_literal(value, dialect_name: str) -> str:
|
|
| 92 |
return "'" + str(value).replace("'", "''") + "'"
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def auto_migrate_sqlite(target_engine, metadata) -> list[str]:
|
| 96 |
"""Add ORM columns missing from existing tables (additive only).
|
| 97 |
|
|
|
|
| 92 |
return "'" + str(value).replace("'", "''") + "'"
|
| 93 |
|
| 94 |
|
| 95 |
+
def lock_down_public_schema(target_engine, metadata) -> list[str]:
|
| 96 |
+
"""Enable Row-Level Security on every app table (Postgres only).
|
| 97 |
+
|
| 98 |
+
Supabase auto-exposes the public schema through its REST API (PostgREST):
|
| 99 |
+
any table WITHOUT RLS is readable AND writable by anyone holding the
|
| 100 |
+
project URL + anon key - which for this app would mean users (password
|
| 101 |
+
hashes), corpora, jobs, everything. This app never uses that REST API:
|
| 102 |
+
the backend talks to Postgres directly as the table owner, and owners
|
| 103 |
+
bypass RLS. So RLS-with-no-policies cleanly closes the public door
|
| 104 |
+
without touching app behavior (Supabase linter: rls_disabled_in_public).
|
| 105 |
+
|
| 106 |
+
Runs at every startup AFTER create_all, so tables added later are locked
|
| 107 |
+
down the day they appear, not when someone remembers. Idempotent.
|
| 108 |
+
"""
|
| 109 |
+
import logging
|
| 110 |
+
|
| 111 |
+
from sqlalchemy import text
|
| 112 |
+
|
| 113 |
+
if target_engine.dialect.name != "postgresql":
|
| 114 |
+
return [] # SQLite has no exposed REST surface (and no RLS)
|
| 115 |
+
locked = []
|
| 116 |
+
with target_engine.begin() as conn:
|
| 117 |
+
for table in metadata.sorted_tables:
|
| 118 |
+
conn.execute(text(f'ALTER TABLE "{table.name}" ENABLE ROW LEVEL SECURITY'))
|
| 119 |
+
locked.append(table.name)
|
| 120 |
+
logging.getLogger("ccr.db").info("RLS enabled on: %s", ", ".join(locked))
|
| 121 |
+
return locked
|
| 122 |
+
|
| 123 |
+
|
| 124 |
def auto_migrate_sqlite(target_engine, metadata) -> list[str]:
|
| 125 |
"""Add ORM columns missing from existing tables (additive only).
|
| 126 |
|
backend/app/main.py
CHANGED
|
@@ -30,7 +30,15 @@ from . import registry
|
|
| 30 |
from .ccr import FAKE_MODEL_NAME
|
| 31 |
from .construct_files import parse_construct_file
|
| 32 |
from .construct_lib import sync_library
|
| 33 |
-
from .db import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
from .ingest import IngestError, load_corpus, max_rows as corpus_max_rows, suggest_text_column
|
| 35 |
from .models import AdminAudit, Construct, Corpus, Invite, Job, Project, RoleAssignment, User
|
| 36 |
from .reproducibility import (
|
|
@@ -104,6 +112,7 @@ async def lifespan(_: FastAPI):
|
|
| 104 |
"""Create tables and sync the construct library (YAML source of truth) at startup."""
|
| 105 |
Base.metadata.create_all(engine)
|
| 106 |
auto_migrate_sqlite(engine, Base.metadata) # additive column adds for existing dev DBs
|
|
|
|
| 107 |
registry.list_models() # fail fast on an invalid models.yaml
|
| 108 |
db = SessionLocal()
|
| 109 |
try:
|
|
|
|
| 30 |
from .ccr import FAKE_MODEL_NAME
|
| 31 |
from .construct_files import parse_construct_file
|
| 32 |
from .construct_lib import sync_library
|
| 33 |
+
from .db import (
|
| 34 |
+
DATA_DIR,
|
| 35 |
+
Base,
|
| 36 |
+
SessionLocal,
|
| 37 |
+
auto_migrate_sqlite,
|
| 38 |
+
engine,
|
| 39 |
+
get_db,
|
| 40 |
+
lock_down_public_schema,
|
| 41 |
+
)
|
| 42 |
from .ingest import IngestError, load_corpus, max_rows as corpus_max_rows, suggest_text_column
|
| 43 |
from .models import AdminAudit, Construct, Corpus, Invite, Job, Project, RoleAssignment, User
|
| 44 |
from .reproducibility import (
|
|
|
|
| 112 |
"""Create tables and sync the construct library (YAML source of truth) at startup."""
|
| 113 |
Base.metadata.create_all(engine)
|
| 114 |
auto_migrate_sqlite(engine, Base.metadata) # additive column adds for existing dev DBs
|
| 115 |
+
lock_down_public_schema(engine, Base.metadata) # Supabase: RLS on, REST surface closed
|
| 116 |
registry.list_models() # fail fast on an invalid models.yaml
|
| 117 |
db = SessionLocal()
|
| 118 |
try:
|