diff --git a/DEVLOG.md b/DEVLOG.md index b060a37..6b9bc4b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +++++++++++++++++++++++------------------------------ + +--- diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f186b5e..f72bdee 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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: diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 6fb5fc2..15cee9c 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -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 diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 7ef9498..354dcda 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -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() diff --git a/backend/migrations/versions/0024_row_level_security.py b/backend/migrations/versions/0024_row_level_security.py new file mode 100644 index 0000000..51854c0 --- /dev/null +++ b/backend/migrations/versions/0024_row_level_security.py @@ -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))) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2da6804..cedf07f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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: