From 05a80f09981e488d0c4f5f78494b87f127fb05c0 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Tue, 4 Aug 2026 15:28:49 +0200 Subject: [PATCH] feat: Auftragskatalog-Auswahl-API und optionen-Passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Migrationen 0008 (device_merkmale.optionen JSONB) und 0009 (Kategorien app-store/individuelle-systemkonfiguration, Katalogfähigkeit für guest-session-ephemeral/browser-brave/office-onlyoffice). Check-in-Antwort gibt optionen je Auftrag mit. Neue admin-token-geschützte Endpoints zum Auflisten/Auswählen/Abwählen von Aufträgen pro Gerät. Co-Authored-By: Claude Sonnet 5 --- app.py | 198 ++++++++++++++++++- migrations/0008_device_merkmale_optionen.sql | 5 + migrations/0009_auftragskatalog_seed.sql | 24 +++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 migrations/0008_device_merkmale_optionen.sql create mode 100644 migrations/0009_auftragskatalog_seed.sql diff --git a/app.py b/app.py index 7eb4bfb..b63c574 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,7 @@ LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") +ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} app = FastAPI(title="Provisioning Activation Server", version="0.1.0") @@ -55,6 +56,10 @@ class AgentReportRequest(BaseModel): results: list[AgentReportEntry] +class AuftragSelectRequest(BaseModel): + optionen: dict = {} + + def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") @@ -328,6 +333,17 @@ def fetch_templates(organization_id: str) -> list[dict]: for row in rows ] +def require_admin_token(authorization: str | None) -> bool: + if not ADMIN_TOKEN: + return False + + if authorization is None or not authorization.startswith("Bearer "): + return False + + provided_token = authorization.removeprefix("Bearer ") + + return hmac.compare_digest(provided_token, ADMIN_TOKEN) + def hash_agent_secret(secret: str) -> str: return hashlib.sha256(secret.encode("utf-8")).hexdigest() @@ -415,7 +431,8 @@ def fetch_auftragskatalog_state(device_id: str): cur.execute( """ SELECT m.key, bp.ansible_role, - COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL) + COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL), + COALESCE(dm.optionen, '{}'::jsonb) FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s LEFT JOIN device_merkmale dm @@ -431,10 +448,116 @@ def fetch_auftragskatalog_state(device_id: str): "merkmal": key, "ansible_role": ansible_role, "state": "present" if aktiv else "absent", + "optionen": optionen, } - for key, ansible_role, aktiv in cur.fetchall() + for key, ansible_role, aktiv, optionen in cur.fetchall() ] +def fetch_auftragskatalog_listing(device_id: str): + """ + Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um + Anzeige-relevante Felder (Name, Beschreibung, Kategorie) statt nur die + für den Agenten nötigen (Rollenname, State). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT bv.workspace_id, bv.backend_id + FROM assignments a + JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id + WHERE a.device_id = %s + """, + (device_id,), + ) + assignment_row = cur.fetchone() + + if assignment_row is None: + return None + + workspace_id, backend_id = assignment_row + + cur.execute( + """ + SELECT m.key, m.name, m.description, + k.key, k.name, k.sort_order, + COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL), + COALESCE(dm.optionen, '{}'::jsonb) + FROM merkmale m + JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s + LEFT JOIN kategorien k ON k.id = m.kategorie_id + LEFT JOIN device_merkmale dm + ON dm.merkmal_id = m.id AND dm.device_id = %s + LEFT JOIN workspace_merkmale wm + ON wm.merkmal_id = m.id AND wm.workspace_id = %s + WHERE m.im_auftragskatalog + ORDER BY k.sort_order NULLS LAST, m.name + """, + (backend_id, device_id, workspace_id), + ) + return [ + { + "merkmal": key, + "name": name, + "description": description, + "kategorie": ( + {"key": kat_key, "name": kat_name, "sort_order": sort_order} + if kat_key is not None else None + ), + "state": "present" if aktiv else "absent", + "optionen": optionen, + } + for ( + key, name, description, + kat_key, kat_name, sort_order, + aktiv, optionen, + ) in cur.fetchall() + ] + +def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict): + """ + Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in + device_merkmale) und protokolliert die Änderung als selected/deselected- + Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key + existiert (unbekannter Key oder im_auftragskatalog = false). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog", + (merkmal_key,), + ) + merkmal_row = cur.fetchone() + + if merkmal_row is None: + return False + + merkmal_id = merkmal_row[0] + + cur.execute( + """ + INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (device_id, merkmal_id) DO UPDATE + SET aktiv = EXCLUDED.aktiv, + optionen = EXCLUDED.optionen, + updated_at = CURRENT_TIMESTAMP + """, + (uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)), + ) + + cur.execute( + """ + INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type) + VALUES (%s, %s, %s, %s) + """, + (uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"), + ) + + return True + @app.get("/health") def health(): return { @@ -723,3 +846,74 @@ def agent_report( ) return {"success": True} + + +@app.get("/api/v1/devices/{device_id}/auftragskatalog") +def get_device_auftragskatalog( + device_id: str, + authorization: str | None = Header(default=None), +): + if not require_admin_token(authorization): + return { + "success": False, + "error": "unauthorized", + "message": "Fehlendes oder ungültiges Admin-Token.", + } + + katalog = fetch_auftragskatalog_listing(device_id) + + if katalog is None: + return { + "success": False, + "error": "device_not_assigned", + "message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.", + } + + return {"success": True, "auftragskatalog": katalog} + + +@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select") +def select_auftrag( + device_id: str, + merkmal_key: str, + payload: AuftragSelectRequest, + authorization: str | None = Header(default=None), +): + if not require_admin_token(authorization): + return { + "success": False, + "error": "unauthorized", + "message": "Fehlendes oder ungültiges Admin-Token.", + } + + if not set_auftrag_selection(device_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} + + +@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect") +def deselect_auftrag( + device_id: str, + merkmal_key: str, + authorization: str | None = Header(default=None), +): + if not require_admin_token(authorization): + return { + "success": False, + "error": "unauthorized", + "message": "Fehlendes oder ungültiges Admin-Token.", + } + + if not set_auftrag_selection(device_id, merkmal_key, False, {}): + return { + "success": False, + "error": "unknown_merkmal", + "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}", + } + + return {"success": True} diff --git a/migrations/0008_device_merkmale_optionen.sql b/migrations/0008_device_merkmale_optionen.sql new file mode 100644 index 0000000..a6fb026 --- /dev/null +++ b/migrations/0008_device_merkmale_optionen.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE device_merkmale ADD COLUMN optionen JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMIT; diff --git a/migrations/0009_auftragskatalog_seed.sql b/migrations/0009_auftragskatalog_seed.sql new file mode 100644 index 0000000..5ca9dbf --- /dev/null +++ b/migrations/0009_auftragskatalog_seed.sql @@ -0,0 +1,24 @@ +BEGIN; + +INSERT INTO kategorien (id, key, name, description, sort_order) VALUES + ('99999999-9999-4999-8999-999999999999', 'app-store', 'App-Store', + 'Installierbare Anwendungen.', 10), + ('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'individuelle-systemkonfiguration', 'Individuelle Systemkonfiguration', + 'Verhaltensänderungen am System, keine reinen Anwendungsinstallationen.', 20); + +UPDATE merkmale SET + im_auftragskatalog = true, + kategorie_id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + WHERE key = 'guest-session-ephemeral'; + +UPDATE merkmale SET + im_auftragskatalog = true, + kategorie_id = '99999999-9999-4999-8999-999999999999' + WHERE key = 'browser-brave'; + +UPDATE merkmale SET + im_auftragskatalog = true, + kategorie_id = '99999999-9999-4999-8999-999999999999' + WHERE key = 'office-onlyoffice'; + +COMMIT;