From 1d9f51008e179fdc3bc3afce91faeceba3f0279e Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 5 Aug 2026 09:32:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Enrollment=20Sessions=20+=20Neuger?= =?UTF-8?q?=C3=A4t-Best=C3=A4tigung=20(Kundenplattform-Admin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints für den Admin-Bereich "Verwaltung -> Flows": Enrollment Sessions anlegen/auflisten/widerrufen, unbestätigte Geräte je Organisation auflisten (Geräte ohne Bereitstellungsvorlagen-Zuweisung), Bereitstellungsvorlagen einer Organisation abrufen (bisher nur intern in fetch_templates() für /activate genutzt, jetzt auch als eigener Endpoint). Die Bestätigung selbst nutzt den bereits bestehenden /api/v1/templates/{id}/resolve-Endpoint - kein neuer Mechanismus nötig. Deckt den serverseitigen Teil aus ADR-0008 ab. Der Installer-seitige Wartemechanismus (Auto-Modus-Gate pollt auf Freigabe) ist bewusst nicht Teil dieser Änderung - separates Folgeprojekt in tuxflotte-installer. Co-Authored-By: Claude Sonnet 5 --- app.py | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/app.py b/app.py index 8b5929d..9e91ec5 100644 --- a/app.py +++ b/app.py @@ -92,6 +92,12 @@ class CreateWorkspaceRequest(BaseModel): organization_id: str | None = None +class CreateEnrollmentSessionRequest(BaseModel): + bereitstellungsvorlage_id: str + max_devices: int + expires_at: str + + 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") @@ -933,6 +939,94 @@ def fetch_blueprints(): for merkmal_key, backend_key, ansible_role in cur.fetchall() ] +def fetch_unassigned_devices(organization_id: str): + """ + Geräte einer Organisation ohne Bereitstellungsvorlagen-Zuweisung - die + Kandidaten für die Neugerät-Bestätigung (ADR-0008) im Flows-Bereich. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT d.id, d.hostname, d.device_fingerprint, d.created_at + FROM devices d + LEFT JOIN assignments a ON a.device_id = d.id + WHERE d.organization_id = %s AND a.id IS NULL + ORDER BY d.created_at + """, + (organization_id,), + ) + return [ + { + "id": str(device_id), + "hostname": hostname, + "device_fingerprint": fingerprint, + "created_at": created_at.isoformat(), + } + for device_id, hostname, fingerprint, created_at in cur.fetchall() + ] + +def fetch_enrollment_sessions(organization_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT es.id, bv.label, es.max_devices, es.devices_used, + es.expires_at, es.revoked_at, es.created_at + FROM enrollment_sessions es + JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id + WHERE es.organization_id = %s + ORDER BY es.created_at DESC + """, + (organization_id,), + ) + return [ + { + "id": str(sid), + "bereitstellungsvorlage_label": label, + "max_devices": max_devices, + "devices_used": devices_used, + "expires_at": expires_at.isoformat(), + "revoked_at": revoked_at.isoformat() if revoked_at else None, + "created_at": created_at.isoformat(), + } + for ( + sid, label, max_devices, devices_used, + expires_at, revoked_at, created_at, + ) in cur.fetchall() + ] + +def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str): + session_id = uuid4() + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO enrollment_sessions + (id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at) + VALUES (%s, %s, %s, %s, %s) + """, + (session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at), + ) + + return {"id": str(session_id)} + +def revoke_enrollment_session(session_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE enrollment_sessions + SET revoked_at = CURRENT_TIMESTAMP + WHERE id = %s AND revoked_at IS NULL + RETURNING id + """, + (session_id,), + ) + return cur.fetchone() is not None + @app.get("/health") def health(): return { @@ -1427,6 +1521,59 @@ def list_blueprints(authorization: str | None = Header(default=None)): return {"success": True, "blueprints": fetch_blueprints()} +@app.get("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen") +def list_bereitstellungsvorlagen(organization_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "bereitstellungsvorlagen": fetch_templates(organization_id)} + + +@app.get("/api/v1/organizations/{organization_id}/devices/unassigned") +def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "devices": fetch_unassigned_devices(organization_id)} + + +@app.get("/api/v1/organizations/{organization_id}/enrollment-sessions") +def list_enrollment_sessions(organization_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "enrollment_sessions": fetch_enrollment_sessions(organization_id)} + + +@app.post("/api/v1/organizations/{organization_id}/enrollment-sessions") +def create_enrollment_session_endpoint( + organization_id: str, + payload: CreateEnrollmentSessionRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + session = create_enrollment_session( + organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at + ) + + return {"success": True, "enrollment_session": session} + + +@app.post("/api/v1/enrollment-sessions/{session_id}/revoke") +def revoke_enrollment_session_endpoint(session_id: str, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + revoked = revoke_enrollment_session(session_id) + + if not revoked: + return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist bereits widerrufen."} + + return {"success": True} + + @app.get("/api/v1/devices/{device_id}/auftragskatalog") def get_device_auftragskatalog( device_id: str,