feat(01-foundation-01): Alembic migrations with RLS and tenant isolation tests

- alembic.ini + migrations/env.py: async SQLAlchemy migration setup using asyncpg
- migrations/versions/001_initial_schema.py: creates tenants, agents, channel_connections, portal_users
  - ENABLE + FORCE ROW LEVEL SECURITY on agents and channel_connections
  - RLS policy: tenant_id = current_setting('app.current_tenant', TRUE)::uuid
  - konstruct_app role created with SELECT/INSERT/UPDATE/DELETE on all tables
- packages/shared/shared/rls.py: idempotent configure_rls_hook, UUID-sanitized SET LOCAL
- tests/conftest.py: test_db_name (session-scoped), db_engine + db_session as konstruct_app
- tests/unit/test_normalize.py: 11 tests for KonstructMessage Slack normalization (CHAN-01)
- tests/unit/test_tenant_resolution.py: 7 tests for workspace_id → tenant resolution (TNNT-02)
- tests/unit/test_redis_namespacing.py: 15 tests for Redis key namespace isolation (TNNT-03)
- tests/integration/test_tenant_isolation.py: 7 tests proving RLS tenant isolation (TNNT-01)
  - tenant_b cannot see tenant_a's agents or channel_connections
  - FORCE ROW LEVEL SECURITY verified via pg_class.relforcerowsecurity
This commit is contained in:
2026-03-23 09:57:29 -06:00
parent 5714acf741
commit 47e78627fd
13 changed files with 1364 additions and 4 deletions

View File

@@ -9,7 +9,7 @@ How it works:
SET LOCAL app.current_tenant = '<tenant_id>'
into the current transaction.
4. PostgreSQL evaluates this setting in every RLS policy via:
current_setting('app.current_tenant')::uuid
current_setting('app.current_tenant', TRUE)::uuid
CRITICAL: The application MUST connect as `konstruct_app` (not postgres
superuser). Superuser connections bypass RLS entirely — isolation tests
@@ -17,6 +17,12 @@ would pass trivially but provide zero real protection.
IMPORTANT: SET LOCAL is transaction-scoped. The tenant context resets
automatically when each transaction ends — no manual cleanup required.
NOTE ON SQL INJECTION: PostgreSQL's SET LOCAL does not support parameterized
placeholders. We protect against injection by passing the tenant_id value
through uuid.UUID() — any non-UUID string raises ValueError before it reaches
the database. The resulting string is always in canonical UUID format:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx with only hex chars and hyphens.
"""
from __future__ import annotations
@@ -33,6 +39,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine
# ---------------------------------------------------------------------------
current_tenant_id: ContextVar[UUID | None] = ContextVar("current_tenant_id", default=None)
# Track engines that have already had the hook configured (by sync engine id)
_configured_engines: set[int] = set()
def configure_rls_hook(engine: AsyncEngine) -> None:
"""
@@ -46,6 +55,12 @@ def configure_rls_hook(engine: AsyncEngine) -> None:
configure_rls_hook(engine)
"""
# Idempotent — skip if already configured for this engine
engine_id = id(engine.sync_engine)
if engine_id in _configured_engines:
return
_configured_engines.add(engine_id)
@event.listens_for(engine.sync_engine, "before_cursor_execute")
def _set_rls_tenant(
conn: Any,
@@ -58,10 +73,17 @@ def configure_rls_hook(engine: AsyncEngine) -> None:
"""
Inject SET LOCAL app.current_tenant before every statement.
Uses parameterized query to prevent SQL injection.
PostgreSQL SET LOCAL does not support parameterized placeholders.
We prevent SQL injection by validating the tenant_id value through
uuid.UUID() — any non-UUID string raises ValueError before it reaches
the database. The resulting string contains only hex characters and
hyphens in canonical UUID format.
SET LOCAL is transaction-scoped and resets on commit/rollback.
"""
tenant_id = current_tenant_id.get()
if tenant_id is not None:
# Parameterized to prevent SQL injection — never use f-string here
cursor.execute("SET LOCAL app.current_tenant = %s", (str(tenant_id),))
# Sanitize: round-trip through UUID raises ValueError on invalid input.
# UUID.__str__ always produces canonical xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
safe_id = str(UUID(str(tenant_id)))
cursor.execute(f"SET LOCAL app.current_tenant = '{safe_id}'")