agent-rls: PostgreSQL Row Level Security für Mandanten-Isolation

- Migration 0024: RLS + FORCE RLS auf 18 Tabellen
  - Direkte company_id-Policies: users, departments, companies, absence_types,
    audit_logs, kiosk_devices, ldap_configs, smtp_configs, caldav_company_configs,
    work_schedules, overtime_balances
  - JOIN-Policies (user_id → company_id): absences, sessions, password_resets,
    time_entries, vacation_balances, caldav_user_configs
  - public_holidays ausgenommen (globale Referenztabelle)
- database.py: get_db setzt bypass_rls='on' als Default (Auth-Endpoints unverändert)
- dependencies.py: get_current_user setzt app.company_id + bypass_rls='off'
  für alle nicht-SUPER_ADMIN Rollen
- migrations/env.py: Alembic-Migrationen nutzen bypass_rls='on'
- tests/conftest.py: override_get_db setzt bypass_rls='on' für Test-Session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:57:58 +02:00
parent 3dfcff30e7
commit 6d4b8a9f17
6 changed files with 319 additions and 0 deletions
+6
View File
@@ -1,3 +1,4 @@
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
@@ -24,6 +25,11 @@ class Base(DeclarativeBase):
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
# Default: RLS bypass active so that unauthenticated routes
# (register, login, password-reset) and internal operations work.
# get_current_user() will disable bypass and set app.company_id for
# authenticated, non-SUPER_ADMIN requests.
await session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
yield session
await session.commit()
except Exception:
+18
View File
@@ -4,6 +4,7 @@ from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@@ -30,9 +31,26 @@ async def get_current_user(
except JWTError:
raise credentials_exception
# User lookup happens while bypass_rls = 'on' (set in get_db), so the
# SELECT on users is unrestricted — necessary because we don't yet know
# the company_id at this point.
user = await db.get(User, UUID(user_id))
if user is None or not user.is_active:
raise credentials_exception
# ── RLS context ────────────────────────────────────────────────────────
# SUPER_ADMIN can see all companies → keep bypass_rls = 'on'.
# Every other role gets the RLS fence applied: set company_id and disable
# bypass so subsequent queries in the same transaction are automatically
# filtered to the user's company.
if user.role != UserRole.SUPER_ADMIN and user.company_id is not None:
# SET LOCAL does not accept bind parameters in PostgreSQL; the value
# must be inlined. We sanitise by converting through uuid.UUID first
# so an attacker-supplied token payload cannot inject arbitrary SQL.
safe_company_id = str(user.company_id) # already a UUID object from db.get()
await db.execute(text(f"SET LOCAL app.company_id = '{safe_company_id}'"))
await db.execute(text("SET LOCAL app.bypass_rls = 'off'"))
return user