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:
@@ -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}'")
|
||||
|
||||
Reference in New Issue
Block a user