umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -34,14 +34,15 @@ from dataclasses import dataclass, field
class ActiveSession:
session_id: int
task: asyncio.Task
user_id: int
watchers: set[asyncio.Queue] = field(default_factory=set)
_active: dict[int, ActiveSession] = {}
def register(session_id: int, task: asyncio.Task) -> None:
_active[session_id] = ActiveSession(session_id=session_id, task=task)
def register(session_id: int, task: asyncio.Task, user_id: int) -> None:
_active[session_id] = ActiveSession(session_id=session_id, task=task, user_id=user_id)
def unregister(session_id: int) -> None:
@ -56,6 +57,22 @@ def all_ids() -> set[int]:
return set(_active.keys())
def count_total() -> int:
"""Anzahl aller laufenden Sitzungen auf diesem Prozess (E.4,
Umsetzungsauftrag Teil E: globale Obergrenze)."""
return len(_active)
def count_for_user(user_id: int) -> int:
"""Anzahl laufender Sitzungen eines Benutzers auf diesem Prozess (E.4:
Obergrenze je Benutzer). Bewusst prozesslokal wie die gesamte Registry
(siehe Moduldoc) -- bei einem einzelnen uvicorn-Worker (Standard-
Deployment) ist das gleichbedeutend mit "insgesamt", bei mehreren
Workern zaehlt es nur die auf DIESEM Worker laufenden Sitzungen dieses
Benutzers."""
return sum(1 for entry in _active.values() if entry.user_id == user_id)
def add_watcher(session_id: int) -> asyncio.Queue | None:
"""Meldet einen Beobachter fuer eine laufende Sitzung an. Gibt None
zurueck, wenn die Sitzung nicht (mehr) auf diesem Prozess laeuft --

View File

@ -15,6 +15,8 @@ import json
from datetime import datetime, timezone
from typing import Any
import asyncio
import aiosqlite
GENESIS_HASH = "0" * 64
@ -25,6 +27,22 @@ def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) ->
return hashlib.sha256(payload).hexdigest()
# E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Lesen von prev_hash und das
# nachfolgende INSERT muessen atomar sein. Ohne Schutz koennen zwei
# gleichzeitig laufende Aufrufe (z.B. zwei Sitzungen, die im selben Moment
# starten/enden) denselben prev_hash lesen und beide darauf aufbauen -- die
# Kette bricht dann dauerhaft, weil UPDATE/DELETE auf audit_log durch
# DB-Trigger verboten sind (0001_initial.sql). Zwei Schutzschichten:
# 1. Ein prozessweiter asyncio.Lock serialisiert alle Aufrufe innerhalb
# dieses (einzigen) Event-Loops -- ausreichend, solange die Anwendung in
# einem uvicorn-Worker mit einer globalen Verbindung laeuft (E.0/E.5).
# 2. BEGIN IMMEDIATE/COMMIT als zweite, von der Prozesstopologie
# unabhaengige Schutzschicht: falls doch einmal mehrere Verbindungen auf
# dieselbe Datei schreiben, sorgt die IMMEDIATE-Schreibsperre dafuer,
# dass SELECT und INSERT nicht auseinandergerissen werden.
_write_lock = asyncio.Lock()
async def write_audit_event(
conn: aiosqlite.Connection,
*,
@ -37,20 +55,33 @@ async def write_audit_event(
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
Lesen des prev_hash und das INSERT sind durch einen Lock plus eine
BEGIN-IMMEDIATE-Transaktion atomar (siehe E1 in Umsetzungsauftrag_Sonnet5.md).
"""
cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1")
row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH
async with _write_lock:
await conn.execute("BEGIN IMMEDIATE")
try:
cursor = await conn.execute(
"SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
)
row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
await conn.execute(
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
)
await conn.execute(
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
)
except BaseException:
await conn.execute("ROLLBACK")
raise
else:
await conn.execute("COMMIT")
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:

View File

@ -11,6 +11,8 @@ from __future__ import annotations
import time
from collections import defaultdict, deque
from app.config import settings
class SlidingWindowRateLimiter:
def __init__(self, max_events: int, window_seconds: int) -> None:
@ -29,6 +31,16 @@ class SlidingWindowRateLimiter:
return True
# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den
# Account-basierten Lockout in app/auth/routes.py.
login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60)
# E8 (Umsetzungsauftrag Teil E): Werte konfigurierbar (settings.login_rate_
# limit_*, siehe app/config.py) statt fest verdrahtet -- ergaenzt den
# Account-basierten Lockout in app/auth/routes.py. ACHTUNG: greift pro
# Quell-IP. Sitzen mehrere interne Benutzer hinter derselben NAT-Adresse
# (typischer Buero-Fall), teilen sie sich dieses Budget; das ist eine
# bewusste Betriebsentscheidung und muss zusammen mit dem nginx-limit_req
# und der fail2ban-Schwelle (beide ebenfalls konfigurierbar, siehe
# ansible/roles/nginx_proxy und ansible/roles/fail2ban) im Betriebshandbuch
# festgehalten werden.
login_rate_limiter = SlidingWindowRateLimiter(
max_events=settings.login_rate_limit_max_events,
window_seconds=settings.login_rate_limit_window_s,
)

View File

@ -0,0 +1,83 @@
"""
Startup-Reaper fuer verwaiste Sitzungen (Umsetzungsauftrag_Sonnet5.md, E7).
app/security/active_sessions.py ist bewusst rein prozesslokal -- bei einem
Absturz oder Neustart des uvicorn-Prozesses geht diese Registry verloren,
aber die zugehoerigen Zeilen in der Tabelle 'sessions' bleiben mit
ended_at IS NULL stehen, weil kein Task mehr existiert, der den regulaeren
finally-Cleanup-Pfad (Update + Audit-Event) durchlaeuft.
Ohne Korrektur wachsen diese 'Geistersitzungen' mit jedem Neustart an:
die Superadmin-Sessionview zeigt sie als aktiv, aber nicht beendbar
(killable: false), und jede kuenftige Zaehlung 'wie viele Sitzungen hat
dieser Benutzer offen' (E6, Teil F) rechnet mit ihnen.
reap_orphaned_sessions() wird einmalig beim Start aufgerufen (app/main.py::
lifespan, NACH init_db() -- die Migrationen muessen gelaufen sein) und
schliesst alle offenen Sitzungen mit end_reason='server_restart'. Es ist zu
diesem Zeitpunkt garantiert korrekt, JEDE offene Sitzung so zu behandeln:
der Prozess startet gerade erst, es kann also noch keine echte, laufende
Sitzung dieses neuen Prozesses geben.
"""
from __future__ import annotations
import logging
import aiosqlite
from app.security.audit import write_audit_event
logger = logging.getLogger("jumphost.session_reaper")
async def reap_orphaned_sessions(conn: aiosqlite.Connection) -> int:
"""Schliesst alle beim Start noch offenen Sitzungen. Gibt die Anzahl zurueck."""
cursor = await conn.execute(
"SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY id"
)
rows = await cursor.fetchall()
orphaned_ids = [row[0] for row in rows]
if not orphaned_ids:
logger.info("Keine verwaisten Sitzungen beim Start gefunden.")
return 0
await conn.execute(
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), "
"end_reason = 'server_restart' WHERE ended_at IS NULL"
)
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 3): explizit committen,
# BEVOR write_audit_event() unten aufgerufen wird. write_audit_event()
# oeffnet immer ein eigenes "BEGIN IMMEDIATE" (app/security/audit.py, E1)
# -- ohne diesen Commit bleibt das UPDATE oben (je nach isolation_level
# der uebergebenen Connection) als offene Transaktion stehen, und das
# BEGIN IMMEDIATE schlaegt mit "cannot start a transaction within a
# transaction" fehl. In der Produktion faellt das nicht auf, weil
# app/db.py::init_db() die einzige echte Connection bewusst mit
# isolation_level=None (Autocommit) oeffnet -- dort committet das UPDATE
# bereits von selbst. reap_orphaned_sessions() bekommt seine Connection
# aber als Parameter uebergeben und darf sich auf dieses Detail des
# Aufrufers nicht verlassen (siehe tests/test_session_reaper.py, das
# bewusst eine eigene :memory:-Connection MIT Standard-isolation_level
# verwendet und den Fehler dadurch reproduzierbar machte).
await conn.commit()
logger.warning(
"Startup-Reaper: %d verwaiste Sitzung(en) aus vorherigem Prozess geschlossen "
"(end_reason=server_restart): %s",
len(orphaned_ids),
orphaned_ids,
)
# Ein einziges Sammel-Audit-Ereignis statt eines je Sitzung (E7) -- die
# Anzahl und betroffenen IDs sind das, was fuer die Nachvollziehbarkeit
# gebraucht wird; ein Ereignis pro Zeile waere Rauschen im Log.
await write_audit_event(
conn,
event_type="session_reaper_server_restart",
user_id=None,
client_ip=None,
details={"count": len(orphaned_ids), "session_ids": orphaned_ids},
)
return len(orphaned_ids)