diff --git a/app.py b/app.py index 62a260f..5569f33 100644 --- a/app.py +++ b/app.py @@ -773,9 +773,9 @@ def fetch_devices_for_organization(organization_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT id, hostname, device_fingerprint, agent_last_checkin + SELECT id, hostname, device_fingerprint, agent_last_checkin, deprovisioned_at FROM devices - WHERE organization_id = %s + WHERE organization_id = %s AND archived_at IS NULL ORDER BY hostname NULLS LAST, created_at """, (organization_id,), @@ -788,8 +788,11 @@ def fetch_devices_for_organization(organization_id: str): "agent_last_checkin": ( last_checkin.isoformat() if last_checkin is not None else None ), + "deprovisioned_at": ( + deprovisioned_at.isoformat() if deprovisioned_at is not None else None + ), } - for device_id, hostname, fingerprint, last_checkin in cur.fetchall() + for device_id, hostname, fingerprint, last_checkin, deprovisioned_at in cur.fetchall() ] def fetch_all_devices(): @@ -804,9 +807,10 @@ def fetch_all_devices(): cur.execute( """ SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, - d.organization_id, o.name + d.organization_id, o.name, d.deprovisioned_at FROM devices d JOIN organizations o ON o.id = d.organization_id + WHERE d.archived_at IS NULL ORDER BY o.name, d.hostname NULLS LAST, d.created_at """ ) @@ -820,13 +824,197 @@ def fetch_all_devices(): ), "organization_id": str(organization_id), "organization_name": organization_name, + "deprovisioned_at": ( + deprovisioned_at.isoformat() if deprovisioned_at is not None else None + ), } for ( device_id, hostname, fingerprint, last_checkin, - organization_id, organization_name, + organization_id, organization_name, deprovisioned_at, ) in cur.fetchall() ] +def fetch_latest_hardware_snapshot(device_id: str): + """ + Für die Kundenportal-Aktion "Hardware-Informationen": neuester + hardware_snapshots-Eintrag des Geräts (mehrere Snapshots über die Zeit + sind möglich, siehe store_hardware_snapshot() - hier interessiert nur der + aktuelle Stand) plus dessen Netzwerk-Interfaces und Storage-Devices. + None, falls das Gerät noch nie aktiviert wurde und somit noch keinen + Snapshot hat. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, collected_at, architecture, manufacturer, product_name, + product_version, system_uuid, system_serial, board_vendor, + board_name, board_serial, bios_vendor, bios_version, + boot_mode, secure_boot, tpm_version, cpu_model, + cpu_logical_count, memory_bytes + FROM hardware_snapshots + WHERE device_id = %s + ORDER BY collected_at DESC + LIMIT 1 + """, + (device_id,), + ) + row = cur.fetchone() + + if row is None: + return None + + ( + snapshot_id, collected_at, architecture, manufacturer, product_name, + product_version, system_uuid, system_serial, board_vendor, + board_name, board_serial, bios_vendor, bios_version, + boot_mode, secure_boot, tpm_version, cpu_model, + cpu_logical_count, memory_bytes, + ) = row + + cur.execute( + "SELECT name, type, mac_address FROM network_interfaces WHERE hardware_snapshot_id = %s ORDER BY name", + (snapshot_id,), + ) + network_interfaces = [ + {"name": name, "type": type_, "mac_address": mac_address} + for name, type_, mac_address in cur.fetchall() + ] + + cur.execute( + "SELECT name, model, serial, size_bytes, transport FROM storage_devices WHERE hardware_snapshot_id = %s ORDER BY name", + (snapshot_id,), + ) + storage_devices = [ + { + "name": name, + "model": model, + "serial": serial, + "size_bytes": size_bytes, + "transport": transport, + } + for name, model, serial, size_bytes, transport in cur.fetchall() + ] + + return { + "collected_at": collected_at.isoformat(), + "architecture": architecture, + "manufacturer": manufacturer, + "product_name": product_name, + "product_version": product_version, + "system_uuid": system_uuid, + "system_serial": system_serial, + "board_vendor": board_vendor, + "board_name": board_name, + "board_serial": board_serial, + "bios_vendor": bios_vendor, + "bios_version": bios_version, + "boot_mode": boot_mode, + "secure_boot": secure_boot, + "tpm_version": tpm_version, + "cpu_model": cpu_model, + "cpu_logical_count": cpu_logical_count, + "memory_bytes": memory_bytes, + "network_interfaces": network_interfaces, + "storage_devices": storage_devices, + } + +def fetch_device_events(device_id: str): + """ + Für die Kundenportal-Aktion "Installations-/Aktions-Log": alle bisher + vom Agenten/Portal gemeldeten device_merkmal_events des Geräts, neueste + zuerst. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT e.occurred_at, m.key, m.name, e.event_type, e.detail + FROM device_merkmal_events e + JOIN merkmale m ON m.id = e.merkmal_id + WHERE e.device_id = %s + ORDER BY e.occurred_at DESC + """, + (device_id,), + ) + return [ + { + "occurred_at": occurred_at.isoformat(), + "merkmal": merkmal_key, + "merkmal_name": merkmal_name, + "event_type": event_type, + "detail": detail, + } + for occurred_at, merkmal_key, merkmal_name, event_type, detail in cur.fetchall() + ] + +def deprovision_device(device_id: str) -> bool: + """ + Serverseitiger Entzug (siehe Kundenportal-Geräteaktionen-Plan): macht das + aktuelle Agent-Secret ungültig, ohne den Geräte-Datensatz oder dessen + Historie anzutasten. Der Agent auf dem Gerät selbst wird nicht aktiv + kontaktiert - er bekommt beim nächsten Check-in schlicht 401 und bleibt + bis zur Reprovisionierung untätig liegen (siehe agent.py Self-Heal). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE devices + SET agent_secret_hash = NULL, agent_secret_issued_at = NULL, + deprovisioned_at = CURRENT_TIMESTAMP + WHERE id = %s AND archived_at IS NULL + RETURNING id + """, + (device_id,), + ) + return cur.fetchone() is not None + +def reprovision_device(device_id: str) -> bool: + """ + Hebt die Deprovisionierung auf. Erzeugt bewusst kein neues Agent-Secret - + das holt sich der Agent beim nächsten Poll-Zyklus selbst über den + bestehenden /api/v1/agent/bootstrap-Endpunkt (401 auf checkin loest im + Agenten einen Re-Bootstrap aus, siehe agent.py). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE devices + SET deprovisioned_at = NULL + WHERE id = %s AND archived_at IS NULL + RETURNING id + """, + (device_id,), + ) + return cur.fetchone() is not None + +def archive_device(device_id: str) -> bool: + """ + Kundenportal-Aktion "Gerät löschen": Soft Delete. Das Gerät verschwindet + aus fetch_devices_for_organization()/fetch_all_devices(), Datensatz und + Historie (hardware_snapshots, device_merkmal_events) bleiben in der DB + erhalten. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE devices + SET archived_at = CURRENT_TIMESTAMP + WHERE id = %s AND archived_at IS NULL + RETURNING id + """, + (device_id,), + ) + return cur.fetchone() is not None + def fetch_organizations(): with get_database_connection() as conn: with conn.cursor() as cur: @@ -1736,6 +1924,96 @@ def list_all_devices(authorization: str | None = Header(default=None)): return {"success": True, "devices": fetch_all_devices()} +@app.get("/api/v1/devices/{device_id}/hardware") +def get_device_hardware(device_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.", + } + + snapshot = fetch_latest_hardware_snapshot(device_id) + + if snapshot is None: + return { + "success": False, + "error": "no_snapshot", + "message": "Für dieses Gerät liegt noch kein Hardware-Snapshot vor.", + } + + return {"success": True, "hardware": snapshot} + + +@app.get("/api/v1/devices/{device_id}/events") +def get_device_events(device_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, "events": fetch_device_events(device_id)} + + +@app.post("/api/v1/devices/{device_id}/deprovision") +def deprovision(device_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.", + } + + if not deprovision_device(device_id): + return { + "success": False, + "error": "device_not_found", + "message": "Das angegebene Gerät wurde nicht gefunden.", + } + + return {"success": True} + + +@app.post("/api/v1/devices/{device_id}/reprovision") +def reprovision(device_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.", + } + + if not reprovision_device(device_id): + return { + "success": False, + "error": "device_not_found", + "message": "Das angegebene Gerät wurde nicht gefunden.", + } + + return {"success": True} + + +@app.post("/api/v1/devices/{device_id}/archive") +def archive(device_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.", + } + + if not archive_device(device_id): + return { + "success": False, + "error": "device_not_found", + "message": "Das angegebene Gerät wurde nicht gefunden.", + } + + return {"success": True} + + @app.get("/api/v1/organizations") def list_organizations(authorization: str | None = Header(default=None)): if not require_service_token(authorization): diff --git a/migrations/0017_device_lifecycle.sql b/migrations/0017_device_lifecycle.sql new file mode 100644 index 0000000..d639242 --- /dev/null +++ b/migrations/0017_device_lifecycle.sql @@ -0,0 +1,11 @@ +BEGIN; + +-- Kundenportal-Geräteaktionen (De-/Reprovisionieren, Löschen): zwei +-- unabhängige Nullable-Timestamps statt eines Status-Enums, weil ein Gerät +-- deprovisioniert und trotzdem noch in der aktiven Liste sichtbar sein kann, +-- waehrend "archiviert" es komplett ausblendet - ein einzelnes Enum wuerde +-- ungueltige Kombinationen erlauben oder Zustaende weglassen. +ALTER TABLE devices ADD COLUMN deprovisioned_at TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN archived_at TIMESTAMPTZ; + +COMMIT;