feat: Kundenplattform-Service-Token + Organizations-Devices-Endpoint

Neue Env-Var TUXFLOTTE_KUNDENPLATTFORM_TOKEN, require_admin_token zu
require_service_token verallgemeinert (akzeptiert Admin- oder
Kundenplattform-Token). Neuer GET /api/v1/organizations/{id}/devices
Endpoint für Kundenplattforms Geräteliste + Besitz-Validierung (ADR-0011).
Bestehende Auftragskatalog-Endpoints akzeptieren jetzt beide Tokens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-04 16:52:55 +02:00
parent dc1637de7e
commit 0967f7369e

70
app.py
View File

@ -20,6 +20,7 @@ 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", "")
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
@ -333,16 +334,26 @@ 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
def require_service_token(authorization: str | None) -> bool:
"""
Akzeptiert entweder den Betreiber-Admin-Token oder das separate
Kundenplattform-Service-Token (siehe ADR-0011) - beide dürfen die
Auftragskatalog-Endpoints aufrufen, Kompromittierung/Rotation des einen
betrifft den anderen nicht.
"""
if authorization is None or not authorization.startswith("Bearer "):
return False
provided_token = authorization.removeprefix("Bearer ")
return hmac.compare_digest(provided_token, ADMIN_TOKEN)
if ADMIN_TOKEN and hmac.compare_digest(provided_token, ADMIN_TOKEN):
return True
if KUNDENPLATTFORM_TOKEN and hmac.compare_digest(provided_token, KUNDENPLATTFORM_TOKEN):
return True
return False
def hash_agent_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
@ -558,6 +569,36 @@ def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optione
return True
def fetch_devices_for_organization(organization_id: str):
"""
Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011):
alle Geräte einer Organisation, unabhängig von Bereitstellungsvorlagen-
Zuweisung.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, hostname, device_fingerprint, agent_last_checkin
FROM devices
WHERE organization_id = %s
ORDER BY hostname NULLS LAST, created_at
""",
(organization_id,),
)
return [
{
"id": str(device_id),
"hostname": hostname,
"device_fingerprint": fingerprint,
"agent_last_checkin": (
last_checkin.isoformat() if last_checkin is not None else None
),
}
for device_id, hostname, fingerprint, last_checkin in cur.fetchall()
]
@app.get("/health")
def health():
return {
@ -848,12 +889,27 @@ def agent_report(
return {"success": True}
@app.get("/api/v1/organizations/{organization_id}/devices")
def get_organization_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_devices_for_organization(organization_id)}
@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):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
@ -879,7 +935,7 @@ def select_auftrag(
payload: AuftragSelectRequest,
authorization: str | None = Header(default=None),
):
if not require_admin_token(authorization):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",
@ -902,7 +958,7 @@ def deselect_auftrag(
merkmal_key: str,
authorization: str | None = Header(default=None),
):
if not require_admin_token(authorization):
if not require_service_token(authorization):
return {
"success": False,
"error": "unauthorized",