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
+79
View File
@@ -559,3 +559,82 @@ Keine Änderungen ermittelbar.
- DEVLOG.md | 22 ++++++++++++++++++++++
---
## 2026-05-23 20:33 21:07 (34m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- fbc04bc agent-07 phase 2: fix test isolation + CSV import UI
### Geänderte Dateien
- DEVLOG.md | 12 ++
- backend/app/services/user_import_service.py | 6 +-
- backend/tests/conftest.py | 10 +-
- backend/tests/test_personnel_number.py | 27 ++--
- frontend/src/pages/UsersPage.tsx | 186 +++++++++++++++++++++++++++-
---
## 2026-05-23 21:07 21:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 12 ++
- backend/app/services/user_import_service.py | 6 +-
- backend/tests/conftest.py | 10 +-
- backend/tests/test_personnel_number.py | 27 ++--
- frontend/src/pages/UsersPage.tsx | 186 +++++++++++++++++++++++++++-
---
## 2026-05-23 21:08 21:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 12 ++
- backend/app/services/user_import_service.py | 6 +-
- backend/tests/conftest.py | 10 +-
- backend/tests/test_personnel_number.py | 27 ++--
- frontend/src/pages/UsersPage.tsx | 186 +++++++++++++++++++++++++++-
---
## 2026-05-23 21:09 21:16 (7m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 8e5e76d add deployment updater scripts
### Geänderte Dateien
- scripts/check_migrations.sh | 344 +++++++++++++++
- update.sh | 1007 ++++++++++++++++++++-----------------------
---
## 2026-05-23 21:23 21:31 (8m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- 3dfcff3 update.sh: switch backend sync to git pull instead of rsync
### Geänderte Dateien
- update.sh | 53 +++++++++++++++++++++++------------------------------
---
## 2026-05-23 21:47 21:48 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- update.sh | 53 +++++++++++++++++++++++------------------------------
---
+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
+4
View File
@@ -34,6 +34,10 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection: Connection) -> None:
# Ensure Alembic itself is never blocked by RLS policies.
# SET LOCAL is transaction-scoped; context.begin_transaction() opens one.
from sqlalchemy import text as sa_text
connection.execute(sa_text("SET LOCAL app.bypass_rls = 'on'"))
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
@@ -0,0 +1,208 @@
"""Add PostgreSQL Row Level Security (RLS) for tenant isolation
Revision ID: 0024
Revises: 0023
Create Date: 2026-05-23
"""
from alembic import op
revision = "0024"
down_revision = "0023"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
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}",
]
# ---------------------------------------------------------------------------
# Tables covered by RLS
# ---------------------------------------------------------------------------
# Tables with a 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",
]
# Tables where the row references users (which belong to a company)
USER_JOIN_TABLES = [
"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 tenant column) RLS not applied
def upgrade() -> None:
conn = op.get_bind()
# --- 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))
# --- 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))
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 ---
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 ---
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)))
+4
View File
@@ -43,6 +43,10 @@ async def db_session():
async def client(db_session: AsyncSession):
async def override_get_db():
try:
# Tests use a shared session without a real transaction context per
# request. Set bypass_rls = 'on' so that all test queries succeed
# regardless of whether app.company_id is set.
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'"))
yield db_session
await db_session.commit()
except Exception: