Fix: optionen-Update überschrieb bisherige Werte statt sie zu ergänzen + OE-/Gruppen-Select-Endpunkte akzeptieren jetzt optionen-Payload

Zwei zusammenhängende Fixes, beide Voraussetzung für die neue
optionen-Eingabe-UI in kundenplattform (struktur.py/gruppen.py):

1. set_auftrag_selection()/set_oe_auswahl()/set_gruppe_auswahl()
   ersetzten optionen bisher komplett (kein natives JSONB-Merge im
   UPSERT) - ein im Formular leer gelassenes Passwort-Feld
   (parse_optionen_form() lässt solche Keys bewusst weg, siehe
   optionen_felder.py) hätte ein zuvor gesetztes Passwort beim nächsten
   Bearbeiten stillschweigend gelöscht. Neue merge_optionen()-Funktion:
   bestehenden Wert holen, mit den neu eingegebenen Feldern
   zusammenführen, erst dann verschlüsseln (encrypt_sensitive_optionen()
   dafür idempotent gemacht - erkennt bereits "enc:"-präfigierte Werte
   und verschlüsselt nicht doppelt).

2. /api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select
   und die Gruppen-Entsprechung nahmen bisher gar keinen Payload-Body
   entgegen (hartcodiert leeres optionen-Dict) - jetzt wie der
   geräteweise Endpunkt mit AuftragSelectRequest. Ohne diesen Fix hätte
   die neue OE-/Gruppen-UI (Kundenplattform) eingegebene AD-Domäne o.ä.
   nie beim Server ankommen können.

Zusätzlich: fetch_oe_merkmale_listing()/fetch_gruppe_merkmale_listing()
liefern jetzt auch optionen (maskiert, siehe mask_sensitive_optionen())
für die "bereits gesetzt"-Anzeige im Formular.

ECHT gegen die Produktions-DB getestet (nicht nur Code-Review): OE-Ebene
erst mit Domain+Passwort setzen, dann NUR die Domain erneut setzen ohne
Passwort-Feld - Passwort blieb korrekt erhalten, Domain wurde aktualisiert.

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:38:41 +02:00
1 parent d970936b7c
commit b270103eab
1 file changed
+77 -20
+77 -20
View File
@@ -881,14 +881,32 @@ def encrypt_sensitive_optionen(optionen: dict) -> dict:
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)."""
bei brave/standardbrowser). Idempotent (bereits "enc:"-präfigierte
Werte werden nicht erneut verschlüsselt) - wichtig für
merge_optionen(), das teilweise schon verschlüsselte Altwerte mit
frischem Klartext aus einem Formular zusammenführt, bevor hier
verschlüsselt wird."""
ergebnis = dict(optionen)
for key, value in optionen.items():
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES) and isinstance(value, str) and value:
if key.endswith(SENSITIVE_OPTIONEN_SUFFIXES) and isinstance(value, str) and value and not value.startswith("enc:"):
ergebnis[key] = "enc:" + _fernet().encrypt(value.encode("utf-8")).decode("ascii")
return ergebnis
def merge_optionen(bestehend: dict, neu: dict) -> dict:
"""Führt neu eingegebene optionen-Werte mit den bereits gespeicherten
zusammen - set_oe_auswahl()/set_gruppe_auswahl()/set_auftrag_selection()
ersetzen den kompletten optionen-Wert (kein natives JSONB-Merge im
UPSERT), ein leer gelassenes Passwort-Feld im Formular
(parse_optionen_form() in kundenplattform/optionen_felder.py lässt
solche Keys komplett weg) würde ohne dieses Merge ein zuvor gesetztes
Passwort stillschweigend löschen. Checkbox-Felder sind davon nicht
betroffen (die werden vom Formular immer explizit mitgeschickt)."""
ergebnis = dict(bestehend)
ergebnis.update(neu)
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
@@ -1190,8 +1208,6 @@ 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(
@@ -1205,6 +1221,19 @@ def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optione
merkmal_id = merkmal_row[0]
# Bestehende optionen holen und mit den neu eingegebenen
# zusammenführen (siehe merge_optionen()) - sonst würde ein leer
# gelassenes Passwort-Feld im Formular ein zuvor gesetztes
# Passwort löschen, statt es unverändert zu lassen.
cur.execute(
"SELECT optionen FROM device_merkmale WHERE device_id = %s AND merkmal_id = %s",
(device_id, merkmal_id),
)
bestehend_row = cur.fetchone()
optionen = encrypt_sensitive_optionen(
merge_optionen(bestehend_row[0] if bestehend_row else {}, optionen)
)
cur.execute(
"""
INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen)
@@ -1381,8 +1410,6 @@ 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(
@@ -1394,6 +1421,19 @@ def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
if merkmal_row is None:
return False
merkmal_id = merkmal_row[0]
# Merge statt Ersetzen, siehe set_auftrag_selection() für die
# ausführliche Begründung (leeres Passwort-Feld != löschen).
cur.execute(
"SELECT optionen FROM oe_merkmale WHERE oe_id = %s AND merkmal_id = %s",
(oe_id, merkmal_id),
)
bestehend_row = cur.fetchone()
optionen = encrypt_sensitive_optionen(
merge_optionen(bestehend_row[0] if bestehend_row else {}, optionen)
)
cur.execute(
"""
INSERT INTO oe_merkmale (oe_id, merkmal_id, aktiv, optionen)
@@ -1401,7 +1441,7 @@ def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
ON CONFLICT (oe_id, merkmal_id) DO UPDATE
SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen
""",
(oe_id, merkmal_row[0], aktiv, Jsonb(optionen)),
(oe_id, merkmal_id, aktiv, Jsonb(optionen)),
)
return True
@@ -1440,7 +1480,7 @@ def fetch_oe_merkmale_listing(oe_id: str):
"""
SELECT m.key, m.name, m.description,
k.key, k.name, k.sort_order,
om.aktiv
om.aktiv, COALESCE(om.optionen, '{}'::jsonb)
FROM merkmale m
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN oe_merkmale om ON om.merkmal_id = m.id AND om.oe_id = %s
@@ -1459,8 +1499,12 @@ def fetch_oe_merkmale_listing(oe_id: str):
if kat_key is not None else None
),
"aktiv": aktiv,
# Wie im Auftragskatalog-Listing bewusst maskiert (siehe
# mask_sensitive_optionen()) - nie Klartext/verschlüsselten
# Rohwert an den Browser.
"optionen": mask_sensitive_optionen(optionen),
}
for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall()
for key, name, description, kat_key, kat_name, sort_order, aktiv, optionen in cur.fetchall()
]
def move_device_to_oe(device_id: str, oe_id: str):
@@ -1526,8 +1570,6 @@ 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(
@@ -1539,6 +1581,19 @@ def set_gruppe_auswahl(gruppe_id: str, merkmal_key: str, aktiv: bool, optionen:
if merkmal_row is None:
return False
merkmal_id = merkmal_row[0]
# Merge statt Ersetzen, siehe set_auftrag_selection() für die
# ausführliche Begründung (leeres Passwort-Feld != löschen).
cur.execute(
"SELECT optionen FROM gruppen_merkmale WHERE gruppe_id = %s AND merkmal_id = %s",
(gruppe_id, merkmal_id),
)
bestehend_row = cur.fetchone()
optionen = encrypt_sensitive_optionen(
merge_optionen(bestehend_row[0] if bestehend_row else {}, optionen)
)
cur.execute(
"""
INSERT INTO gruppen_merkmale (gruppe_id, merkmal_id, aktiv, optionen)
@@ -1546,7 +1601,7 @@ def set_gruppe_auswahl(gruppe_id: str, merkmal_key: str, aktiv: bool, optionen:
ON CONFLICT (gruppe_id, merkmal_id) DO UPDATE
SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen
""",
(gruppe_id, merkmal_row[0], aktiv, Jsonb(optionen)),
(gruppe_id, merkmal_id, aktiv, Jsonb(optionen)),
)
return True
@@ -1577,7 +1632,7 @@ def fetch_gruppe_merkmale_listing(gruppe_id: str):
"""
SELECT m.key, m.name, m.description,
k.key, k.name, k.sort_order,
gm.aktiv
gm.aktiv, COALESCE(gm.optionen, '{}'::jsonb)
FROM merkmale m
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN gruppen_merkmale gm ON gm.merkmal_id = m.id AND gm.gruppe_id = %s
@@ -1596,8 +1651,9 @@ def fetch_gruppe_merkmale_listing(gruppe_id: str):
if kat_key is not None else None
),
"aktiv": aktiv,
"optionen": mask_sensitive_optionen(optionen),
}
for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall()
for key, name, description, kat_key, kat_name, sort_order, aktiv, optionen in cur.fetchall()
]
def add_device_to_gruppe(device_id: str, gruppe_id: str):
@@ -4259,16 +4315,16 @@ def delete_organisationseinheit_endpoint(oe_id: str, authorization: str | None =
def select_oe_merkmal(
oe_id: str,
merkmal_key: str,
payload: AuftragSelectRequest = AuftragSelectRequest(),
authorization: str | None = Header(default=None),
):
# Kein Payload-Body wie beim geräteweisen select (siehe
# AuftragSelectRequest/OPTIONEN_FELDER in kundenplattform) - die
# OE-/Gruppen-Editoren bieten keine Merkmal-Optionen an, nur
# aktiv/inaktiv/nicht festgelegt.
# Seit 10.09.2026 mit optionen-Payload (vorher hartcodiert {}) - siehe
# AD/OIDC/Fileserver-Merkmale, die auf OE-Ebene echte Konfigurationswerte
# brauchen (Domäne, Server-Adresse etc.), nicht nur aktiv/inaktiv.
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not set_oe_auswahl(oe_id, merkmal_key, True, {}):
if not set_oe_auswahl(oe_id, merkmal_key, True, payload.optionen):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}
@@ -4365,12 +4421,13 @@ def delete_gruppe_endpoint(gruppe_id: str, authorization: str | None = Header(de
def select_gruppe_merkmal(
gruppe_id: str,
merkmal_key: str,
payload: AuftragSelectRequest = AuftragSelectRequest(),
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if not set_gruppe_auswahl(gruppe_id, merkmal_key, True, {}):
if not set_gruppe_auswahl(gruppe_id, merkmal_key, True, payload.optionen):
return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"}
return {"success": True}