fix: router db.refresh() nach commit bricht RLS-Kontext

SET LOCAL Werte (bypass_rls, company_id) sind transaktions-gebunden.
Nach db.commit() ist der Kontext weg – ein nachfolgendes db.refresh()
läuft in einer neuen Transaktion ohne RLS-Kontext und liefert 0 Rows.

Da expire_on_commit=False gesetzt ist, sind alle Instanz-Attribute
nach dem Commit bereits im Speicher vorhanden. Die expliziten
db.refresh()-Aufrufe nach db.commit() in allen Routers sind daher
redundant und wurden entfernt.

test_rls.py: 6 neue Tests beweisen DB-seitige Mandanten-Isolation.
conftest.py: _apply_rls() wendet RLS-Policies auf Test-DB an.
migrations/0024: korrigiert auf op.execute(text()) API.
migrations/env.py: SET LOCAL außerhalb Transaktion entfernt.

Ergebnis: 8 failed (pre-existing), 126 passed – identisch zur Baseline vor RLS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:34:48 +02:00
parent 6d4b8a9f17
commit dd3e069466
12 changed files with 305 additions and 202 deletions
@@ -5,204 +5,87 @@ Revises: 0023
Create Date: 2026-05-23
"""
from alembic import op
from sqlalchemy import text
revision = "0024"
down_revision = "0023"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# Helper
# RLS expression helpers
# ---------------------------------------------------------------------------
def _rls_bypass_expr() -> str:
"""USING-expression that always allows when bypass is set."""
return "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
def _company_id_expr(col: str = "company_id") -> str:
"""USING-expression that matches the session company_id."""
return (
f"{col} = NULLIF(current_setting('app.company_id', true), '')::uuid"
)
def _using(col: str = "company_id") -> str:
return f"({_rls_bypass_expr()} OR {_company_id_expr(col)})"
def _with_check(col: str = "company_id") -> str:
return f"({_rls_bypass_expr()} OR {_company_id_expr(col)})"
def _user_join_expr() -> str:
"""For tables with user_id: restrict via JOIN to users.company_id."""
return (
f"({_rls_bypass_expr()} OR "
f"user_id IN ("
f"SELECT id FROM users WHERE company_id = "
f"NULLIF(current_setting('app.company_id', true), '')::uuid"
f"))"
)
def _enable_rls(table: str) -> str:
return f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY"
def _force_rls(table: str) -> str:
return f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY"
def _disable_rls(table: str) -> str:
return f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY"
def _no_force_rls(table: str) -> str:
return f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY"
def _create_policies_company_col(table: str, col: str = "company_id") -> list[str]:
"""SELECT/INSERT/UPDATE/DELETE policies for tables that have a direct company_id column."""
using = _using(col)
with_check = _with_check(col)
return [
f"CREATE POLICY rls_{table}_select ON {table} FOR SELECT USING {using}",
f"CREATE POLICY rls_{table}_insert ON {table} FOR INSERT WITH CHECK {with_check}",
f"CREATE POLICY rls_{table}_update ON {table} FOR UPDATE USING {using} WITH CHECK {with_check}",
f"CREATE POLICY rls_{table}_delete ON {table} FOR DELETE USING {using}",
]
def _create_policies_user_join(table: str) -> list[str]:
"""Policies for tables that reference users (no direct company_id)."""
using = _user_join_expr()
return [
f"CREATE POLICY rls_{table}_select ON {table} FOR SELECT USING {using}",
f"CREATE POLICY rls_{table}_insert ON {table} FOR INSERT WITH CHECK {using}",
f"CREATE POLICY rls_{table}_update ON {table} FOR UPDATE USING {using} WITH CHECK {using}",
f"CREATE POLICY rls_{table}_delete ON {table} FOR DELETE USING {using}",
]
def _drop_policies(table: str) -> list[str]:
return [
f"DROP POLICY IF EXISTS rls_{table}_select ON {table}",
f"DROP POLICY IF EXISTS rls_{table}_insert ON {table}",
f"DROP POLICY IF EXISTS rls_{table}_update ON {table}",
f"DROP POLICY IF EXISTS rls_{table}_delete ON {table}",
]
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
_IID = "id = NULLIF(current_setting('app.company_id', true), '')::uuid"
def _using_cid(): return f"({_BYPASS} OR {_CID})"
def _using_iid(): return f"({_BYPASS} OR {_IID})"
def _using_join(): return (
f"({_BYPASS} OR user_id IN ("
f"SELECT id FROM users WHERE {_CID}))"
)
# ---------------------------------------------------------------------------
# Tables covered by RLS
# Tables
# ---------------------------------------------------------------------------
# Tables with a direct company_id column
# Direct company_id column
COMPANY_COL_TABLES = [
"absence_types",
"audit_logs",
"caldav_company_configs",
"departments",
"kiosk_devices",
"ldap_configs",
"overtime_balances",
"smtp_configs",
"users",
"work_schedules",
"absence_types", "audit_logs", "caldav_company_configs", "departments",
"kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
"users", "work_schedules",
]
# Tables where the row references users (which belong to a company)
# Linked via user_id → users.company_id
USER_JOIN_TABLES = [
"absences",
"caldav_user_configs",
"password_resets",
"sessions",
"time_entries",
"vacation_balances",
"absences", "caldav_user_configs", "password_resets",
"sessions", "time_entries", "vacation_balances",
]
# The companies table itself restrict by id
COMPANIES_TABLE = "companies"
# public_holidays is global no RLS
# public_holidays is global (no tenant column) RLS not applied
# ---------------------------------------------------------------------------
# upgrade / downgrade
# ---------------------------------------------------------------------------
def _exec(sql: str) -> None:
op.execute(text(sql))
def _enable(table: str, using: str) -> None:
_exec(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
_exec(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
for cmd in ("SELECT", "INSERT", "UPDATE", "DELETE"):
_exec(f"DROP POLICY IF EXISTS rls_{table}_{cmd.lower()} ON {table}")
_exec(f"CREATE POLICY rls_{table}_select ON {table} FOR SELECT USING {using}")
_exec(f"CREATE POLICY rls_{table}_insert ON {table} FOR INSERT WITH CHECK {using}")
_exec(f"CREATE POLICY rls_{table}_update ON {table} FOR UPDATE USING {using} WITH CHECK {using}")
_exec(f"CREATE POLICY rls_{table}_delete ON {table} FOR DELETE USING {using}")
def _disable(table: str) -> None:
for cmd in ("select", "insert", "update", "delete"):
_exec(f"DROP POLICY IF EXISTS rls_{table}_{cmd} ON {table}")
_exec(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY")
_exec(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY")
def upgrade() -> None:
conn = op.get_bind()
# companies: restrict by id (companies IS the tenant root)
_enable("companies", _using_iid())
# --- companies table ---
conn.execute(__import__("sqlalchemy").text(_enable_rls(COMPANIES_TABLE)))
conn.execute(__import__("sqlalchemy").text(_force_rls(COMPANIES_TABLE)))
companies_using = (
f"({_rls_bypass_expr()} OR "
f"id = NULLIF(current_setting('app.company_id', true), '')::uuid)"
)
conn.execute(__import__("sqlalchemy").text(
f"DROP POLICY IF EXISTS rls_companies_select ON companies"
))
conn.execute(__import__("sqlalchemy").text(
f"DROP POLICY IF EXISTS rls_companies_insert ON companies"
))
conn.execute(__import__("sqlalchemy").text(
f"DROP POLICY IF EXISTS rls_companies_update ON companies"
))
conn.execute(__import__("sqlalchemy").text(
f"DROP POLICY IF EXISTS rls_companies_delete ON companies"
))
conn.execute(__import__("sqlalchemy").text(
f"CREATE POLICY rls_companies_select ON companies FOR SELECT USING {companies_using}"
))
conn.execute(__import__("sqlalchemy").text(
f"CREATE POLICY rls_companies_insert ON companies FOR INSERT WITH CHECK {companies_using}"
))
conn.execute(__import__("sqlalchemy").text(
f"CREATE POLICY rls_companies_update ON companies FOR UPDATE "
f"USING {companies_using} WITH CHECK {companies_using}"
))
conn.execute(__import__("sqlalchemy").text(
f"CREATE POLICY rls_companies_delete ON companies FOR DELETE USING {companies_using}"
))
# --- tables with direct company_id ---
for table in COMPANY_COL_TABLES:
conn.execute(__import__("sqlalchemy").text(_enable_rls(table)))
conn.execute(__import__("sqlalchemy").text(_force_rls(table)))
for drop_stmt in _drop_policies(table):
conn.execute(__import__("sqlalchemy").text(drop_stmt))
for create_stmt in _create_policies_company_col(table):
conn.execute(__import__("sqlalchemy").text(create_stmt))
_enable(table, _using_cid())
# --- tables with user_id join ---
for table in USER_JOIN_TABLES:
conn.execute(__import__("sqlalchemy").text(_enable_rls(table)))
conn.execute(__import__("sqlalchemy").text(_force_rls(table)))
for drop_stmt in _drop_policies(table):
conn.execute(__import__("sqlalchemy").text(drop_stmt))
for create_stmt in _create_policies_user_join(table):
conn.execute(__import__("sqlalchemy").text(create_stmt))
_enable(table, _using_join())
def downgrade() -> None:
conn = op.get_bind()
# --- companies ---
for stmt in _drop_policies(COMPANIES_TABLE):
conn.execute(__import__("sqlalchemy").text(stmt))
conn.execute(__import__("sqlalchemy").text(_no_force_rls(COMPANIES_TABLE)))
conn.execute(__import__("sqlalchemy").text(_disable_rls(COMPANIES_TABLE)))
# --- tables with direct company_id ---
_disable("companies")
for table in COMPANY_COL_TABLES:
for stmt in _drop_policies(table):
conn.execute(__import__("sqlalchemy").text(stmt))
conn.execute(__import__("sqlalchemy").text(_no_force_rls(table)))
conn.execute(__import__("sqlalchemy").text(_disable_rls(table)))
# --- tables with user_id join ---
_disable(table)
for table in USER_JOIN_TABLES:
for stmt in _drop_policies(table):
conn.execute(__import__("sqlalchemy").text(stmt))
conn.execute(__import__("sqlalchemy").text(_no_force_rls(table)))
conn.execute(__import__("sqlalchemy").text(_disable_rls(table)))
_disable(table)