Fix: OE-/Gruppen-Optionen erreichten nie das Gerät + At-Rest-Verschlüsselung für sensible Merkmal-Optionen

Fund beim Vorbereiten der AD/OIDC/Fileserver-Merkmale (10.09.2026):
resolve_katalog_overrides() selektierte nie die optionen-Spalte aus
oe_merkmale/gruppen_merkmale, obwohl set_oe_auswahl()/set_gruppe_auswahl()
sie langst speichern konnten - nur device_merkmale.optionen erreichte je
ein Geraet. Da die Geraete-Einzel-Ebene laut Entscheidung vom 09.09.2026
nicht der vorgesehene Ort fuer Merkmal-Konfiguration ist (siehe
project_phase2_app_store_merkmale-Memory), waere OE-/Gruppen-seitig
gesetzte Konfiguration (z.B. eine AD-Domaene) nie beim Geraet angekommen.

resolve_katalog_overrides() liefert jetzt (aktiv, quelle, quelle_id,
optionen) statt nur (aktiv, quelle, quelle_id) - beide Aufrufer
(fetch_auftragskatalog_state fuer den Agenten,
fetch_auftragskatalog_listing fuer die Kundenportal-Anzeige) angepasst.

Zusaetzlich neue At-Rest-Verschluesselung fuer optionen-Werte, deren Key
auf _passwort/_password/_secret/_token endet (Namenskonvention statt
Allowlist - neue Merkmale mit einem Zugangsdaten-Feld brauchen nur den
passenden Key-Namen, keine Codeaenderung). Gleiches Fernet-Muster wie
kundenplattform/encryption.py fuer den WLAN-PSK, aber eigenes Modul/eigener
Schluessel (TUXFLOTTE_ENCRYPTION_KEY) - provisioning-server muss bei jedem
Agent-Checkin entschluesseln koennen, anders als der einmalige ISO-Build-
Transfer dort. Kundenportal-Katalog-Listing zeigt sensible Werte nur als
maskierten Boolean (mask_sensitive_optionen()), nie im Klartext oder auch
nur verschluesselt im Browser.

Neue Abhaengigkeit: cryptography==50.0.0 (gleiche Version wie
kundenplattform), TUXFLOTTE_ENCRYPTION_KEY live auf anode gesetzt.

ECHT gegen die laufende Produktions-Datenbank getestet (nicht nur
Syntax-Check): OE-Ebene (verschluesselter Roh-DB-Wert, korrekter
Klartext im Agent-Checkin-Pfad, korrekt maskiert im Listing-Pfad) UND
Geraete-Ebene als Regressionstest (bestehender, meistgenutzter Pfad
funktioniert unveraendert weiter) - beide bestanden, Testdaten wieder
entfernt.

Voraussetzung fuer die anstehenden AD/OIDC/Fileserver-Merkmale (siehe
project_phase2_app_store_merkmale-Memory), die auf OE-/Gruppen-Ebene mit
echten Zugangsdaten konfiguriert werden sollen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-10 09:21:51 +02:00
1 parent 8c8894b942
commit d61071335d
2 files changed
+117 -22

No files matched your search

+116 -22
View File
@@ -18,6 +18,7 @@ from uuid import UUID, uuid4
import psycopg
from psycopg.types.json import Jsonb
from cryptography.fernet import Fernet, InvalidToken
from fastapi import FastAPI, Header, HTTPException, Request
from pydantic import BaseModel
@@ -70,6 +71,20 @@ GOLDEN_IMAGES_DIR = Path(os.environ.get("TUXFLOTTE_GOLDEN_IMAGES_DIR", "data/gol
# ansible-pull-Lauf, kennt aber (anders als Golden Images) keinen
# Aktivierungs-Kontext dafuer.
WEBAPP_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_WEBAPP_ICONS_DIR", "data/webapp-icons")).resolve()
# At-Rest-Verschluesselung fuer Merkmal-Optionen (10.09.2026, Vorbereitung
# AD/OIDC/Fileserver-Merkmale mit echten Zugangsdaten) - gleiches Muster
# wie kundenplattform/encryption.py fuer den WLAN-PSK, aber eigener
# Schluessel/eigenes Modul: dort ist es ein einmaliger Transfer beim
# ISO-Bau, hier muss der Server bei JEDEM Agent-Checkin entschluesseln
# koennen, andere Betriebsanforderung. Erzeugen wie dort:
# python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY = os.environ.get("TUXFLOTTE_ENCRYPTION_KEY", "")
# Namenskonvention statt Allowlist pro Merkmal: jeder optionen-Key, der auf
# diese Endungen passt, wird transparent verschluesselt/entschluesselt -
# neue Merkmale mit einem Zugangsdaten-Feld muessen dafuer nur ihren
# optionen-Key entsprechend benennen (z.B. "ad_join_passwort"), keine
# Aenderung an dieser Stelle noetig.
SENSITIVE_OPTIONEN_SUFFIXES = ("_passwort", "_password", "_secret", "_token")
ISO_BUILD_TIMEOUT_SECONDS = 1800
# ISO-Ablauf nach 7 Tagen (Migration 0021): ergaenzt die bestehende
# "neuer Build ersetzt alten"-Aufraeumung um eine reine Zeitdimension.
@@ -853,6 +868,58 @@ def fetch_assigned_blueprints(device_id: str):
for key, ansible_role in cur.fetchall()
]
def _fernet() -> Fernet:
if not ENCRYPTION_KEY:
raise RuntimeError("TUXFLOTTE_ENCRYPTION_KEY ist nicht gesetzt.")
return Fernet(ENCRYPTION_KEY.encode("ascii"))
def encrypt_sensitive_optionen(optionen: dict) -> dict:
"""Verschlüsselt jeden optionen-Wert, dessen Key auf
SENSITIVE_OPTIONEN_SUFFIXES endet, bevor er in oe_merkmale/
gruppen_merkmale/device_merkmale.optionen landet - siehe
decrypt_sensitive_optionen() für die Gegenrichtung. Nur String-Werte
werden angefasst, alles andere bleibt unverändert (z.B. Booleans wie
bei brave/standardbrowser)."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES) and isinstance(value, str) and value:
ergebnis[key] = "enc:" + _fernet().encrypt(value.encode("utf-8")).decode("ascii")
return ergebnis
def decrypt_sensitive_optionen(optionen: dict) -> dict:
"""Gegenrichtung zu encrypt_sensitive_optionen() - wird beim
Zusammenstellen der Checkin-Antwort aufgerufen, damit der Agent (und
darüber die Ansible-Rolle) den Klartext bekommt. Ein nicht entschlüssel-
barer Wert (z.B. nach Schlüsselwechsel) wird stillschweigend zu None -
lieber ein leeres Feld an die Rolle liefern als den ganzen Checkin mit
500 abbrechen, analog zum bestehenden Muster in
kundenplattform/encryption.py."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES) and isinstance(value, str) and value.startswith("enc:"):
try:
ergebnis[key] = _fernet().decrypt(value.removeprefix("enc:").encode("ascii")).decode("utf-8")
except InvalidToken:
ergebnis[key] = None
return ergebnis
def mask_sensitive_optionen(optionen: dict) -> dict:
"""Für Anzeige-Zwecke (Kundenportal-Katalog-Listing): sensible Werte
werden durch einen Boolean ersetzt ("ist ein Wert hinterlegt?"), nie im
Klartext oder auch nur verschlüsselt an den Browser ausgeliefert -
anders als decrypt_sensitive_optionen() für den Agenten, der den
Klartext wirklich braucht."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES):
ergebnis[key] = bool(value)
return ergebnis
def resolve_katalog_overrides(device_id: str) -> dict:
"""
Ermittelt für ein Gerät die OE-/Gruppen-Zwischenschicht des
@@ -907,20 +974,20 @@ def resolve_katalog_overrides(device_id: str) -> dict:
oe_aktiv_by_merkmal = {}
if oe_chain:
cur.execute(
"SELECT oe_id, merkmal_id, aktiv FROM oe_merkmale WHERE oe_id = ANY(%s)",
"SELECT oe_id, merkmal_id, aktiv, optionen FROM oe_merkmale WHERE oe_id = ANY(%s)",
(oe_chain,),
)
rows_by_oe = {}
for row_oe_id, merkmal_id, aktiv in cur.fetchall():
rows_by_oe.setdefault(row_oe_id, {})[merkmal_id] = aktiv
for row_oe_id, merkmal_id, aktiv, optionen in cur.fetchall():
rows_by_oe.setdefault(row_oe_id, {})[merkmal_id] = (aktiv, optionen)
for oe_id_in_chain in oe_chain:
for merkmal_id, aktiv in rows_by_oe.get(oe_id_in_chain, {}).items():
oe_aktiv_by_merkmal.setdefault(merkmal_id, (aktiv, oe_id_in_chain))
for merkmal_id, (aktiv, optionen) in rows_by_oe.get(oe_id_in_chain, {}).items():
oe_aktiv_by_merkmal.setdefault(merkmal_id, (aktiv, oe_id_in_chain, optionen))
cur.execute(
"""
SELECT gm.merkmal_id, gm.gruppe_id, gm.aktiv
SELECT gm.merkmal_id, gm.gruppe_id, gm.aktiv, gm.optionen
FROM device_gruppen dg
JOIN gruppen_merkmale gm ON gm.gruppe_id = dg.gruppe_id
WHERE dg.device_id = %s
@@ -928,24 +995,33 @@ def resolve_katalog_overrides(device_id: str) -> dict:
(device_id,),
)
gruppen_stimmen_by_merkmal = {}
for merkmal_id, gruppe_id, aktiv in cur.fetchall():
gruppen_stimmen_by_merkmal.setdefault(merkmal_id, []).append((gruppe_id, aktiv))
for merkmal_id, gruppe_id, aktiv, optionen in cur.fetchall():
gruppen_stimmen_by_merkmal.setdefault(merkmal_id, []).append((gruppe_id, aktiv, optionen))
# Fund 10.09.2026 (Vorbereitung AD/OIDC/Fileserver-Merkmale): optionen
# wurde hier bisher gar nicht mitgeführt, obwohl oe_merkmale/
# gruppen_merkmale die Spalte längst haben (set_oe_auswahl()/
# set_gruppe_auswahl() speichern sie) - nur device_merkmale.optionen kam
# je beim Gerät an. OE-/Gruppen-Ebene ist aber laut Entscheidung vom
# 09.09.2026 der eigentlich vorgesehene Ort für Merkmal-Konfiguration
# (nicht die Geräte-Einzel-Ebene) - ohne diesen Fix käme dort gesetzte
# Konfiguration (z.B. AD-Domäne) nie am Gerät an.
ergebnis = {}
for merkmal_id in set(oe_aktiv_by_merkmal) | set(gruppen_stimmen_by_merkmal):
oe_aktiv, oe_id_gewinner = oe_aktiv_by_merkmal.get(merkmal_id, (None, None))
oe_aktiv, oe_id_gewinner, oe_optionen = oe_aktiv_by_merkmal.get(merkmal_id, (None, None, None))
gruppen_stimmen = gruppen_stimmen_by_merkmal.get(merkmal_id, [])
aktive_gruppen = [gruppe_id for gruppe_id, aktiv in gruppen_stimmen if aktiv]
inaktive_gruppen = [gruppe_id for gruppe_id, aktiv in gruppen_stimmen if not aktiv]
aktive_gruppen = [(gruppe_id, optionen) for gruppe_id, aktiv, optionen in gruppen_stimmen if aktiv]
inaktive_gruppen = [gruppe_id for gruppe_id, aktiv, _optionen in gruppen_stimmen if not aktiv]
if oe_aktiv is True or aktive_gruppen:
quelle = "oe" if oe_aktiv is True else "gruppe"
quelle_id = oe_id_gewinner if oe_aktiv is True else aktive_gruppen[0]
ergebnis[merkmal_id] = (True, quelle, quelle_id)
quelle_id = oe_id_gewinner if oe_aktiv is True else aktive_gruppen[0][0]
optionen = oe_optionen if oe_aktiv is True else aktive_gruppen[0][1]
ergebnis[merkmal_id] = (True, quelle, quelle_id, optionen or {})
else:
quelle = "oe" if oe_aktiv is False else "gruppe"
quelle_id = oe_id_gewinner if oe_aktiv is False else inaktive_gruppen[0]
ergebnis[merkmal_id] = (False, quelle, quelle_id)
ergebnis[merkmal_id] = (False, quelle, quelle_id, {})
return ergebnis
@@ -1000,19 +1076,25 @@ def fetch_auftragskatalog_state(device_id: str):
overrides = resolve_katalog_overrides(device_id)
ergebnis = []
for merkmal_id, key, ansible_role, device_override, workspace_default, optionen in rows:
for merkmal_id, key, ansible_role, device_override, workspace_default, device_optionen in rows:
# optionen folgen derselben Rangfolge wie aktiv - device_merkmale
# gewinnt, sonst die OE-/Gruppen-Quelle, die die aktiv-Entscheidung
# getroffen hat, sonst leer (kein Optionen-Konzept auf reiner
# Workspace-Default-Ebene, siehe workspace_merkmale-Schema).
if device_override is not None:
aktiv = device_override
optionen = device_optionen
elif merkmal_id in overrides:
aktiv, _quelle, _quelle_id = overrides[merkmal_id]
aktiv, _quelle, _quelle_id, optionen = overrides[merkmal_id]
else:
aktiv = workspace_default
optionen = {}
ergebnis.append({
"merkmal": key,
"ansible_role": ansible_role,
"state": "present" if aktiv else "absent",
"optionen": optionen,
"optionen": decrypt_sensitive_optionen(optionen),
})
return ergebnis
@@ -1069,14 +1151,14 @@ def fetch_auftragskatalog_listing(device_id: str):
for (
merkmal_id, key, name, description,
kat_key, kat_name, sort_order,
device_override, workspace_default, optionen,
device_override, workspace_default, device_optionen,
) in rows:
if device_override is not None:
aktiv, quelle, quelle_id = device_override, "geraet", None
aktiv, quelle, quelle_id, optionen = device_override, "geraet", None, device_optionen
elif merkmal_id in overrides:
aktiv, quelle, quelle_id = overrides[merkmal_id]
aktiv, quelle, quelle_id, optionen = overrides[merkmal_id]
else:
aktiv, quelle, quelle_id = workspace_default, "workspace", None
aktiv, quelle, quelle_id, optionen = workspace_default, "workspace", None, {}
ergebnis.append({
"merkmal": key,
@@ -1089,7 +1171,13 @@ def fetch_auftragskatalog_listing(device_id: str):
"state": "present" if aktiv else "absent",
"quelle": quelle,
"quelle_id": str(quelle_id) if quelle_id is not None else None,
"optionen": optionen,
# Bewusst NICHT entschlüsselt (anders als fetch_auftragskatalog_state()
# für den Agenten) - für die Anzeige im Kundenportal reicht ein
# "ist gesetzt"-Hinweis, das Klartext-Passwort gehört nicht in
# eine HTML-Seite. mask_sensitive_optionen() ersetzt sensible
# Werte durch einen Boolean statt durch den (immer noch
# verschlüsselten) Rohwert.
"optionen": mask_sensitive_optionen(optionen),
})
return ergebnis
@@ -1102,6 +1190,8 @@ def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optione
existiert (unbekannter Key oder im_auftragskatalog = false).
"""
optionen = encrypt_sensitive_optionen(optionen)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
@@ -1291,6 +1381,8 @@ def delete_organisationseinheit(oe_id: str):
def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
"""Setzt die OE-weite Auswahl eines katalogfähigen Merkmals (Upsert in oe_merkmale), analog zu set_auftrag_selection."""
optionen = encrypt_sensitive_optionen(optionen)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
@@ -1434,6 +1526,8 @@ def delete_gruppe(gruppe_id: str):
return cur.fetchone() is not None
def set_gruppe_auswahl(gruppe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
optionen = encrypt_sensitive_optionen(optionen)
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
+1
View File
@@ -2,6 +2,7 @@ annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
click==8.4.1
cryptography==50.0.0
fastapi==0.136.3
h11==0.16.0
idna==3.18