diff --git a/app.py b/app.py index 1bb3d0d..73f4411 100644 --- a/app.py +++ b/app.py @@ -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}