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:
1 parent
8c8894b942
commit
d61071335d
2 files changed
+117
-22
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user