Files
ssh-jumphost/FORTSETZUNG_Teil_D.md
2026-09-02 20:30:44 +02:00

42 KiB

Fortsetzungsdokument: Umsetzungsauftrag_Sonnet5.md — Teil D (Berechtigungen ausschliesslich ueber Benutzergruppen)

Stand: 2026-09-01, 05:20 UTC. Teil D (D.6 Schritte 1-7) ist vollstaendig abgeschlossen. Achse B ist scharf geschaltet (SSH/RDP- Verbindungen loesen Zugangsdaten benutzer-/gruppenabhaengig auf), die Direktvergabe-Schreibpfade sind abgeschaltet (HTTP 410), zwei Konsolidierungs-Reports stehen bereit (Schritt 6, bewusst ohne automatische Zusammenlegung/Entzug, siehe Abschnitt 2), und alle 14 durch Schritt 4/5 verursachten Testfehlschlaege sind behoben (Schritt 7, siehe Abschnitt 1d). Es verbleiben nur die urspruenglichen, von Teil D unabhaengigen 18 Bugs aus Teil C (siehe FORTSETZUNG_Teil_C.md Abschnitt 0) sowie ein paar bewusst zurueckgestellte Backlog-Punkte (Abschnitt 2, Schritt 6). Repo: ssh_jumphost, erreichbar ueber die device_bash-Bridge unter $HOME/mnt/ssh_jumphost. Teil C ist vollstaendig abgeschlossen, siehe FORTSETZUNG_Teil_C.md.

Reihenfolge des Gesamtauftrags: Teil A/B -> Teil E -> Teil C (fertig) -> Teil D (hier, in Arbeit) -> Teil F -> Aufgabe #11.

Die vollstaendige fachliche Spezifikation steht in Umsetzungsauftrag_Sonnet5.md, Abschnitt "TEIL D" (Zeilen 449-653 zum Zeitpunkt dieses Dokuments -- IMMER per grep -n "^# TEIL" neu verorten, da sich Zeilennummern durch andere Aenderungen verschieben). Dieses Fortsetzungsdokument dupliziert die Spezifikation NICHT, sondern haelt nur Umsetzungsstand, Entscheidungen und naechste Schritte fest.


0) Betreiberentscheidung: die drei toten Rollen (D.3)

Per Rueckfrage am 2026-08-31 entschieden -- alle drei entfernen, keine ausimplementieren:

  1. clipboard -> entfernen. Clipboard bleibt ausschliesslich ueber hosts.clipboard_enabled (host-global) steuerbar, keine Pro-Nutzer- Ebene. Jumphost_Konzept.md 4.6 wurde entsprechend korrigiert (warb vorher faelschlich mit rollenbasierter Steuerung).
  2. session_recording_view -> entfernen. Sitzungs-Playback bleibt dauerhaft ausschliesslich require_global_admin vorbehalten.
  3. admin_hostgroup -> entfernen. Keine delegierte Hostgruppen-Admin- Ebene; es bleibt bei genau zwei Stufen (globaler Admin vs. granulare Rollen ueber Gruppen).

Diese Entscheidung darf von keiner Folge-Session stillschweigend geaendert werden.


1) Was in dieser Session bereits fertiggestellt wurde (D.6 Schritt 1: Vorarbeit, rechteneutral)

  • Tote Rollen entfernt: neue Migration app/db/migrations/0015_remove_dead_roles.sql (loescht zuerst alle Vergaben in user_hostgroup_roles/group_hostgroup_roles fuer die drei Rollen, dann die roles-Zeilen selbst). Unabhaengig gegen einen synthetischen Voll-Migrationslauf (0001-0015) getestet: keine FK- Verletzungen, roles enthaelt danach nur noch ssh_connect, rdp_connect, file_transfer, credentials_view, credentials_manage. app/models/schemas.py::ROLE_NAME (Pydantic-Whitelist) entsprechend gekuerzt und kommentiert.
  • static/js/admin.js: ROLE_NAMES-Konstante war hartkodiert (Doppelung zur DB) -- jetzt let ROLE_NAMES = [], wird beim ersten Aufruf von loadRolesTab() per GET /admin/roles/names gefuellt (dieser Endpunkt existierte bereits, war nur ungenutzt). Erfuellt nebenbei einen Punkt aus D.5 ("ROLE_NAMES von der Hartkodierung auf GET /admin/roles/names umstellen").
  • S6 (Katalog-Protokoll-Filter) gegengeprueft: war bereits behoben. app/catalog/routes.py filterte schon vor dieser Session korrekt nach h.protocol (vermutlich in einer frueheren Phase-9-Session erledigt, der Umsetzungsauftrag war nur nicht nachgezogen). Trotzdem im Zuge von S13 (siehe unten) umgebaut -- die Protokoll-Filterung ist jetzt in Python nach der zentralen rbac.py-Abfrage statt in eingebettetem SQL.
  • S12 geschlossen: revoke_role (app/admin/routes.py) prueft jetzt zusaetzlich _assert_user_in_scope(conn, payload.user_id) (vorher nur Hostgruppe), revoke_group_role zusaetzlich _assert_user_group_in_scope(conn, payload.user_group_id) -- analog zu den bereits vorhandenen Pruefungen in grant_role/grant_group_role. Hinweis: die urspruengliche Schwere dieses Punkts war an Mandanten-Scoping gebunden (ein Tenant-Admin haette sonst hostgruppen-fremde User treffen koennen) -- seit dem Teil-C-Rueckbau ist das nur noch eine Existenz-/Konsistenzpruefung (404 statt stillem No-Op-DELETE), kein eigentliches Sicherheitsloch mehr.
  • S13 (RBAC-Logik dreifach implementiert) geschlossen fuer die verbleibenden zwei Implementierungen (die dritte, app/tenancy.py, war bereits in Teil C nach _to_delete/ verschoben worden): app/rbac.py bekam eine neue Funktion user_host_group_ids_with_any_role(conn, *, user_id, role_names) -- loest die Vereinigung aus Direkt- und Gruppenvergabe fuer eine oder mehrere Rollen auf, ueber ALLE Hostgruppen hinweg (Mengenrueckgabe statt Einzel-Bool). app/catalog/routes.py baut die UNION/EXISTS-Logik nicht mehr selbst nach, sondern ruft diese Funktion viermal auf (ssh_connect, rdp_connect, file_transfer, credentials_view+manage) und verknuepft/filtert in Python (Protokoll-Abgleich inklusive). app/rbac.py ist damit die einzige verbleibende Wahrheitsquelle fuer RBAC-Auflösung.
  • Echter pytest-Lauf nach allen obigen Aenderungen: unveraendert 124 passed, 18 failed -- exakt dieselben 18 vorbestehenden, von Teil D unabhaengigen Fehlschlaege wie am Ende von Teil C (siehe FORTSETZUNG_Teil_C.md Abschnitt 3). Keine Regression. tests/test_rbac.py und die catalog-bezogenen Tests in tests/test_phase9.py laufen weiterhin gruen.

Bewusst NICHT in Schritt 1 erledigt: S4

D.6 listet S4 ("Nicht-Admin mit credentials_manage kann fremde Zugangsdaten anziehen") unter Schritt 1 ("rechteneutral"), D.5 beschreibt die eigentliche Lösung aber als Teil der Achse-B-Umstellung ("Prüfung im Nicht-Admin-Zweig wird von 'existiert' auf 'ist einer meiner Gruppen freigegeben' umgestellt"). Ein Blick auf den Code zeigt, warum eine Zwischenloesung in Schritt 1 riskanter waere als sie nuetzt: CurrentUser unterscheidet aktuell NICHT zwischen "Nicht-Admin-Session-User ueber Hostgruppen-Rolle durchgelassen" und "API-Token mit passendem Scope durchgelassen" (beide kommen mit is_admin=False aus require_admin_scope_or_host_role zurueck, siehe app/auth/deps.py:190-224). Tokens sind aber laut Docstring dort explizit als admin-aequivalent fuer ihren Scope gedacht ("kein Token traegt je eine Hostgruppen-Rolle") -- eine pauschale Verschaerfung in assign_rdp_credential_to_host/ map_ssh_key_to_host wuerde also entweder Tokens unbeabsichtigt einschraenken oder muesste CurrentUser um ein Unterscheidungsmerkmal erweitern, was kein rein "rechteneutraler" Schritt mehr waere. Entscheidung dieser Session: S4 wird korrekt erst mit Achse B in Schritt 5 geloest (dort ist die Unterscheidung ohnehin noetig, weil Achse B die Freigabe explizit prueft). Bis dahin bleibt die Luecke offen -- das ist ein bekanntes Risiko, kein vergessener Punkt.


1a) Was in dieser Session zusaetzlich fertiggestellt wurde (D.6 Schritt 3, alle 6 Punkte: Migration)

Drei neue, sequenziell aufeinander aufbauende Migrationen, jede einzeln per synthetischem Voll-Migrationslauf (sqlite3.executescript ueber die gesamte Kette + PRAGMA foreign_key_check + Stichproben-Scenario) UND anschliessend per echtem pytest-Lauf gegenverifiziert:

  1. app/db/migrations/0016_ssh_password_credential_objects.sql (D.4 Punkt 4, Vorbedingung fuer Achse B): baut ssh_password_credentials von einer reinen host_id-Zuordnungstabelle zu einem eigenstaendigen Objekt mit eigener ID um, analog zu Migration 0012 (rdp_credentials/host_rdp_credential_map). Die alte Tabelle wird zu ssh_password_credentials_legacy umbenannt und dauerhaft aufbewahrt (Projektkonvention, nicht droppen), die neue ssh_password_credentials (id, label, username, password_enc, created_at, rotated_at) plus host_ssh_password_credential_map (host_id PK, ssh_password_credential_id) werden befuellt, Korrelation Alt->Neu ueber password_enc als natuerlichen Unique-Schluessel (jede Verschluesselung nutzt einen frischen Nonce). Begleitende Code-Anpassungen: app/admin/routes.py (set_ssh_password_credentials/delete_ssh_password_credentials sowie die beiden Lesepfade im Host-Detail-Endpunkt und die Hard-Delete-Kaskade pruefen/schreiben jetzt ueber die Map-Tabelle), app/ssh_proxy/proxy.py (Credential-Lookup ebenso ueber die Map-Tabelle).
  2. app/db/migrations/0017_personal_groups.sql (D.4 Punkte 1-2): fuegt user_groups.is_personal (INTEGER NOT NULL DEFAULT 0) hinzu, dazu einen BEFORE INSERT ON user_group_members-Trigger (enforce_personal_group_single_member), der fuer persoenliche Gruppen ab dem zweiten Mitglied mit RAISE(ABORT, ...) hart abbricht (D.7: "Rechteausweitung ueber persoenliche Gruppen" -- serverseitig erzwungen, nicht nur Konvention). Anschliessend legt die Migration in einem BEGIN IMMEDIATE ... COMMIT-Block fuer jeden aktiven User mit mindestens einer user_hostgroup_roles-Zeile eine persoenliche Gruppe an ('Persoenlich: ' || username || ' (#' || id || ')'), macht ihn zum einzigen Mitglied und spiegelt alle seine Direktvergaben 1:1 nach group_hostgroup_roles (inkl. granted_by/granted_at/expires_at verbatim, keine Neuvergabe). Bewusst OHNE PRAGMA foreign_keys = OFF/ON (anders als Migration 0014) -- reine INSERTs koennen hier keine FK-Verletzung erzeugen, ein Abschalten der Pruefung waere eine unbegruendete Abweichung und wurde deshalb aus einem ersten Entwurf wieder entfernt. Mit einem synthetischen 5-User-Szenario verifiziert (admin, zwei User mit Direktvergaben, ein User ohne Vergabe, ein soft-geloeschter User mit Vergabe) -- korrekte Anlage, korrekter Ausschluss der letzten beiden, exakte Feld-Uebernahme, und der Trigger blockiert nachweislich ein zweites INSERT INTO user_group_members fuer dieselbe persoenliche Gruppe.
  3. app/db/migrations/0018_credential_group_grants.sql (D.4 Punkte 3 und 5): legt die drei Achse-B-Freigabetabellen an -- group_ssh_key_grants, group_rdp_credential_grants, group_ssh_password_credential_grants (je zusammengesetzter PK aus user_group_id + Credential-ID, granted_by/granted_at/expires_at, Indizes auf beiden FK-Spalten, ON DELETE CASCADE auf user_group_id). Befuellt rechteneutral per INSERT OR IGNORE ... SELECT DISTINCT: jede Gruppe, die auf der Hostgruppe eines Hosts mit zugeordnetem Credential die passende Connect-Rolle (ssh_connect bzw. rdp_connect) in group_hostgroup_roles haelt, wird fuer dessen Credential(s) freigegeben -- reproduziert exakt das heutige Verhalten. Muss zwingend NACH Migration 0017 laufen, sonst wuerden User mit ausschliesslich Direktvergaben (noch nicht gespiegelt) faelschlich uebergangen. Wichtig: diese Migration aendert noch KEIN Laufzeitverhalten -- app/rbac.py und die Proxies lesen diese Tabellen noch nicht, das ist Schritt 4. Mit einem synthetischen Mehrgruppen-Szenario verifiziert (Team-Gruppe "devs" + ssh_connect auf einer Hostgruppe, dazu eine bereits gespiegelte persoenliche Gruppe von "alice" mit derselben Rolle, drei Hosts darunter einer mit SSH-Key, einer mit SSH-Passwort-Credential, einer mit RDP-Credential auf einer Hostgruppe OHNE rdp_connect-Vergabe) -- Ergebnis: keine FK-Verletzungen, SSH-Key- und SSH-Passwort-Freigaben korrekt fuer beide Gruppen erzeugt, group_rdp_credential_grants korrekt leer geblieben.
  4. Echter pytest-Lauf gegen die volle Kette 0001-0018 (aufgeteilt in vier serielle Gruppen ueber getrennte device_bash-Aufrufe mit je eigenem JUMPHOST_DATA_DIR, wegen der ~120s-Aufrufgrenze; ein einzelner pytest -n 4-Parallellauf wurde verworfen, da er durch Worker-Interferenz 8 zusaetzliche, nicht reproduzierbare Errors erzeugte -- kein echter Befund, siehe unten): exakt 124 passed, 18 failed, Fehlschlaege 1:1 identisch mit den in FORTSETZUNG_Teil_C.md Abschnitt 3 dokumentierten 18 vorbestehenden Bugs (Signatur-Drift RDP/Session- Broadcast, Session-Reaper-Doppel-BEGIN, Recorder-.close(), Test- Substring-Fehlalarm, Session-Invalidierung nach Passwortwechsel, hard_deleted-Logik). Keine Regression durch Migrationen 0016-0018.

Schritt 3 ist damit vollstaendig abgeschlossen. Empfehlung fuer die naechste Session: vor dem eigentlichen Produktions-Deployment dieser Migrationskette zusaetzlich scripts/diff_effective_rights.py --before <Kopie-der-Produktions-DB-vor-0016> --after <DB-nach-0018> gegen die ECHTEN Produktionsdaten laufen lassen (bisher nur synthetisch verifiziert) -- siehe Docstring des Skripts.


1b) Testverifikation nach Schritt 4 -- 12 ERWARTETE neue Fehlschlaege, keine Regression

Echter pytest-Lauf (dieselbe Vier-Gruppen-Aufteilung wie zuvor, wegen der ~120s-Aufrufgrenze) gegen den vollstaendigen Codestand nach Schritt 4: 30 failed, 112 passed (vorher, am Ende von Schritt 3: 18 failed, 124 passed -- macht +12 neue Fehlschlaege). Alle 12 wurden EINZELN nachverfolgt (nie pauschal angenommen) und sind auf genau zwei erwartete, mit dem Umbau selbst zusammenhaengende Ursachen zurueckgefuehrt -- keine davon ist eine unerklaerte Regression:

Ursache A -- Test seedet Rechte direkt in user_hostgroup_roles, das app/rbac.py seit Schritt 4 nicht mehr liest (6 Tests): tests/test_pentest_security.py::test_user_cannot_access_host_outside_granted_hostgroup, tests/test_phase9.py::test_credentials_manage_role_grants_non_admin_write_access, tests/test_phase9.py::test_credentials_view_role_is_read_only, tests/test_phase9.py::test_catalog_hosts_reports_can_view_credentials_flag, tests/test_phase13.py::test_ssh_password_credentials_via_credentials_manage_role, tests/test_rbac.py::test_rbac_grants_and_expiry. Jeweils per Einzellauf gegengeprueft: die Assertion schlaegt fehl, weil die zuvor per INSERT INTO user_hostgroup_roles ... vergebene Rolle jetzt ignoriert wird -- exakt das erwartete Verhalten, siehe Modul-Docstring von app/rbac.py. Fix gehoert zu Schritt 7 (Tests auf Gruppen-basiertes Seeding umstellen).

Ursache B -- Test ruft connect_to_host()/load_private_key_for_host() ohne das neue Pflicht-Keyword user_id auf, UND/ODER die Test-DB seedet keine Achse-B-Freigabe fuer den verwendeten Nutzer (6 Tests): tests/test_phase10.py::test_load_private_key_for_host_uses_stored_passphrase, tests/test_phase12.py::test_verbindung_ohne_hinterlegten_hostkey_wird_abgelehnt, tests/test_phase12.py::test_abweichender_hostkey_bricht_vor_der_anmeldung_ab, tests/test_phase12.py::test_verbindung_nutzt_benutzernamen_der_zugangsdaten, tests/test_phase12.py::test_hostkey_wechsel_nach_der_pruefung_beendet_die_sitzung, tests/test_phase12.py::test_altbestand_ohne_gespeicherten_hostkey_wird_nachgetragen. Bewusst NICHT mit einem schnellen user_id=1 "repariert": diese Tests' _make_db()-Hilfsfunktion seedet ueberhaupt keine users/user_groups/ group_ssh_key_grants-Zeilen -- ein einfaches Nachreichen des Arguments wuerde resolve_credential_for_user_on_host() fuer jeden beliebigen user_id leer zurueckliefern lassen und den Test von einem TypeError-Fehlschlag in einen HostNotConfiguredError-Fehlschlag verwandeln, ohne die eigentliche Testabsicht (Host-Key-Pinning, NICHT Credential-Autorisierung) wiederherzustellen. Ein sauberer Fix gehoert deshalb ebenfalls zu Schritt 7 -- _make_db() braucht dafuer echtes Achse-B-Seeding (Benutzer, Gruppe, Mitgliedschaft, group_ssh_key_grants- Zeile), keine Ein-Zeilen-Notloesung.

Fuer Schritt 7 (Abschnitt 2 unten) folgt daraus eine Ergaenzung: die dort bereits gelistete Datei-Liste (test_rbac.py, test_phase9.py, test_phase13.py, test_admin_groups_tokens.py) ist um test_pentest_security.py, test_phase10.py und test_phase12.py zu erweitern -- diese drei waren im urspruenglichen Umsetzungsauftrag NICHT als betroffen gelistet, sind es aber nachweislich seit Schritt 4.

tests/test_admin_groups_tokens.py (im urspruenglichen Auftrag als betroffen genannt) lief in dieser Session bereits vollstaendig gruen weiter -- vermutlich, weil es (wie schon in Teil C festgestellt) bereits gruppen-basiert seedet.


1c) Testverifikation nach Schritt 5 -- 2 ERWARTETE neue Fehlschlaege, keine Regression, plus 6 neue gruene Tests

Echter pytest-Lauf (dieselbe Vier-Gruppen-Aufteilung, plus ein separater Lauf nur der neuen Datei) gegen den vollstaendigen Codestand nach Schritt 5: 116 passed, 32 failed (148 Tests insgesamt; vorher, am Ende von Schritt 4: 112 passed, 30 failed, 142 Tests). Rechnet sich vollstaendig auf: 142 alte Tests + 6 neue (tests/test_teil_d_schritt5.py, alle gruen) = 148; von den 142 alten wurden GENAU 2 durch Schritt 5 neu zum Fehlschlag, macht 112 - 2 + 6 = 116 passed und 30 + 2 = 32 failed. Beide neuen Fehlschlaege wurden einzeln (nie pauschal) auf Schritt 5 selbst zurueckgefuehrt -- keine unerklaerte Regression:

  • tests/test_admin_crud.py::test_multi_role_grant_for_individual_user -- ruft POST /admin/roles/grant auf und erwartet 200; bekommt jetzt 410. Erwartet: dieser Test pruefte exakt den jetzt retirierten Direktvergabe-Pfad. Fix gehoert zu Schritt 7 (auf /admin/group-roles/* umstellen, wie im Umsetzungsauftrag fuer diese Datei ohnehin vorgesehen).
  • tests/test_pentest_security.py::test_role_on_one_hostgroup_does_not_grant_different_permission_type -- seedet per rohem SQL direkt in user_hostgroup_roles; diese Tabelle heisst seit Migration 0019 user_hostgroup_roles_legacy, das INSERT scheitert daher jetzt mit sqlite3.OperationalError: no such table: user_hostgroup_roles statt (wie unter Schritt 4) still zu laufen. War bei den 12 Ursache-A-Fehlschlaegen aus Abschnitt 1b NICHT gelistet, weil die Testaussage (403 bei fehlendem file_transfer) zufaellig unabhaengig davon war, ob die separat geseedete ssh_connect-Rolle tatsaechlich griff -- der Test blieb unter Schritt 4 also aus falschem Grund gruen. Gehoert in dieselbe Kategorie wie die 6 Ursache-A-Tests aus 1b und damit zu deren Schritt-7-Fix.

Zur Vollstaendigkeit gegengeprueft, dass sich auch die 12 Ursache-A/B-Fehlschlaege aus Schritt 4 sauber wiederfinden: vier davon (test_credentials_manage_role_grants_non_admin_write_access, test_credentials_view_role_is_read_only, test_ssh_password_credentials_via_credentials_manage_role, test_catalog_hosts_reports_can_view_credentials_flag) seeden ueber den API-Aufruf POST /admin/roles/grant statt per rohem SQL -- ihre Fehlermeldung hat sich dadurch ebenfalls verschoben (Assertion auf Status 410 bzw. nachgelagerte Assertion, statt der urspruenglichen Rollenpruefungs-Assertion aus Schritt 4), bleibt aber inhaltlich dieselbe Ursache-A-Familie. test_user_cannot_access_host_outside_granted_hostgroup und test_rbac.py::test_rbac_grants_and_expiry seeden wie test_role_on_one_hostgroup_... oben per rohem SQL und zeigen daher ebenfalls no such table: user_hostgroup_roles statt der Schritt-4- Assertion. Keine dieser Verschiebungen aendert etwas an der Schritt-7- Aufgabe (Tests auf Gruppen-Seeding umstellen) -- nur die Fehlermeldung, an der man das erkennt, hat sich geaendert.

Die 6 neuen Tests in tests/test_teil_d_schritt5.py decken ab: 410 fuer /admin/roles/grant|revoke; GET /admin/roles zeigt via_group_name; Freigeben/Lesen/Entziehen fuer alle drei Achse-B-Arten inkl. 422 bei unbekannter Art und 404 bei nicht existierendem Zugangsdatensatz; GET /admin/ssh-password-credentials (globale Liste, keine Passwort-Leckage); S4 (freigegebener Key darf angehaengt werden, nicht freigegebener wird mit 403 abgelehnt); S10 (gained_rights/lost_rights im Audit-Event). Dazu ein isolierter GET /admin-Smoke-Test: die Seite rendert, enthaelt das neue Panel und nicht mehr den alten Text "Rolle(n) an Benutzer vergeben" -- sowie tests/test_csp_compliance.py, das ohnehin bei jedem Lauf mitlief und weiterhin gruen ist.


1d) Testverifikation nach Schritt 7 -- alle 14 bekannten Fehlschlaege behoben, nur noch die urspruenglichen 18 (Teil C) offen

Echter pytest-Lauf (dieselbe Vier-Gruppen-Aufteilung) gegen den vollstaendigen Codestand nach Schritt 7: 134 passed, 18 failed (152 Tests insgesamt; vorher, am Ende von Schritt 6: 118 passed, 32 failed, 150 Tests -- die 2 zusaetzlichen Tests kommen aus tests/test_rbac.pys Neuschreibung, siehe unten). Rechnet sich vollstaendig auf: 118 + 2 neue test_rbac.py-Tests = 120; von den bisher 32 fehlschlagenden Tests wurden GENAU 14 durch das Umstellen auf Gruppen-basiertes Seeding repariert, macht 120 + 14 = 134 passed und 32 - 14 = 18 failed. Die verbleibenden 18 Fehlschlaege sind exakt (namentlich geprueft, keine zufaellige Uebereinstimmung der Anzahl) die urspruenglichen, in FORTSETZUNG_Teil_C.md Abschnitt 0 dokumentierten Bugs aus Teil C -- ausserhalb des Schritt-7- Auftrags (der deckte namentlich nur die 14 aus den Abschnitten 1b/1c ab) und explizit NICHT ohne neue Anweisung zu beheben (Betreiberentscheidung aus einer fruegeren Session, siehe FORTSETZUNG_Teil_C.md).

Damit ist Teil D (Umsetzungsauftrag_Sonnet5.md, D.6 Schritte 1-7) vollstaendig abgeschlossen. Die einzigen offenen Punkte sind bewusst zurueckgestellte Backlog-Punkte (siehe Abschnitt 2, Schritt 6: manuelle Konsolidierung persoenlicher Gruppen; manuelles Bestaetigen/Entziehen der Achse-B-Vorbefuellung; siehe auch die "Fuer eine kuenftige Session"-Hinweise in den Abschnitten 1/1a/1b) sowie der unabhaengige 18-Bugs-Rucksack aus Teil C.


2) Was noch zu tun ist (D.6 Schritte 2-7)

Schritt 2 ist bereits erfuellt (Teil C ist abgeschlossen, Voraussetzung fuer Schritt 3). Schritt 3 UND Schritt 4 sind ebenfalls vollstaendig abgeschlossen, siehe Abschnitt 1a bzw. 1b -- naechster offener Schritt ist Schritt 5.

Schritt 3: Migration (D.4) -- VOLLSTAENDIG FERTIG (alle 6 Punkte)

  1. Neue Spalte/Kennzeichen "ist persoenliche Gruppe" auf user_groups FERTIG -- app/db/migrations/0017_personal_groups.sql (user_groups.is_personal), inkl. serverseitigem Trigger gegen ein zweites Mitglied. Siehe Abschnitt 1a.
  2. Fuer jeden User mit >=1 Zeile in user_hostgroup_roles: persoenliche Gruppe anlegen ... FERTIG -- Teil derselben Migration 0017. Siehe Abschnitt 1a.
  3. Drei neue Achse-B-Tabellen FERTIG -- app/db/migrations/0018_credential_group_grants.sql (group_ssh_key_grants, group_rdp_credential_grants, group_ssh_password_credential_grants). Siehe Abschnitt 1a.
  4. Vorher noetig: ssh_password_credentials ... umbauen FERTIG -- app/db/migrations/0016_ssh_password_credential_objects.sql (ssh_password_credentials_legacy + neues eigenstaendiges Objekt + host_ssh_password_credential_map), inkl. Anpassung der lesenden/ schreibenden Codepfade in app/admin/routes.py und app/ssh_proxy/proxy.py. Siehe Abschnitt 1a.
  5. Achse B rechteneutral vorbefuellen FERTIG -- Teil von Migration 0018 (INSERT OR IGNORE ... SELECT DISTINCT, gegen ein Mehrgruppen-Szenario verifiziert). Siehe Abschnitt 1a.
  6. Verpflichtend (D.7): Vorher/Nachher-Diff der effektiven Rechte je Benutzer FERTIG -- neues, dauerhaftes Werkzeug scripts/diff_effective_rights.py (nicht nur ein Einmal-Skript: nimmt zwei SQLite-Dateipfade --before/--after, oeffnet beide read-only und vergleicht fuer jeden aktiven User und jede Rolle aus app.models.schemas.ROLE_NAME die Menge der Hostgruppen-IDs ueber app.rbac.user_host_group_ids_with_any_role -- dieselbe Funktion, die die Anwendung selbst zur Laufzeit nutzt, keine Nachbau-Logik. Exit-Code 1 bei jeder Abweichung, 0 bei Uebereinstimmung; alternativ --dump <db> --out <json> fuer einen reinen Snapshot-Export vor einem Wartungsfenster. Verifiziert an einem synthetischen 5-User-Szenario (Direktvergabe, Gruppenvergabe, Ueberschneidung von Direkt- und Gruppenvergabe auf derselben Hostgruppe/Rolle als Dedup-Test, sowie eine abgelaufene Vergabe als Ausschluss-Test): Diff gegen die Kette 0001-0015 vs. 0001-0018 mit identischem Seed liefert keine Abweichung (Exit 0). Zusaetzlich mit einer Negativprobe abgesichert (eine group_hostgroup_roles-Zeile in der Nachher-DB absichtlich geloescht) -- das Werkzeug erkennt den Rechteverlust korrekt (Exit 1, user_id=3 ('bob'), Rolle='rdp_connect': VERLOREN auf Hostgruppen [2]), das Werkzeug ist also kein Blindgaenger, der immer "keine Abweichung" meldet.

Schritt 4: Lesepfade umstellen -- FERTIG

  • app/rbac.py::user_has_role() auf einen Zweig kuerzen FERTIG. app/rbac.py komplett neu geschrieben: user_has_role(), user_has_role_for_host() und user_host_group_ids_with_any_role() lesen nur noch group_hostgroup_roles -- der user_hostgroup_roles-Teil des fruehreren UNION ist vollstaendig entfallen. Sicher, weil Migration 0017 (Schritt 3) jede vormals direkte Vergabe bereits 1:1 in eine persoenliche Gruppe gespiegelt hat.
  • Zwei neue Funktionen in app/rbac.py FERTIG: user_can_use_credential(conn, *, user_id, kind, credential_id) ("darf Benutzer X Credential Y nutzen") und resolve_credential_for_user_on_host(conn, *, user_id, host_id, kind) ("welches Credential gilt fuer Benutzer X auf Host Z"), beide generisch ueber kind: Literal["ssh_key", "rdp_credential", "ssh_password_credential"] fuer alle drei Achse-B-Tabellen aus Migration 0018. Deterministische Auswahlregel wie im Auftrag empfohlen: bei mehr als einem Treffer wirft resolve_credential_for_user_on_host() eine neue AmbiguousCredentialError (mit host_id, kind, credential_ids) statt still auszuwaehlen. Kann strukturell nur bei kind="ssh_key" auftreten (n:m-Zuordnung ueber host_ssh_key_map) -- RDP- und SSH-Passwort-Map haben laut Schema PK auf host_id, also hoechstens einen Treffer.
  • app/ssh_proxy/proxy.py ... auf die neue Auflösung umstellen FERTIG. load_ssh_key_credential_for_host() und load_ssh_password_credential_for_host() (sowie die davon abgeleitete load_private_key_for_host()) nehmen jetzt ein PFLICHT-Keyword-Argument user_id und loesen ueber resolve_credential_for_user_on_host() auf, statt blind per Host-ID zu selektieren. connect_to_host() nimmt ebenfalls user_id entgegen und reicht es durch; die drei Aufrufer (app/ssh_proxy/terminal_ws.py, app/ssh_proxy/sftp.py x2) uebergeben user_id=user.id. AmbiguousCredentialError ist in SSH_SETUP_ERRORS aufgenommen (von terminal_ws.py/sftp.py bereits gemeinsam behandelt) und hat eine eigene Klartextmeldung in describe_connection_error().
  • app/rdp_proxy/ws_tunnel.py ... auf die neue Auflösung umstellen FERTIG. Die vormals direkte SELECT ... FROM host_rdp_credential_map JOIN rdp_credentials ... WHERE host_id = ?-Abfrage ist durch resolve_credential_for_user_on_host(..., kind="rdp_credential") plus eine anschliessende ID-Abfrage ersetzt. AmbiguousCredentialError wird defensiv abgefangen (strukturell nicht erreichbar, siehe oben) und wie der "kein Credential"-Fall ueber _reject() sauber beantwortet, statt unbehandelt durchzuschlagen.
  • app/catalog/routes.py brauchte keine weitere Aenderung -- war seit Schritt 1 (S13) bereits vollstaendig auf user_host_group_ids_with_any_role() umgestellt und profitiert von der Zweig-Kuerzung automatisch. Die D.5-Empfehlung, can_connect zusaetzlich abzubilden, ob ueberhaupt ein fuer den Benutzer nutzbares Credential existiert, ist NICHT umgesetzt (kein Pflichtpunkt von D.6 Schritt 4) -- offener Backlog-Punkt fuer eine spaetere Session.
  • Vorab-Report (D.7, Pflicht vor diesem Schritt): "Hosts mit Zugangsdaten ohne Gruppenfreigabe" FERTIG. Neues Werkzeug scripts/pre_schritt4_checks.py (zwei Reports in einem Skript, --db <pfad>, Exit-Code 1 bei Funden). Mit einem synthetischen 3-Host- Szenario verifiziert (Host mit einem abgedeckten Schluessel -> still; Host mit zwei Schluesseln, beide dergleichen Gruppe freigegeben -> Report 2 schlaegt an; Host mit einem Zugangsdatensatz auf einer Hostgruppe ohne jede ssh_connect-Vergabe -> Report 1 schlaegt an) -- beide Reports feuern exakt an den engineeren Problemfaellen und bleiben beim sauberen Host still.
  • Vorab-Pruefung (D.7): welche Hosts haben mehr als einen SSH-Key FERTIG -- Report 2 desselben Skripts, siehe oben.

WICHTIG fuer die naechste Session: Diese beiden Reports wurden nur gegen ein synthetisches Testszenario gefahren, NICHT gegen die echten Produktionsdaten (dafuer gibt es in dieser Umgebung keine Kopie). Vor dem tatsaechlichen Produktions-Deployment dieses Codestands MUSS scripts/pre_schritt4_checks.py --db <Kopie-der-Produktions-DB> einmal real laufen und sauber (Exit 0) sein -- siehe auch die analoge Empfehlung fuer scripts/diff_effective_rights.py in Abschnitt 1a.

Schritt 5: Schreibpfade abschalten -- FERTIG

  • POST /admin/roles/grant + /revoke auf HTTP 410 FERTIG. Beide Endpunkte nehmen keinen Body mehr entgegen (bewusst -- ein alter Client soll den 410 sehen, keinen 422 wegen eines irrelevanten Validierungsdetails) und antworten mit HTTP 410 plus Verweis auf /admin/group-roles/grant|revoke. Die Auth-Dependency bleibt aktiv (kein anonymer Zugriff auf den Endpunktnamen).
  • GET /admin/roles zur abgeleiteten Sicht umbauen FERTIG. Liest jetzt group_hostgroup_roles JOIN user_groups JOIN user_group_members JOIN users (deleted_at IS NULL) JOIN host_groups JOIN roles, gefiltert auf nicht abgelaufene expires_at. Neue Felder via_group_id/ via_group_name (S1: macht sichtbar, WELCHE Gruppe ein Recht vermittelt -- steht ein Benutzer ueber zwei Gruppen fuer dieselbe Rolle/Hostgruppe berechtigt, erscheinen zwei Zeilen). deleted_at IS NULL filtert Karteileichen aus (S9).
  • S4 loesen FERTIG, aber NUR fuer map_ssh_key_to_host und assign_rdp_credential_to_host -- beide pruefen jetzt zusaetzlich zur Existenz (_assert_ssh_key_in_scope/_assert_rdp_credential_in_scope) user_can_use_credential(conn, user_id=admin.id, kind=..., credential_id=...), wenn not admin.is_admin and not admin.is_token (neues Feld CurrentUser.is_token, gesetzt in app/auth/deps.py::_validate_api_token -- unterscheidet einen echten Nicht-Admin-Session-User von einem API-Token mit passendem Scope, das wie ein Admin behandelt bleibt). Verletzung -> HTTP 403. Bewusste Abweichung vom oben unter Schritt 4 notierten Plan: set_ssh_password_credentials/ delete_ssh_password_credentials bekommen die Pruefung NICHT -- anders als bei SSH-Keys/RDP-Zugangsdaten legt dieser Endpunkt IMMER ein neues Credential-Objekt frisch an bzw. rotiert das bestehende in place; es gibt keinen credential_id-Parameter, ueber den ein fremdes, bereits existierendes Objekt angehaengt werden koennte -- die S4-Angriffsflaeche ("beliebiges vorhandenes Credential an eigenen Host haengen") existiert dort strukturell nicht. Mit einem echten End-to-End-Test verifiziert (test_s4_non_admin_needs_group_grant_to_attach_credential in tests/test_teil_d_schritt5.py): Mitglied mit credentials_manage haengt einen freigegebenen Key erfolgreich an, ein zweiter, existierender aber nicht freigegebener Key wird mit 403 abgelehnt.
  • Neue Achse-B-Endpunkte FERTIG. Ein Endpunkt-Trio bedient alle drei Credential-Arten ueber einen {kind}-Pfadparameter (FastAPI-Literal- Validierung, unbekannte Art -> 422) statt drei Kopien: POST /admin/group-credentials/{kind}/grant, POST /admin/group-credentials/{kind}/revoke, GET /admin/group-credentials/{kind}. Neue Schemas GroupCredentialGrantRequest/GroupCredentialRevokeRequest (app/models/schemas.py) -- Struktur bewusst analog zu GroupRoleGrantRequest. Dazu neu GET /admin/ssh-password-credentials (globale Liste, analog zu list_rdp_credentials()) -- fehlte bisher komplett, obwohl das Objekt seit Migration 0016 eine eigene ID hat; ohne diesen Endpunkt haette die neue Achse-B-UI fuer diese Credential-Art keine Datenquelle fuer den Auswahl-Dropdown gehabt.
  • POST /admin/user-groups/{id}/members S10 FERTIG. add_group_member()/remove_group_member() ermitteln vor der Mutation ueber die neue Hilfsfunktion _group_effective_rights_summary() die aktuell von der Gruppe gewaehrten Rechte (Achse A: Liste {host_group, role}; Achse B: Anzahl freigegebener Zugangsdaten je Art) und schreiben sie als gained_rights/lost_rights in die details_json-Spalte des Audit-Events. Bewusst KEIN Diff gegen zuvor schon ueber ANDERE Gruppen gehaltene Rechte des Users (deutlich groesserer Umbau) -- beschreibt, was DIESE Gruppe gewaehrt, nicht zwingend das Netto-Delta. Mit echtem Test verifiziert (test_s10_audit_event_carries_gained_and_lost_rights).
  • UI: Direktvergabe-Formular entfernen FERTIG. Panel "Rolle(n) an Benutzer vergeben" (templates/admin.html, vormals ~Zeilen 498-529) vollstaendig entfernt; #role-grants-table ist jetzt eine nicht-editierbare Tabelle (Spalten Benutzer/Hostgruppe/Rolle/Gruppe/ Ablauf, keine Aktionen-Spalte mehr). Neues Panel "Zugangsdaten fuer Gruppe freigeben" (Gruppe-/Art-/Zugangsdatensatz-Auswahl, Ablaufdatum, Tabelle mit Entziehen-Button). static/js/admin.js: #role-grant-form- Handler entfernt, refreshRoleGrants() liest die neue Antwortform (via_group_name), neue Funktionen populateCredentialSelect() (befuellt den Zugangsdatensatz-Dropdown je gewaehlter Art aus den bereits vorhandenen Caches cachedSshKeys/cachedRdpCredentials plus neu cachedSshPasswordCredentials) und refreshGroupCredentialGrants() (holt alle drei Arten parallel, mergt fuer eine gemeinsame Tabelle). ROLE_NAMES kam schon seit Schritt 1 aus GET /admin/roles/names -- hier keine Aenderung noetig. Verifiziert: node --check auf beiden JS-Dateien, tests/test_csp_compliance.py gruen (CSP-Konformitaet der neuen Inline-freien Struktur), GET /admin liefert das neue Panel und NICHT mehr den alten Text "Rolle(n) an Benutzer vergeben".
  • user_hostgroup_roles leeren/umbenennen FERTIG -- Migration 0019 (ALTER TABLE user_hostgroup_roles RENAME TO user_hostgroup_roles_legacy), NICHT gedroppt (Projektkonvention siehe 0010:9-14, 0012:12-17). Synthetisch gegen die volle Kette 0001-0019 verifiziert: keine FK-Verletzungen, alter Tabellenname verschwunden, user_hostgroup_roles_legacy vorhanden und weiterhin abfragbar, Index automatisch mitgewandert.

Schritt 6: Konsolidierung -- FERTIG (als Report-Werkzeug, bewusst OHNE automatische Zusammenlegung/Entzug)

Zwei neue, rein lesende Endpunkte in app/admin/routes.py, beide hinter require_admin_or_scope("roles", "read"):

  • Admin-Report "persoenliche Gruppen mit identischem Rechteprofil" FERTIG. GET /admin/reports/personal-groups. Fuer jede Gruppe mit is_personal=1 (Migration 0017) berechnet _personal_group_rights_fingerprint() einen kanonischen String aus ALLEN Rechten (Achse A: group_hostgroup_roles, sortiert nach Hostgruppe/Rolle/Ablauf; Achse B: alle drei Freigabetabellen, sortiert nach Credential-ID/Ablauf) -- Gruppen mit identischem Fingerprint landen im selben Cluster. Antwort: Liste von Clustern mit group_count, consolidation_candidate (group_count > 1) und den Mitgliedsgruppen (inkl. des jeweils einzigen Mitglieds-Users, siehe Migration-0017- Trigger). Mit echtem Test verifiziert (zwei Gruppen mit identischer ssh_connect-Rolle landen im selben Cluster, eine dritte ohne jede Rolle bleibt allein).
  • restriktives Aufraeumen der Achse-B-Vorbefuellung BEWUSST NICHT als automatischer Entzug umgesetzt -- als Report-Werkzeug FERTIG, siehe Begruendung unten. GET /admin/reports/unconfirmed-credential-grants listet alle Achse-B-Freigaben mit granted_by IS NULL -- exakt die Kennzeichnung, die Migration 0018 fuer ihre rechteneutrale Vorbefuellung verwendet (ein manueller Grant ueber POST /admin/group-credentials/{kind}/grant setzt immer admin.id). Sobald ein Admin eine Zeile ueber diesen Endpunkt bestaetigt, verschwindet sie aus dem Report (mit echtem Test verifiziert: Vorbefuellungs-Zeile erscheint, nach manueller Bestaetigung nicht mehr).

Warum kein automatischer Entzug: die D.7-Zeile "Rechteausweitung durch die Achse-B-Vorbefuellung" sieht ausdruecklich vor, das als Aufraeum- Backlog zu DOKUMENTIEREN, "wenn diese Session/ein Folgeauftrag es nicht mehr vollstaendig umsetzt" -- das System hat keine echten Nutzungsdaten (wer benutzt welches Credential tatsaechlich), ein automatisches Entziehen ungenutzt erscheinender Vorbefuellungs-Zeilen waere daher blindes Raten mit echtem Rechteverlust-Risiko (genau das Risiko, das die erste D.7-Zeile "Rechteverlust beim Abschalten der Direktvergabe" fuer den gesamten Umbau vermeiden wollte). Der Report macht die Vorbefuellung sichtbar und auditierbar; das tatsaechliche Entziehen bleibt eine bewusste, fallweise Admin-Entscheidung ueber den bereits vorhandenen POST /admin/group-credentials/{kind}/revoke-Endpunkt (Schritt 5). Ebenso fuehrt GET /admin/reports/personal-groups KEINE automatische Zusammenlegung durch -- das Verschmelzen zweier Gruppen aendert Mitgliedschaften und wuerde bei falscher Annahme (zwei Gruppen mit heute identischem Profil, die bewusst getrennt bleiben sollen, z.B. fuer zukuenftige Differenzierung) unnoetig Struktur zerstoeren, die sich nicht ohne Weiteres wiederherstellen laesst.

Fuer eine kuenftige Session: GET /admin/reports/unconfirmed-credential-grants gegen die echten Produktionsdaten laufen lassen (analog zur Empfehlung fuer scripts/pre_schritt4_checks.py/scripts/diff_effective_rights.py in den Abschnitten 1/1a) und Zeile fuer Zeile entscheiden: freigeben (bestaetigen) oder entziehen. GET /admin/reports/personal-groups als Grundlage fuer eine manuelle Entscheidung nutzen, welche Cluster tatsaechlich zu echten Team-Gruppen zusammengelegt werden sollen (z.B. per PUT /admin/user-groups/{id} umbenennen und is_personal -- dafuer existiert aktuell noch KEIN Endpunkt, der das Flag aendert; das waere ein naheliegender, aber bisher nicht spezifizierter Folgepunkt).

Schritt 7: Tests -- FERTIG (alle 14 bekannten Fehlschlaege behoben, siehe Abschnitt 1d)

Alle 14 in den Abschnitten 1b/1c namentlich bekannten Fehlschlaege sind einzeln (nie pauschal) auf Gruppen-basiertes Seeding umgestellt und verifiziert. Details siehe Abschnitt 1d. Kurzfassung je betroffener Datei:

  • tests/test_rbac.py ... vollstaendig neu geschrieben FERTIG. Alle drei Tests seeden jetzt ueber user_groups/user_group_members/ group_hostgroup_roles statt user_hostgroup_roles. Zwei zusaetzliche Tests ergaenzt (Rolle bleibt bei Ablauf EINER von zwei Gruppen bestehen; Rolle leckt nicht zwischen Hostgruppen) -- nicht im urspruenglichen Umsetzungsauftrag gefordert, aber naheliegende Luecken angesichts des Neuschreibens.
  • tests/test_phase9.py, tests/test_phase13.py, tests/test_admin_groups_tokens.py test_admin_groups_tokens.py war bereits gruen (siehe Abschnitt 1b), keine Aenderung noetig. test_phase9.py/test_phase13.py: FERTIG, neue Hilfsfunktionen _grant_group_role() (beide Dateien) und _grant_group_credential() (nur test_phase9.py, fuer den S4-Fall) ersetzen POST /admin/roles/grant.
  • tests/test_pentest_security.py (2 Tests), tests/test_phase10.py (1 Test), tests/test_phase12.py (5 Tests) FERTIG. Bei test_phase10.py/test_phase12.py zusaetzlich zur Gruppen-Freigabe das neue Pflicht-Keyword user_id an allen connect_to_host()/ load_private_key_for_host()-Aufrufen ergaenzt; test_phase12.pys _make_db()-Hilfsfunktion seedet jetzt immer einen Benutzer samt Gruppe und (bei with_key=True) eine group_ssh_key_grants-Freigabe -- genau das zuvor als "kein Notloesungs-user_id=1" zurueckgestellte echte Achse-B-Seeding aus Abschnitt 1b.
  • tests/test_admin_crud.py (1 Test) FERTIG, aber NICHT durch einen Ersatztest fuer den Einzel-User-Pfad -- test_multi_role_grant_for_individual_user wurde zu test_direct_multi_role_grant_for_individual_user_is_retired: bestaetigt, dass POST /admin/roles/grant mit role_names jetzt 410 liefert UND folgenlos bleibt (GET /admin/roles zeigt keine Rolle). Kein Ersatz fuer die Mehrfachauswahl-Faehigkeit selbst noetig -- die deckt test_multi_role_grant_for_user_group (Gruppen-Pfad) bereits ab, diese Faehigkeit existierte fuer einzelne Benutzer ohnehin nur als jetzt entfallener Spezialfall derselben Logik.
  • tests/test_tenants.py (vom Umsetzungsauftrag noch referenziert) existiert weiterhin nicht als aktive Testdatei -- liegt seit Teil C in _to_delete/. Keine Aktion noetig.
  • Jede Aenderung einzeln gegen einen echten pytest-Lauf verifiziert (siehe FORTSETZUNG_Teil_C.md Abschnitt 0 fuer venv-/Env-Var-Setup).

3) Risiken, die beim Weitermachen besonders im Blick bleiben muessen (D.7)

Die vollstaendige Tabelle steht im Umsetzungsauftrag; am wichtigsten fuer die Reihenfolge:

  • Reihenfolge Schritt 3 vor Schritt 5 ist zwingend -- sonst Rechteverlust beim Abschalten der Direktvergabe.
  • Reihenfolge "Vorab-Report" vor Schritt 4 ist zwingend -- sonst Zugriffsausfall durch Achse B fuer jeden Host ohne Gruppenfreigabe.
  • Mehrdeutigkeit bei der Credential-Auswahl (mehrere Keys an einem Host) muss VOR der Umstellung auf die neue Auflösung geprueft werden, nicht erst danach durch Nutzerbeschwerden auffallen.

4) Technische Hinweise (siehe auch FORTSETZUNG_Teil_C.md Abschnitt 0)

  • venv liegt unter .venv/ im Projektordner, funktioniert. Vor jedem pytest-Lauf JUMPHOST_DATA_DIR/JUMPHOST_ENV/JUMPHOST_DEV_KEK/ JUMPHOST_DEV_SESSION_SECRET exportieren (siehe dortiger Codeblock).
  • Migrations-Dateinamen sind alphabetisch sortiert entscheidend (app/db.py::_apply_migrations, sorted(MIGRATIONS_DIR.glob("*.sql"))) -- naechste freie Nummer nach 0018_credential_group_grants.sql ist 0019.
  • Migrationen schreiben laut Projektkonvention KEINE eigenen audit_log-Eintraege (Hash-Chain wird ausschliesslich aus Python-Code gepflegt, write_audit_event()) -- bei Bedarf einen Audit-Eintrag aus einer begleitenden Codeaenderung schreiben, nicht aus dem SQL-Skript selbst.
  • Alle Datei-Edits weiterhin ueber device_bash-Python-Skripte mit content.count(old) == 1-Assert-dann-Replace-Muster, danach py_compile/node --check UND einen echten pytest-Lauf zur Verifikation (jetzt moeglich, siehe Teil-C-Dokument Abschnitt 0) -- keine Aenderung mehr nur "statisch" verifizieren, wenn ein echter Test- Lauf verfuegbar ist.