feat: Enrollment Sessions + Neugerät-Bestätigung (Kundenplattform-Admin)

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-05 09:32:39 +02:00
parent 34f9c9b2ef
commit 1d9f51008e

147
app.py
View File

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