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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user