"""RLS-enforced database access for plugins (SPEC §11.1 + privacy hardening). ``plugin_session(user_id)`` yields a normal SQLAlchemy session whose transaction has been dropped to the least-privilege :data:`~app.plugins.rls.PLUGIN_ROLE` and bound to the per-user RLS context. Both are set with transaction-local scope (``SET LOCAL`` / ``set_config(..., is_local=true)``), so they auto-revert on commit/rollback and cannot leak across pooled connections. Because the role is non-superuser / non-owner / NOBYPASSRLS, the row-level policy from :mod:`app.plugins.rls` is actually enforced: a plugin query that forgets to filter ``user_id`` still returns only the current user's rows, and it cannot write rows for anyone else (policy ``WITH CHECK``). """ from __future__ import annotations import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_sessionmaker from app.plugins.rls import PLUGIN_ROLE @asynccontextmanager async def plugin_session(user_id: uuid.UUID) -> AsyncIterator[AsyncSession]: """A transactional session scoped to ``user_id`` under RLS (SPEC §11.1).""" async with get_sessionmaker()() as session: try: # Bind the RLS user context first (while still the privileged role), then # drop into the restricted role for the rest of the transaction. await session.execute( text("SELECT set_config('app.current_user_id', :uid, true)"), {"uid": str(user_id)}, ) await session.execute(text(f"SET LOCAL ROLE {PLUGIN_ROLE}")) yield session await session.commit() except Exception: await session.rollback() raise