"""Row-Level Security for plugin-owned tables (SPEC §11.1 + privacy hardening). Per-user isolation for ``plg_*`` tables is enforced by Postgres, not by remembering a ``WHERE user_id = ?`` in every query. Each user-data table gets RLS enabled + forced, with a policy comparing ``user_id`` to the transaction-local GUC ``app.current_user_id``. Plugin code reaches these tables only through ``sdk.session(user_id)`` (see :mod:`app.plugins.db`), which drops to the least-privilege :data:`PLUGIN_ROLE` and binds that GUC — so even a query that forgets to filter cannot see or write another user's rows. Plugin migrations call :func:`enable_rls` right after creating a user-data table. """ from __future__ import annotations # NOLOGIN, non-superuser, NOBYPASSRLS role plugin sessions SET LOCAL ROLE into # (created by core migration 0014). RLS is skipped for superusers / table owners / # BYPASSRLS roles, so plugin access must run as this restricted role to be enforced. PLUGIN_ROLE = "webmail_plugin" _USER_CTX = "current_setting('app.current_user_id', true)::uuid" def rls_ddl(table: str, *, user_column: str = "user_id") -> list[str]: """SQL statements that lock ``table`` to the current user (SPEC §11.1). Pure/testable: returns the DDL rather than executing it. ``FORCE`` makes even the table owner subject to the policy; ``WITH CHECK`` blocks inserting/updating rows for a different user.""" policy = f"{table}_user_isolation" return [ f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY", f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY", f"CREATE POLICY {policy} ON {table} " f"USING ({user_column} = {_USER_CTX}) " f"WITH CHECK ({user_column} = {_USER_CTX})", f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO {PLUGIN_ROLE}", ] def enable_rls(table: str, *, user_column: str = "user_id") -> None: """Apply :func:`rls_ddl` via Alembic ``op`` — call from a plugin migration.""" from alembic import op for statement in rls_ddl(table, user_column=user_column): op.execute(statement)