docs: ADR-0013 Geräte-Lebenszyklus-Aktionen im Kundenportal

Dokumentiert nachträglich eine bereits deployte, live-verifizierte
Session-Arbeit: De-/Reprovisionieren (serverseitiger Credential-Entzug,
Agent-Self-Heal mit deprovisioned_at-Gate), Löschen als Soft
Delete/Archivierung, Hardware-/Log-Leseendpunkte. Bislang komplett
undokumentiert in platform-docs gewesen.

09-data-model-v1.md um devices.deprovisioned_at/archived_at ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-12 21:08:29 +02:00
parent 94c4ed69fd
commit a2a34e568f
2 changed files with 45 additions and 0 deletions

View File

@ -0,0 +1,37 @@
# ADR-0013: Geräte-Lebenszyklus-Aktionen im Kundenportal
**Status:** Beschlossen
**Datum:** 10.08.2026
## Kontext
Die Geräteliste im Kundenportal (`/geraete`, siehe ADR-0011) zeigte bislang nur Hostname, Kennung, letzten Check-in und einen Link zum Auftragskatalog. Kund:innen fehlte jede Möglichkeit, Hardware-Informationen oder die Installations-/Aktionshistorie eines Geräts einzusehen, oder ein Gerät zu deaktivieren beziehungsweise loszuwerden, ohne den Betreiber zu kontaktieren.
Für „De-Provisionieren" und „Löschen" waren zwei Fragen zu klären, die reale Konsequenzen auf im Feld laufende Geräte haben:
1. Was passiert technisch mit einem deprovisionierten Gerät — wird der laufende Agent aktiv ferngesteuert deinstalliert, oder wird ihm nur serverseitig der Zugriff entzogen?
2. Ist „Löschen" ein harter, unwiderruflicher Datenbank-Delete, oder eine Archivierung?
## Entscheidung
**De-Provisionieren ist ein rein serverseitiger Credential-Entzug**, keine aktive Fernabmeldung. Das Agent-Secret des Geräts wird ungültig gemacht (`agent_secret_hash = NULL`, `deprovisioned_at = now()`); der laufende Agent auf dem Gerät selbst wird nicht kontaktiert und deinstalliert sich nicht — er bekommt beim nächsten Check-in schlicht `401 unauthorized` und bleibt bis zur Reprovisionierung untätig liegen. Historie (Hardware-Snapshots, Aktions-Log) bleibt vollständig erhalten. Diese Variante wurde einer aktiven Fernabmeldung vorgezogen, weil letztere eine neue, deutlich invasivere Fähigkeit im `provisioning-agent` gebraucht hätte (Uninstall-Kommando auf echten Kundengeräten), mit entsprechend höherem Test- und Sicherheitsaufwand.
Damit „Erneut provisionieren" ein deprovisioniertes, aber weiterhin laufendes Gerät ohne physischen Eingriff reaktivieren kann, wurde der Agent (`provisioning-agent/agent.py`) um einen **Self-Heal-Mechanismus** erweitert: bekommt der Agent beim Check-in `401`, ruft er einmalig `/api/v1/agent/bootstrap` erneut auf (dieser Endpoint kannte von Anfang an keine andere Authentifizierung als die `device_id` selbst — bestehendes Design, kein neues Risiko) und übernimmt bei Erfolg das neue Secret. Damit dieser Mechanismus die De-Provisionierung nicht selbst aushebelt — ein deprovisioniertes Gerät würde sich sonst beim nächsten Poll-Zyklus einfach automatisch selbst reaktivieren, unabhängig davon, ob „Erneut provisionieren" im Portal je ausgelöst wurde —, prüft `agent_bootstrap` seit dieser Änderung zusätzlich `deprovisioned_at IS NULL`. Erst das explizite Löschen von `deprovisioned_at` (die Reprovisionieren-Aktion) macht einen erneuten Bootstrap-Aufruf überhaupt erfolgreich. Dieses Gate wurde bei der Live-Verifikation nachträglich ergänzt, nachdem der ursprüngliche Entwurf diese Lücke übersehen hatte.
**Löschen ist Soft Delete (Archivierung), kein Hard Delete.** Ein „gelöschtes" Gerät bekommt `archived_at = now()` gesetzt, verschwindet aus der aktiven Geräteliste (`fetch_devices_for_organization()`/`fetch_all_devices()` filtern `archived_at IS NULL`), Datensatz und Historie bleiben aber vollständig in der Datenbank erhalten. Kein separater „Wiederherstellen"-Endpunkt in dieser ersten Ausbaustufe — die Zeile bleibt bei Bedarf über die Datenbank erreichbar.
Zusätzlich neu: Lese-Endpunkte für **Hardware-Informationen** (`GET /api/v1/devices/{id}/hardware`, jüngster `hardware_snapshots`-Eintrag samt Netzwerk-Interfaces/Storage-Devices) und **Installations-/Aktions-Log** (`GET /api/v1/devices/{id}/events`, bestehende `device_merkmal_events`-Tabelle aus ADR-0010, hier erstmals für Kund:innen sichtbar gemacht statt nur intern verwendet).
Alle Aktionen sind über ein Pico-natives Dropdown (`<details class="dropdown">`) in der Geräteliste erreichbar, ohne zusätzliches JavaScript — konsistent mit dem Rest von `kundenplattform`, das durchgehend serverseitig rendert.
## Konsequenzen
`devices` bekommt zwei unabhängige, nullable Zeitstempel-Spalten (`deprovisioned_at`, `archived_at`) statt eines einzelnen Status-Enums — ein Gerät kann deprovisioniert und trotzdem noch in der aktiven Liste sichtbar sein, während „archiviert" es komplett ausblendet; ein einzelnes Enum hätte diese unabhängigen Zustände entweder verboten oder umständlich kombinieren müssen.
`agent_bootstrap` verweigert seit dieser Änderung Geräte mit gesetztem `deprovisioned_at` — bestehende, funktionierende Erstinstallationen sind davon nicht betroffen, da `deprovisioned_at` bei neu angelegten Geräten immer `NULL` ist.
Der Self-Heal-Mechanismus im Agenten ist die einzige Änderung dieses ADRs, die tatsächlich auf bereits im Feld laufenden Geräten ausgeführten Code betrifft (nicht nur auf anode) — entsprechend wurde er vor dem Live-Rollout gezielt gegen ein deprovisioniertes/reprovisioniertes Testgerät durchgespielt, nicht nur gegen die API-Antwort geprüft.
Die Aktionen sind bislang nur im Kundenportal (`/geraete`) umgesetzt, nicht im internen Admin-Bereich (`/admin/geraete`) — letzterer nutzt für den Auftragskatalog bereits denselben Reuse-Pattern (`admin_geraete.py` importiert aus `routers/geraete.py`), eine spätere Erweiterung ist entsprechend günstig.
Details zum Datenmodell: `09-data-model-v1.md`.

View File

@ -113,6 +113,8 @@ Die interne Geräte-ID ist unabhängig von Hardwaremerkmalen.
Der Hardware-Fingerprint dient der Wiedererkennung während des Provisioning-Handshakes. Der Hardware-Fingerprint dient der Wiedererkennung während des Provisioning-Handshakes.
Ein Device kann deprovisioniert (Zugriff serverseitig entzogen, aber weiterhin sichtbar) und/oder archiviert (aus der aktiven Geräteliste ausgeblendet, „gelöscht" im Sinne des Kundenportals) sein — beides unabhängig voneinander und ohne den Datensatz oder seine Historie zu entfernen (siehe ADR-0013).
Attribute: Attribute:
- id - id
@ -122,6 +124,8 @@ Attribute:
- hostname - hostname
- created_at - created_at
- last_seen - last_seen
- deprovisioned_at (optional; NULL bedeutet aktiv provisioniert)
- archived_at (optional; NULL bedeutet nicht archiviert/gelöscht)
Beziehungen: Beziehungen:
@ -570,6 +574,8 @@ Spalten:
- hostname - hostname
- created_at - created_at
- last_seen - last_seen
- deprovisioned_at (nullable)
- archived_at (nullable)
Beziehungen: Beziehungen:
@ -858,6 +864,8 @@ Wird eine Organisationseinheit gelöscht, die noch Unter-OEs oder zugeordnete De
Wird eine Gruppe gelöscht, wird ihre Mitgliedschafts- und Merkmal-Override-Zuordnung mitgelöscht; die zugeordneten Devices selbst bleiben unberührt. Wird eine Gruppe gelöscht, wird ihre Mitgliedschafts- und Merkmal-Override-Zuordnung mitgelöscht; die zugeordneten Devices selbst bleiben unberührt.
De-/Reprovisionierung und Archivierung eines Devices (siehe ADR-0013) sind reine Spaltenänderungen (`deprovisioned_at`, `archived_at`), kein Löschvorgang — sie lösen keine der hier beschriebenen Kaskaden aus, alle Beziehungen und Historie eines Devices bleiben davon unberührt.
Für die relationale Umsetzung gilt daher: Für die relationale Umsetzung gilt daher:
- devices.organization_id → ON DELETE RESTRICT - devices.organization_id → ON DELETE RESTRICT