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:
@@ -559,3 +559,82 @@ Keine Änderungen ermittelbar.
|
|||||||
- DEVLOG.md | 22 ++++++++++++++++++++++
|
- 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 +++++++++++++++++++++++------------------------------
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -24,6 +25,11 @@ class Base(DeclarativeBase):
|
|||||||
async def get_db() -> AsyncSession:
|
async def get_db() -> AsyncSession:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
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
|
yield session
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError
|
from jose import JWTError
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -30,9 +31,26 @@ async def get_current_user(
|
|||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
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))
|
user = await db.get(User, UUID(user_id))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise credentials_exception
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection: Connection) -> 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)
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
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)))
|
||||||
@@ -43,6 +43,10 @@ async def db_session():
|
|||||||
async def client(db_session: AsyncSession):
|
async def client(db_session: AsyncSession):
|
||||||
async def override_get_db():
|
async def override_get_db():
|
||||||
try:
|
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
|
yield db_session
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in New Issue
Block a user