fix(security/dsgvo): RLS für hours_payouts nachgezogen (Mandantentrennung)
hours_payouts war die einzige firmenbezogene Tabelle ohne Row-Level-Security (Migration 0030 hatte keinen RLS-Block). Die Endpunkte filtern zwar applikativ nach company_id (kein akutes Leck), aber das DB-seitige Schutznetz – das im ganzen System (FORCE RLS, 0024/0034) die Mandantentrennung garantiert – fehlte. Migration 0039 aktiviert ENABLE+FORCE RLS + company_id-Policies (analog 0024). conftest.py-RLS-Replik + neuer Cross-Tenant-Test test_rls_hours_payouts_tenant_isolation. Verifiziert auf 137+164 (rls=True, force=True, 4 Policies). 191/191 Tests grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
"""RLS für hours_payouts nachziehen (Mandantentrennung / DSGVO)
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-06-23
|
||||
|
||||
hours_payouts (Migration 0030) hatte als einzige firmenbezogene Tabelle keine
|
||||
Row-Level-Security. Die Endpunkte filtern zwar applikativ nach company_id, aber
|
||||
das DB-seitige Schutznetz (FORCE RLS, analog 0024) fehlte. Hier nachgezogen.
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_BYPASS = "COALESCE(current_setting('app.bypass_rls', true), 'off') = 'on'"
|
||||
_CID = "company_id = NULLIF(current_setting('app.company_id', true), '')::uuid"
|
||||
_USING = f"({_BYPASS} OR {_CID})"
|
||||
|
||||
|
||||
def _exec(sql: str) -> None:
|
||||
op.execute(text(sql))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_exec("ALTER TABLE hours_payouts ENABLE ROW LEVEL SECURITY")
|
||||
_exec("ALTER TABLE hours_payouts FORCE ROW LEVEL SECURITY")
|
||||
for cmd in ("select", "insert", "update", "delete"):
|
||||
_exec(f"DROP POLICY IF EXISTS rls_hours_payouts_{cmd} ON hours_payouts")
|
||||
_exec(f"CREATE POLICY rls_hours_payouts_select ON hours_payouts FOR SELECT USING {_USING}")
|
||||
_exec(f"CREATE POLICY rls_hours_payouts_insert ON hours_payouts FOR INSERT WITH CHECK {_USING}")
|
||||
_exec(f"CREATE POLICY rls_hours_payouts_update ON hours_payouts FOR UPDATE USING {_USING} WITH CHECK {_USING}")
|
||||
_exec(f"CREATE POLICY rls_hours_payouts_delete ON hours_payouts FOR DELETE USING {_USING}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for cmd in ("select", "insert", "update", "delete"):
|
||||
_exec(f"DROP POLICY IF EXISTS rls_hours_payouts_{cmd} ON hours_payouts")
|
||||
_exec("ALTER TABLE hours_payouts NO FORCE ROW LEVEL SECURITY")
|
||||
_exec("ALTER TABLE hours_payouts DISABLE ROW LEVEL SECURITY")
|
||||
@@ -34,7 +34,7 @@ def _rls_using_join(): return (
|
||||
|
||||
_COMPANY_COL_TABLES = [
|
||||
"absence_comments", "absence_types", "audit_logs", "caldav_company_configs", "departments",
|
||||
"kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
|
||||
"hours_payouts", "kiosk_devices", "ldap_configs", "overtime_balances", "smtp_configs",
|
||||
"special_assignments", "users", "work_schedules",
|
||||
]
|
||||
_USER_JOIN_TABLES = [
|
||||
|
||||
@@ -188,3 +188,31 @@ async def test_rls_insert_blocked_for_wrong_tenant(db_session):
|
||||
f"Unerwarteter Fehler (kein RLS-Fehler): {e}"
|
||||
finally:
|
||||
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
|
||||
|
||||
async def test_rls_hours_payouts_tenant_isolation(client: AsyncClient, db_session):
|
||||
"""hours_payouts: Mandant B darf Auszahlungen von Mandant A nicht sehen (DB-Ebene)."""
|
||||
a = await register_company(client, "PAYOUT-A")
|
||||
b = await register_company(client, "PAYOUT-B")
|
||||
cid_a, uid_a = str(a["user"]["company_id"]), str(a["user"]["id"])
|
||||
cid_b = str(b["user"]["company_id"])
|
||||
|
||||
# Auszahlung für Mandant A direkt einfügen (unter Bypass)
|
||||
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
await db_session.execute(text(
|
||||
"INSERT INTO hours_payouts (id, company_id, user_id, hours, created_by) "
|
||||
"VALUES (gen_random_uuid(), :cid, :uid, 8.0, :uid)"
|
||||
), {"cid": cid_a, "uid": uid_a})
|
||||
await db_session.commit()
|
||||
|
||||
async def payouts_as(cid: str) -> set[str]:
|
||||
await db_session.execute(text("SET LOCAL app.bypass_rls = 'off'"))
|
||||
await db_session.execute(text(f"SET LOCAL app.company_id = '{cid}'"))
|
||||
res = await db_session.execute(text("SELECT company_id FROM hours_payouts"))
|
||||
seen = {str(r[0]) for r in res.fetchall()}
|
||||
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||
return seen
|
||||
|
||||
assert cid_a in await payouts_as(cid_a), "Mandant A sieht eigene Auszahlung nicht"
|
||||
assert cid_a not in await payouts_as(cid_b), \
|
||||
"RLS BLOCKIERT NICHT: Mandant B sieht Auszahlung von Mandant A!"
|
||||
|
||||
Reference in New Issue
Block a user