From b4c77689fd1c3a362675f5862a92cc4f8e56feb9 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Thu, 15 Oct 2026 15:55:51 +0200 Subject: [PATCH] =?UTF-8?q?Personalisierung=20Schritt=203:=20Konten=20im?= =?UTF-8?q?=20Check-in,=20R=C3=BCckmeldung=20des=20Agents,=20Sperren,=20Ne?= =?UTF-8?q?uinstallation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check-in liefert persoenliche_konten (anlegen mit Hash, aktiv, sperren, entfernen). /api/v1/agent/persoenliche-konten nimmt die Ergebnisse an: angelegt löscht den Hash und merkt ohne "Bestehenden Zugang behalten" die übrigen Konten zum Entfernen vor. Ein Code ohne Kästchen sperrt bestehende Konten sofort. Sperren/Entsperren je Konto. Eine Neuinstallation beendet alle Personalisierungen des Geräts. Migration 0065 um gesperrt, entfernen, geraet_meldung und Status fehlgeschlagen ergänzt. Co-Authored-By: Claude Opus 5.5 --- app.py | 184 ++++++++++++++++++++++++- migrations/0065_personalisierungen.sql | 18 ++- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 4aaa0ab..a9d391d 100644 --- a/app.py +++ b/app.py @@ -491,6 +491,7 @@ def load_or_create_device( row = cur.fetchone() created = False + personalisierungen_beenden_bei_neuinstallation(cur, row[0]) else: device_id = uuid4() @@ -5914,7 +5915,7 @@ def personalisierung_verfallene_markieren(cur) -> None: PERSONALISIERUNG_SPALTEN = ( "p.id, p.device_id, p.status, p.code_gueltig_bis, p.bestehenden_behalten, p.erstellt_von, " "p.erstellt_am, p.email, p.anmeldename, p.eingereicht_am, p.bestaetigung_bis, p.bestaetigt_am, " - "p.angelegt_am, p.beendet_am" + "p.angelegt_am, p.beendet_am, p.gesperrt, p.entfernen, p.geraet_meldung" ) @@ -5937,6 +5938,9 @@ def personalisierung_als_dict(row) -> dict: "bestaetigt_am": zeit(row[11]), "angelegt_am": zeit(row[12]), "beendet_am": zeit(row[13]), + "gesperrt": row[14], + "entfernen": row[15], + "geraet_meldung": row[16], } @@ -6000,6 +6004,14 @@ def create_personalisierung_code(device_id: str, bestehenden_behalten: bool, ers """, (device_id,), ) + # Ohne "Bestehenden Zugang behalten" wird das bisherige Konto + # sofort gesperrt; entfernt wird es erst, wenn das neue angelegt + # ist (siehe record_persoenliche_konten_ergebnisse()). + if not bestehenden_behalten: + cur.execute( + "UPDATE personalisierungen SET gesperrt = TRUE WHERE device_id = %s AND status = 'angelegt'", + (device_id,), + ) cur.execute( """ INSERT INTO personalisierungen @@ -6155,6 +6167,137 @@ def set_email_domaenen(organization_id: str, domaenen: list[str]) -> bool: return cur.rowcount > 0 +def fetch_persoenliche_konten_fuer_agent(device_id: str) -> list[dict]: + """ + Soll-Zustand der persönlichen Konten für den Check-in (Schritt 3). Je + Konto genau eine Aktion: anlegen (mit Passwort-Hash), aktiv, sperren + oder entfernen. Konten, die hier fehlen, fasst die Rolle nicht an. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + personalisierung_verfallene_markieren(cur) + cur.execute( + """ + SELECT id, status, anmeldename, passwort_hash, gesperrt, entfernen + FROM personalisierungen + WHERE device_id = %s AND status IN ('bestaetigt', 'angelegt') + ORDER BY erstellt_am + """, + (device_id,), + ) + rows = cur.fetchall() + + konten = [] + for pid, status, anmeldename, passwort_hash, gesperrt, entfernen in rows: + if status == "bestaetigt": + if not passwort_hash: + continue + konten.append({"id": str(pid), "anmeldename": anmeldename, "aktion": "anlegen", "passwort_hash": passwort_hash}) + else: + aktion = "entfernen" if entfernen else "sperren" if gesperrt else "aktiv" + konten.append({"id": str(pid), "anmeldename": anmeldename, "aktion": aktion}) + return konten + + +PERSOENLICHE_KONTEN_ERGEBNISSE = ("angelegt", "aktiv", "gesperrt", "entfernt", "fehler") + + +def record_persoenliche_konten_ergebnisse(device_id: str, ergebnisse: list) -> None: + with get_database_connection() as conn: + with conn.cursor() as cur: + for eintrag in ergebnisse: + meldung = (eintrag.meldung or "")[:1000] or None + cur.execute( + "SELECT status, bestehenden_behalten FROM personalisierungen WHERE id = %s AND device_id = %s FOR UPDATE", + (eintrag.id, device_id), + ) + row = cur.fetchone() + if row is None: + continue + status, behalten = row + + if eintrag.ergebnis == "angelegt" and status == "bestaetigt": + cur.execute( + """ + UPDATE personalisierungen + SET status = 'angelegt', angelegt_am = now(), passwort_hash = NULL, geraet_meldung = NULL + WHERE id = %s + """, + (eintrag.id,), + ) + if not behalten: + cur.execute( + """ + UPDATE personalisierungen SET entfernen = TRUE, gesperrt = TRUE + WHERE device_id = %s AND status = 'angelegt' AND id <> %s + """, + (device_id, eintrag.id), + ) + elif eintrag.ergebnis == "entfernt" and status == "angelegt": + cur.execute( + "UPDATE personalisierungen SET status = 'beendet', beendet_am = now(), geraet_meldung = NULL WHERE id = %s", + (eintrag.id,), + ) + elif eintrag.ergebnis == "fehler" and status == "bestaetigt": + cur.execute( + """ + UPDATE personalisierungen + SET status = 'fehlgeschlagen', passwort_hash = NULL, geraet_meldung = %s + WHERE id = %s + """, + (meldung, eintrag.id), + ) + elif eintrag.ergebnis == "fehler": + cur.execute("UPDATE personalisierungen SET geraet_meldung = %s WHERE id = %s", (meldung, eintrag.id)) + elif eintrag.ergebnis in ("aktiv", "gesperrt"): + cur.execute("UPDATE personalisierungen SET geraet_meldung = NULL WHERE id = %s", (eintrag.id,)) + + +def set_personalisierung_gesperrt(device_id: str, personalisierung_id: str, gesperrt: bool) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE personalisierungen SET gesperrt = %s + WHERE id = %s AND device_id = %s AND status = 'angelegt' AND NOT entfernen + """, + (gesperrt, personalisierung_id, device_id), + ) + return cur.rowcount > 0 + + +def personalisierungen_beenden_bei_neuinstallation(cur, device_id) -> None: + """ + ADR-0041: Der Fingerprint ist hardwarebasiert, ein neu installiertes + Gerät kommt als dasselbe zurück - ohne die Konten von vorher. + """ + + cur.execute( + """ + UPDATE personalisierungen + SET status = 'beendet', beendet_am = now(), passwort_hash = NULL, bestaetigung_token_hash = NULL + WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt', 'angelegt') + """, + (device_id,), + ) + + +class PersoenlichesKontoErgebnis(BaseModel): + id: str + ergebnis: str + meldung: str | None = None + + +class AgentPersoenlicheKontenRequest(BaseModel): + device_id: str + ergebnisse: list[PersoenlichesKontoErgebnis] + + +class SetGesperrtRequest(BaseModel): + gesperrt: bool + + class CreatePersonalisierungRequest(BaseModel): bestehenden_behalten: bool = False erstellt_von: str | None = None @@ -6215,6 +6358,42 @@ def get_personalisierungen_organisation(organization_id: str, authorization: str return {"success": True, "geraete": fetch_personalisierungen_organisation(organization_id)} +@app.post("/api/v1/devices/{device_id}/personalisierung/{personalisierung_id}/gesperrt") +def post_personalisierung_gesperrt( + device_id: str, + personalisierung_id: str, + payload: SetGesperrtRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": set_personalisierung_gesperrt(device_id, personalisierung_id, payload.gesperrt)} + + +@app.post("/api/v1/agent/persoenliche-konten") +def agent_persoenliche_konten( + payload: AgentPersoenlicheKontenRequest, + authorization: str | None = Header(default=None), +): + if authorization is None or not authorization.startswith("Bearer "): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Authorization-Header."} + + if not verify_agent_secret(payload.device_id, authorization.removeprefix("Bearer ")): + return {"success": False, "error": "unauthorized", "message": "Ungültiges Agent-Secret."} + + for eintrag in payload.ergebnisse: + if eintrag.ergebnis not in PERSOENLICHE_KONTEN_ERGEBNISSE: + return {"success": False, "error": "invalid_request", "message": f"Unbekanntes Ergebnis: {eintrag.ergebnis}"} + try: + UUID(eintrag.id) + except ValueError: + return {"success": False, "error": "invalid_request", "message": "Ungültige Konto-ID."} + + record_persoenliche_konten_ergebnisse(payload.device_id, payload.ergebnisse) + return {"success": True} + + class CheckPersonalisierungRequest(BaseModel): code: str @@ -6736,6 +6915,9 @@ def agent_checkin( # wenn er sich vom lokal tatsächlich gesetzten unterscheidet. "hostname": hostname, "ansible_repo": ansible_repo_fuer_geraet(payload.device_id), + # Persönliche Konten (ADR-0041, Schritt 3). Ältere Agenten ignorieren + # das Feld; die Rolle fasst nur hier genannte Konten an. + "persoenliche_konten": fetch_persoenliche_konten_fuer_agent(payload.device_id), "poll_interval_seconds": effektives_intervall, } diff --git a/migrations/0065_personalisierungen.sql b/migrations/0065_personalisierungen.sql index 7e39623..877e9b0 100644 --- a/migrations/0065_personalisierungen.sql +++ b/migrations/0065_personalisierungen.sql @@ -10,16 +10,19 @@ ALTER TABLE organizations ADD COLUMN email_domaenen TEXT[] NOT NULL DEFAULT '{}'; -- Eine Zeile je Aktivierungscode. Ablauf der Stände: --- code_offen -> eingereicht -> bestaetigt -> angelegt (Schritt 3) +-- code_offen -> eingereicht -> bestaetigt -> angelegt (Agent hat das +-- Konto am Gerät eingerichtet) -- Abzweige: zurueckgezogen (Kunden-Admin), verfallen (Code oder --- Bestätigungsfrist abgelaufen), beendet (Konto entfernt). +-- Bestätigungsfrist abgelaufen), fehlgeschlagen (Gerät konnte das Konto +-- nicht anlegen, z.B. Name schon vergeben), beendet (Konto entfernt oder +-- Gerät neu installiert). CREATE TABLE personalisierungen ( id UUID PRIMARY KEY, device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ( 'code_offen', 'eingereicht', 'bestaetigt', 'angelegt', - 'zurueckgezogen', 'verfallen', 'beendet' + 'zurueckgezogen', 'verfallen', 'fehlgeschlagen', 'beendet' )), -- Nur der SHA-256 des normalisierten Codes; der Code selbst wird genau -- einmal angezeigt. @@ -39,7 +42,14 @@ CREATE TABLE personalisierungen ( eingereicht_am TIMESTAMPTZ, bestaetigt_am TIMESTAMPTZ, angelegt_am TIMESTAMPTZ, - beendet_am TIMESTAMPTZ + beendet_am TIMESTAMPTZ, + -- Nur für angelegte Konten: gesperrt (Anmeldung verweigert, nicht im + -- Anmeldebildschirm) und zum Entfernen vorgemerkt (nach Bestätigung + -- eines neuen Kontos ohne "Bestehenden Zugang behalten"). + gesperrt BOOLEAN NOT NULL DEFAULT FALSE, + entfernen BOOLEAN NOT NULL DEFAULT FALSE, + -- Letzte Meldung des Geräts zu diesem Konto (Fehlertext). + geraet_meldung TEXT ); CREATE INDEX personalisierungen_device_idx ON personalisierungen (device_id, erstellt_am DESC);