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

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""D.7-Pflichtcheck (Umsetzungsauftrag_Sonnet5.md, Teil D): "Vorher/Nachher-
Diff der effektiven Rechte je Benutzer" als Migrationspruefung.
Berechnet fuer jeden aktiven (nicht soft-geloeschten) Benutzer und jede
bekannte Rolle (dynamisch aus app.models.schemas.ROLE_NAME gelesen, damit
das Skript nicht bei jeder Rollenaenderung von Hand nachgezogen werden
muss) die Menge der Hostgruppen-IDs, fuer die die Rolle effektiv gilt --
ueber exakt dieselbe Logik wie die Anwendung selbst
(app.rbac.user_host_group_ids_with_any_role, die einzige Wahrheitsquelle
fuer RBAC-Aufloesung, siehe FORTSETZUNG_Teil_D.md Abschnitt 1, S13).
Zwei Betriebsarten:
1. Snapshot exportieren (vor UND nach einer Migration je einmal aufrufen):
<venv>/bin/python scripts/diff_effective_rights.py \\
--dump /pfad/zur.db --out vorher.json
2. Zwei bestehende SQLite-Dateien direkt gegeneinander diffen (z.B. eine
Kopie der Produktions-DB vor dem Wartungsfenster gegen die DB danach):
<venv>/bin/python scripts/diff_effective_rights.py \\
--before vorher.db --after nachher.db
Exit-Code 0: identische effektive Rechte (erwartetes Ergebnis fuer eine
rechteneutrale Migration wie D.4/D.6-Schritt-3). Exit-Code 1: mindestens
eine Abweichung gefunden -- Details werden ausgegeben, das Wartungsfenster
sollte dann NICHT fortgesetzt werden, ohne die Abweichung verstanden zu
haben.
Kann beliebig oft und gefahrlos gegen eine DB-Kopie aufgerufen werden --
rein lesend (oeffnet SQLite explizit read-only ueber die URI-Form
`file:...?mode=ro`), schreibt nichts.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import typing
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import aiosqlite # noqa: E402
from app.models.schemas import ROLE_NAME # noqa: E402
from app.rbac import user_host_group_ids_with_any_role # noqa: E402
ROLES: tuple[str, ...] = typing.get_args(ROLE_NAME)
async def snapshot(db_path: Path) -> dict[str, dict]:
"""Liest den DB-Pfad read-only und liefert
{user_id_str: {"username": ..., "rights": {role_name: [host_group_id, ...]}}}
-- nur Rollen mit mindestens einer Hostgruppe werden aufgefuehrt, damit
der Diff bei Uebereinstimmung leer/kompakt bleibt."""
uri = f"file:{db_path}?mode=ro"
async with aiosqlite.connect(uri, uri=True) as conn:
cursor = await conn.execute(
"SELECT id, username FROM users WHERE deleted_at IS NULL ORDER BY id"
)
users = await cursor.fetchall()
result: dict[str, dict] = {}
for user_id, username in users:
rights: dict[str, list[int]] = {}
for role in ROLES:
host_group_ids = await user_host_group_ids_with_any_role(
conn, user_id=user_id, role_names=(role,)
)
if host_group_ids:
rights[role] = sorted(host_group_ids)
result[str(user_id)] = {"username": username, "rights": rights}
return result
def diff_snapshots(before: dict[str, dict], after: dict[str, dict]) -> list[str]:
"""Liefert eine Liste menschenlesbarer Abweichungszeilen; leere Liste ==
identische effektive Rechte."""
lines: list[str] = []
all_ids = sorted(set(before) | set(after), key=lambda x: int(x))
for uid in all_ids:
b = before.get(uid)
a = after.get(uid)
if b is None:
lines.append(f"user_id={uid} ({a['username']!r}): NEU nach der Migration (vorher nicht aktiv/vorhanden)")
continue
if a is None:
lines.append(f"user_id={uid} ({b['username']!r}): VERSCHWUNDEN nach der Migration (vorher aktiv)")
continue
b_rights, a_rights = b["rights"], a["rights"]
roles = sorted(set(b_rights) | set(a_rights))
for role in roles:
b_set = set(b_rights.get(role, []))
a_set = set(a_rights.get(role, []))
if b_set != a_set:
gained = sorted(a_set - b_set)
lost = sorted(b_set - a_set)
detail = []
if gained:
detail.append(f"NEU auf Hostgruppen {gained}")
if lost:
detail.append(f"VERLOREN auf Hostgruppen {lost}")
lines.append(
f"user_id={uid} ({b['username']!r}), Rolle={role!r}: "
+ "; ".join(detail)
)
return lines
async def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dump", type=Path, help="DB-Datei, deren Rechte-Snapshot exportiert werden soll")
parser.add_argument("--out", type=Path, help="Zieldatei fuer --dump (JSON)")
parser.add_argument("--before", type=Path, help="DB-Datei VOR der Migration")
parser.add_argument("--after", type=Path, help="DB-Datei NACH der Migration")
args = parser.parse_args()
if args.dump:
if not args.out:
parser.error("--dump erfordert --out")
data = await snapshot(args.dump)
args.out.write_text(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False), encoding="utf-8")
print(f"Snapshot von {args.dump} -> {args.out} geschrieben ({len(data)} aktive Benutzer).")
return 0
if args.before and args.after:
before = await snapshot(args.before)
after = await snapshot(args.after)
diffs = diff_snapshots(before, after)
if not diffs:
print(f"KEINE Abweichung: effektive Rechte fuer {len(before)} Benutzer identisch vor/nach der Migration.")
return 0
print(f"{len(diffs)} Abweichung(en) gefunden:")
for line in diffs:
print(f" - {line}")
return 1
parser.error("entweder --dump zusammen mit --out, oder --before zusammen mit --after angeben")
return 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Pflicht-Vorab-Reports vor Teil D Schritt 4 (Umsetzungsauftrag_Sonnet5.md,
D.6 Schritt 4 und D.7-Risikotabelle: "Zugriffsausfall durch Achse B" und
"Verhaltensaenderung bei der Credential-Auswahl").
Zwei unabhaengige Reports gegen eine SQLite-Datei (Produktions-DB-Kopie oder
die aktuelle Dev-DB):
1. "Hosts mit Zugangsdaten ohne Gruppenfreigabe" -- ein Host hat einen SSH-
Schluessel, ein SSH-Passwort oder einen RDP-Zugangsdatensatz zugeordnet
(host_*_map), aber KEINE Benutzergruppe hat diesen Datensatz ueber Achse
B (group_*_grants, Migration 0018) freigegeben. Nach Schritt 4 kann sich
niemand mehr darueber anmelden -- das MUSS vor Schritt 4 bereinigt sein
(z.B. durch eine nachtraegliche manuelle Achse-B-Freigabe), sonst ist der
Host fuer alle Benutzer unerreichbar, ungeachtet ihrer ssh_connect/
rdp_connect-Rolle.
2. "Hosts mit mehrdeutigen SSH-Keys" -- mehr als ein Eintrag in
host_ssh_key_map fuer denselben Host. Strukturell nur bei SSH-Keys
moeglich (n:m-Zuordnung); RDP- und SSH-Passwort-Zuordnung sind laut
Schema 1:1 (PK auf host_id). Ab Schritt 4 wirft
app.rbac.resolve_credential_for_user_on_host() einen harten
AmbiguousCredentialError, sobald ein Benutzer ueber mehr als eine seiner
Gruppen Zugriff auf mehr als einen der zugeordneten Schluessel hat --
dieser Report zeigt, wo das ueberhaupt strukturell moeglich ist, damit
vorab geklaert werden kann, ob das gewollt ist (z.B. Umbenennen/
Entfernen des ueberzaehligen Schluessels).
Aufruf:
<venv>/bin/python scripts/pre_schritt4_checks.py --db /pfad/zur.db
Exit-Code 0: beide Reports leer (unbedenklich, Schritt 4 kann bedenkenlos
scharf geschaltet werden). Exit-Code 1: mindestens ein Fund in einem der
beiden Reports -- Details werden ausgegeben.
Rein lesend (SQLite read-only via `file:...?mode=ro`), schreibt nichts.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import aiosqlite # noqa: E402
# (kind, host_map_table, host_map_credential_col, grant_table, grant_credential_col)
_CHECKS = [
("ssh_key", "host_ssh_key_map", "ssh_key_id", "group_ssh_key_grants", "ssh_key_id"),
(
"rdp_credential",
"host_rdp_credential_map",
"rdp_credential_id",
"group_rdp_credential_grants",
"rdp_credential_id",
),
(
"ssh_password_credential",
"host_ssh_password_credential_map",
"ssh_password_credential_id",
"group_ssh_password_credential_grants",
"ssh_password_credential_id",
),
]
async def report_orphaned_credential_mappings(conn: aiosqlite.Connection) -> list[str]:
"""Report 1: host_*_map-Zeilen, deren Credential-ID in keiner Zeile der
zugehoerigen group_*_grants-Tabelle vorkommt (unabhaengig von expires_at
-- ein abgelaufener Grant zaehlt hier bewusst NICHT als Deckung)."""
lines: list[str] = []
for kind, map_table, map_col, grant_table, grant_col in _CHECKS:
cursor = await conn.execute(
f"""
SELECT m.host_id, h.hostname, m.{map_col}
FROM {map_table} m
JOIN hosts h ON h.id = m.host_id
WHERE NOT EXISTS (
SELECT 1 FROM {grant_table} g
WHERE g.{grant_col} = m.{map_col}
AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
ORDER BY m.host_id
"""
)
rows = await cursor.fetchall()
for host_id, hostname, credential_id in rows:
lines.append(
f"kind={kind}: Host {host_id} ({hostname!r}) hat {kind}={credential_id} "
"zugeordnet, aber KEINE Gruppe hat ihn ueber Achse B freigegeben."
)
return lines
async def report_ambiguous_ssh_keys(conn: aiosqlite.Connection) -> list[str]:
"""Report 2: Hosts mit mehr als einem Eintrag in host_ssh_key_map."""
cursor = await conn.execute(
"""
SELECT m.host_id, h.hostname, COUNT(*) AS n, GROUP_CONCAT(m.ssh_key_id)
FROM host_ssh_key_map m
JOIN hosts h ON h.id = m.host_id
GROUP BY m.host_id
HAVING COUNT(*) > 1
ORDER BY m.host_id
"""
)
rows = await cursor.fetchall()
return [
f"Host {host_id} ({hostname!r}): {n} SSH-Keys zugeordnet (ssh_key_ids={key_ids}) "
"-- Mehrdeutigkeit strukturell moeglich, sobald mehr als einer davon derselben "
"Benutzergruppe freigegeben wird."
for host_id, hostname, n, key_ids in rows
]
async def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", type=Path, required=True, help="Pfad zur zu pruefenden SQLite-Datei")
args = parser.parse_args()
uri = f"file:{args.db}?mode=ro"
async with aiosqlite.connect(uri, uri=True) as conn:
orphaned = await report_orphaned_credential_mappings(conn)
ambiguous = await report_ambiguous_ssh_keys(conn)
print("=== Report 1: Hosts mit Zugangsdaten ohne Gruppenfreigabe ===")
if not orphaned:
print(" Keine Funde.")
else:
for line in orphaned:
print(f" - {line}")
print()
print("=== Report 2: Hosts mit mehrdeutigen SSH-Keys ===")
if not ambiguous:
print(" Keine Funde.")
else:
for line in ambiguous:
print(f" - {line}")
return 1 if (orphaned or ambiguous) else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Einmaliger Datenmigrationsschritt fuer TEIL C, Phase 1 des
Umsetzungsauftrag_Sonnet5.md ("Rollenmodell bereinigen").
Betreiberentscheidung (Phase 0.2): jeder Benutzer mit einer Zeile in
tenant_admins wird zu is_admin=1 befoerdert (Voll-Admin), STATT zum
normalen Benutzer degradiert zu werden. Muss AUSGEFUEHRT WERDEN, BEVOR die
Phase-2-Codeaenderungen (TenantScope-Entfernung) deployt werden -- siehe
C.4: wird TenantScope entfernt, ohne vorher jeden tenant_admins-Eintrag
explizit auf is_admin=1 zu heben, waere die Rechteausweitung fuer diese
Benutzer zwar am Ende dieselbe, aber implizit und ohne Audit-Spur zum
Zeitpunkt der eigentlichen Entscheidung. Nach diesem Schritt verhaelt sich
die Anwendung noch exakt wie vorher (Phase 1 ist bewusst noch KEINE
Codeaenderung) -- ein bereits is_admin=1 gesetzter Benutzer sieht/kann
nichts anderes als vorher, ein bisheriger reiner Mandanten-Admin sieht ab
sofort (noch ueber den unveraenderten is_any_admin-Pfad) alle Mandanten
statt nur seiner/seine eigenen. Das ist die in Phase 0.2 getroffene,
gewollte Entscheidung -- keine Nebenwirkung.
Idempotent: bereits befoerderte oder inzwischen geloeschte/deaktivierte
Benutzer werden uebersprungen; mehrfacher Aufruf ist gefahrlos.
Aufruf (vor dem Wartungsfenster aus Phase 2, mit noch unveraendertem Code):
<venv>/bin/python scripts/promote_tenant_admins.py [--dry-run]
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.db import close_db, get_db, init_db # noqa: E402
from app.security.audit import write_audit_event # noqa: E402
async def main(dry_run: bool) -> None:
await init_db()
conn = get_db()
try:
cursor = await conn.execute(
"SELECT DISTINCT ta.user_id, u.username, u.is_admin "
"FROM tenant_admins ta JOIN users u ON u.id = ta.user_id "
"WHERE u.deleted_at IS NULL "
"ORDER BY u.username"
)
rows = await cursor.fetchall()
if not rows:
print("Keine tenant_admins-Eintraege vorhanden -- nichts zu tun.")
return
to_promote = [(uid, uname) for uid, uname, is_admin in rows if not is_admin]
already_admin = [uname for _uid, uname, is_admin in rows if is_admin]
print(f"Gefunden: {len(rows)} Benutzer mit mind. einer tenant_admins-Zeile.")
if already_admin:
print(f" Bereits is_admin=1 (unveraendert): {', '.join(already_admin)}")
if not to_promote:
print("Alle betroffenen Benutzer sind bereits is_admin=1 -- nichts zu tun.")
return
print(f" Wird auf is_admin=1 befoerdert: {', '.join(u for _i, u in to_promote)}")
if dry_run:
print("--dry-run: keine Aenderung vorgenommen.")
return
for user_id, username in to_promote:
await conn.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (user_id,))
await write_audit_event(
conn,
event_type="tenant_admin_promoted_to_admin",
user_id=user_id,
client_ip=None,
details={
"reason": (
"Umsetzungsauftrag_Sonnet5.md Teil C, Phase 0.2/1: "
"Rueckbau der Mandantenfaehigkeit -- bestehende "
"tenant_admins-Zuweisung wird zu vollem is_admin=1 "
"befoerdert (Betreiberentscheidung, keine Degradierung)."
),
"username": username,
},
)
await conn.commit()
print(f"{len(to_promote)} Benutzer befoerdert, je ein Audit-Event geschrieben.")
finally:
await close_db()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run", action="store_true", help="Nur anzeigen, was passieren wuerde, nichts schreiben."
)
args = parser.parse_args()
asyncio.run(main(args.dry_run))