feat: Geräte-Lebenszyklus (De-/Reprovisionieren, Archivieren) + Hardware-/Event-Read-Endpoints

Fundament für die neue Aktionen-Spalte im Kundenportal (/geraete):
- devices.deprovisioned_at / archived_at (Migration 0017)
- GET /api/v1/devices/{id}/hardware, GET .../events
- POST .../deprovision, .../reprovision, .../archive
- fetch_devices_for_organization/fetch_all_devices blenden archivierte
  Geräte aus, liefern deprovisioned_at mit

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-10 13:04:51 +02:00
parent fb175bf861
commit 382ec12245
2 changed files with 294 additions and 5 deletions

288
app.py
View File

@ -773,9 +773,9 @@ def fetch_devices_for_organization(organization_id: str):
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT id, hostname, device_fingerprint, agent_last_checkin SELECT id, hostname, device_fingerprint, agent_last_checkin, deprovisioned_at
FROM devices FROM devices
WHERE organization_id = %s WHERE organization_id = %s AND archived_at IS NULL
ORDER BY hostname NULLS LAST, created_at ORDER BY hostname NULLS LAST, created_at
""", """,
(organization_id,), (organization_id,),
@ -788,8 +788,11 @@ def fetch_devices_for_organization(organization_id: str):
"agent_last_checkin": ( "agent_last_checkin": (
last_checkin.isoformat() if last_checkin is not None else None 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(): def fetch_all_devices():
@ -804,9 +807,10 @@ def fetch_all_devices():
cur.execute( cur.execute(
""" """
SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, 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 FROM devices d
JOIN organizations o ON o.id = d.organization_id 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 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_id": str(organization_id),
"organization_name": organization_name, "organization_name": organization_name,
"deprovisioned_at": (
deprovisioned_at.isoformat() if deprovisioned_at is not None else None
),
} }
for ( for (
device_id, hostname, fingerprint, last_checkin, device_id, hostname, fingerprint, last_checkin,
organization_id, organization_name, organization_id, organization_name, deprovisioned_at,
) in cur.fetchall() ) 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(): def fetch_organizations():
with get_database_connection() as conn: with get_database_connection() as conn:
with conn.cursor() as cur: 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()} 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") @app.get("/api/v1/organizations")
def list_organizations(authorization: str | None = Header(default=None)): def list_organizations(authorization: str | None = Header(default=None)):
if not require_service_token(authorization): if not require_service_token(authorization):

View File

@ -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;