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

@@ -0,0 +1,140 @@
"""
Unit tests for Redis key namespacing.
Tests TNNT-03: Per-tenant Redis namespace isolation for cache and session state.
Every key function must:
1. Prepend {tenant_id}: to every key
2. Never be callable without a tenant_id argument
3. Produce predictable, stable key formats
"""
from __future__ import annotations
import inspect
import pytest
from shared.redis_keys import (
engaged_thread_key,
idempotency_key,
rate_limit_key,
session_key,
)
class TestRedisKeyFormats:
"""Tests for correct key format from all key constructor functions."""
def test_rate_limit_key_format(self) -> None:
"""rate_limit_key must return '{tenant_id}:ratelimit:{channel}'."""
key = rate_limit_key("tenant-a", "slack")
assert key == "tenant-a:ratelimit:slack"
def test_rate_limit_key_different_channel(self) -> None:
"""rate_limit_key must work for any channel type string."""
key = rate_limit_key("tenant-a", "whatsapp")
assert key == "tenant-a:ratelimit:whatsapp"
def test_idempotency_key_format(self) -> None:
"""idempotency_key must return '{tenant_id}:dedup:{message_id}'."""
key = idempotency_key("tenant-a", "msg-123")
assert key == "tenant-a:dedup:msg-123"
def test_session_key_format(self) -> None:
"""session_key must return '{tenant_id}:session:{thread_id}'."""
key = session_key("tenant-a", "thread-456")
assert key == "tenant-a:session:thread-456"
def test_engaged_thread_key_format(self) -> None:
"""engaged_thread_key must return '{tenant_id}:engaged:{thread_id}'."""
key = engaged_thread_key("tenant-a", "T12345")
assert key == "tenant-a:engaged:T12345"
class TestTenantIsolation:
"""Tests that all key functions produce distinct namespaces per tenant."""
def test_rate_limit_keys_are_tenant_scoped(self) -> None:
"""Two tenants with the same channel must produce different keys."""
key_a = rate_limit_key("tenant-a", "slack")
key_b = rate_limit_key("tenant-b", "slack")
assert key_a != key_b
assert key_a.startswith("tenant-a:")
assert key_b.startswith("tenant-b:")
def test_idempotency_keys_are_tenant_scoped(self) -> None:
"""Two tenants with the same message_id must produce different keys."""
key_a = idempotency_key("tenant-a", "msg-999")
key_b = idempotency_key("tenant-b", "msg-999")
assert key_a != key_b
assert key_a.startswith("tenant-a:")
assert key_b.startswith("tenant-b:")
def test_session_keys_are_tenant_scoped(self) -> None:
"""Two tenants with the same thread_id must produce different keys."""
key_a = session_key("tenant-a", "thread-1")
key_b = session_key("tenant-b", "thread-1")
assert key_a != key_b
def test_engaged_thread_keys_are_tenant_scoped(self) -> None:
"""Two tenants with same thread must produce different engaged keys."""
key_a = engaged_thread_key("tenant-a", "thread-1")
key_b = engaged_thread_key("tenant-b", "thread-1")
assert key_a != key_b
def test_all_keys_include_tenant_id_prefix(self) -> None:
"""Every key function must produce a key starting with the tenant_id."""
tenant_id = "my-tenant-uuid"
keys = [
rate_limit_key(tenant_id, "slack"),
idempotency_key(tenant_id, "msg-1"),
session_key(tenant_id, "thread-1"),
engaged_thread_key(tenant_id, "thread-1"),
]
for key in keys:
assert key.startswith(f"{tenant_id}:"), (
f"Key {key!r} does not start with tenant_id prefix '{tenant_id}:'"
)
class TestNoBarKeysIsPossible:
"""Tests that prove no key function can be called without tenant_id."""
def test_rate_limit_key_requires_tenant_id(self) -> None:
"""rate_limit_key signature requires tenant_id as first argument."""
sig = inspect.signature(rate_limit_key)
params = list(sig.parameters.keys())
assert params[0] == "tenant_id"
def test_idempotency_key_requires_tenant_id(self) -> None:
"""idempotency_key signature requires tenant_id as first argument."""
sig = inspect.signature(idempotency_key)
params = list(sig.parameters.keys())
assert params[0] == "tenant_id"
def test_session_key_requires_tenant_id(self) -> None:
"""session_key signature requires tenant_id as first argument."""
sig = inspect.signature(session_key)
params = list(sig.parameters.keys())
assert params[0] == "tenant_id"
def test_engaged_thread_key_requires_tenant_id(self) -> None:
"""engaged_thread_key signature requires tenant_id as first argument."""
sig = inspect.signature(engaged_thread_key)
params = list(sig.parameters.keys())
assert params[0] == "tenant_id"
def test_calling_without_tenant_id_raises_type_error(self) -> None:
"""Calling any key function with zero args must raise TypeError."""
with pytest.raises(TypeError):
rate_limit_key() # type: ignore[call-arg]
with pytest.raises(TypeError):
idempotency_key() # type: ignore[call-arg]
with pytest.raises(TypeError):
session_key() # type: ignore[call-arg]
with pytest.raises(TypeError):
engaged_thread_key() # type: ignore[call-arg]