Personalisierung Schritt 3: Konten im Check-in, Rückmeldung des Agents, Sperren, Neuinstallation

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-15 15:55:51 +02:00
1 parent e12f2206a4
commit b4c77689fd
2 files changed
+197 -5

No files matched your search

+183 -1
View File
@@ -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,
}