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

702
FORTSETZUNG_Teil_D.md Normal file
View File

@ -0,0 +1,702 @@
# 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.py`s
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.py`s
`_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.