"""Row-Level Security enforcement for plugin tables (SPEC §11.1 + privacy). Proves that per-user isolation is enforced by Postgres — not by the query — so a plugin that forgets ``WHERE user_id`` still cannot read or write another user's rows. Requires the ``webmail_plugin`` role (migration 0014) to be applied. """ from __future__ import annotations import uuid import pytest from sqlalchemy import text from app.db import session_scope from app.plugins.db import plugin_session from app.plugins.rls import rls_ddl from app.plugins.sdk import sdk TABLE = "plg_rlsdemo_note" async def _create_demo_table() -> None: async with session_scope() as s: await s.execute(text(f"DROP TABLE IF EXISTS {TABLE}")) await s.execute( text( f"CREATE TABLE {TABLE} (" "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " "user_id uuid NOT NULL, body text)" ) ) for stmt in rls_ddl(TABLE): await s.execute(text(stmt)) async def test_rls_enforces_per_user_isolation() -> None: user_a, user_b = uuid.uuid4(), uuid.uuid4() await _create_demo_table() try: # Seed one row per user as the privileged role (superuser bypasses RLS). async with session_scope() as s: await s.execute( text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'a-note')"), {"u": str(user_a)}, ) b_id = ( await s.execute( text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'b-note') RETURNING id"), {"u": str(user_b)}, ) ).scalar_one() # Scoped to user A: the query has NO user filter, yet only A's row is visible. async with plugin_session(user_a) as s: assert (await s.execute(text("SELECT current_user"))).scalar_one() == "webmail_plugin" visible = (await s.execute(text(f"SELECT user_id FROM {TABLE}"))).scalars().all() assert set(visible) == {user_a} # IDOR blocked: fetching B's row by its exact id returns nothing. got = ( await s.execute(text(f"SELECT id FROM {TABLE} WHERE id = :i"), {"i": str(b_id)}) ).first() assert got is None # WITH CHECK: A cannot insert a row owned by B (error aborts the txn). with pytest.raises(Exception, match="row-level security"): async with plugin_session(user_a) as s: await s.execute( text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'forged')"), {"u": str(user_b)}, ) # A can write its own rows via the SDK session; B's data stays invisible. async with sdk("demo").session(user_a) as s: await s.execute( text(f"INSERT INTO {TABLE}(user_id, body) VALUES (:u, 'a-note-2')"), {"u": str(user_a)}, ) async with plugin_session(user_a) as s: count = (await s.execute(text(f"SELECT count(*) FROM {TABLE}"))).scalar_one() assert count == 2 # both of A's rows, none of B's finally: async with session_scope() as s: await s.execute(text(f"DROP TABLE IF EXISTS {TABLE}"))