- 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
104 lines
3.3 KiB
Python
104 lines
3.3 KiB
Python
"""
|
|
Alembic migration environment — async SQLAlchemy configuration.
|
|
|
|
Uses asyncpg driver with asyncio migration pattern required for SQLAlchemy 2.0.
|
|
Runs migrations as the postgres admin user (DATABASE_ADMIN_URL) so it can:
|
|
- CREATE ROLE konstruct_app
|
|
- ENABLE ROW LEVEL SECURITY
|
|
- FORCE ROW LEVEL SECURITY
|
|
- CREATE POLICY
|
|
|
|
Application code always uses DATABASE_URL (konstruct_app role).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from logging.config import fileConfig
|
|
|
|
from alembic import context
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Make sure packages/shared is importable when running `alembic upgrade head`
|
|
# from the repo root.
|
|
# ---------------------------------------------------------------------------
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from shared.models.tenant import Base # noqa: E402 # type: ignore[import]
|
|
|
|
# Import auth model to register it with Base.metadata
|
|
import shared.models.auth # noqa: E402, F401 # type: ignore[import]
|
|
|
|
# this is the Alembic Config object, which provides
|
|
# access to the values within the .ini file in use.
|
|
config = context.config
|
|
|
|
# Interpret the config file for Python logging.
|
|
if config.config_file_name is not None:
|
|
fileConfig(config.config_file_name)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metadata for autogenerate support
|
|
# ---------------------------------------------------------------------------
|
|
target_metadata = Base.metadata
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Use DATABASE_ADMIN_URL if set (for CI / production migrations),
|
|
# otherwise fall back to alembic.ini sqlalchemy.url.
|
|
# ---------------------------------------------------------------------------
|
|
database_url = os.environ.get("DATABASE_ADMIN_URL") or config.get_main_option("sqlalchemy.url")
|
|
|
|
|
|
def run_migrations_offline() -> None:
|
|
"""
|
|
Run migrations in 'offline' mode.
|
|
|
|
This configures the context with just a URL and not an Engine.
|
|
Useful for generating SQL scripts without a live DB connection.
|
|
"""
|
|
context.configure(
|
|
url=database_url,
|
|
target_metadata=target_metadata,
|
|
literal_binds=True,
|
|
dialect_opts={"paramstyle": "named"},
|
|
)
|
|
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
|
|
|
|
async def run_async_migrations() -> None:
|
|
"""
|
|
Create an async engine and run migrations within an async context.
|
|
|
|
This is the required pattern for SQLAlchemy 2.0 + asyncpg.
|
|
"""
|
|
connectable = create_async_engine(database_url, echo=False)
|
|
|
|
async with connectable.connect() as connection:
|
|
await connection.run_sync(do_run_migrations)
|
|
|
|
await connectable.dispose()
|
|
|
|
|
|
def do_run_migrations(connection: object) -> None:
|
|
"""Synchronous migration runner — called within async context."""
|
|
context.configure(connection=connection, target_metadata=target_metadata) # type: ignore[arg-type]
|
|
|
|
with context.begin_transaction():
|
|
context.run_migrations()
|
|
|
|
|
|
def run_migrations_online() -> None:
|
|
"""Run migrations in 'online' mode with a live DB connection."""
|
|
asyncio.run(run_async_migrations())
|
|
|
|
|
|
if context.is_offline_mode():
|
|
run_migrations_offline()
|
|
else:
|
|
run_migrations_online()
|