From 3034c6c55a4c2ca7676581d44ae372108154dd5a Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 23 Jun 2026 14:06:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(security/dsgvo):=20RLS=20f=C3=BCr=20hours?= =?UTF-8?q?=5Fpayouts=20nachgezogen=20(Mandantentrennung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../versions/0039_rls_hours_payouts.py | 43 +++++++++++++++++++ backend/tests/conftest.py | 2 +- backend/tests/test_rls.py | 28 ++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/versions/0039_rls_hours_payouts.py diff --git a/backend/migrations/versions/0039_rls_hours_payouts.py b/backend/migrations/versions/0039_rls_hours_payouts.py new file mode 100644 index 0000000..b499154 --- /dev/null +++ b/backend/migrations/versions/0039_rls_hours_payouts.py @@ -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") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1aba07a..fe58b19 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 = [ diff --git a/backend/tests/test_rls.py b/backend/tests/test_rls.py index e765c78..52becb3 100644 --- a/backend/tests/test_rls.py +++ b/backend/tests/test_rls.py @@ -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!"