From 896c22c537b612a28c615d9d3e078c3b498b5489 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Thu, 6 Aug 2026 16:56:46 +0200 Subject: [PATCH] feat: Enrollment-Session-Verbrauch, Organisations-PATCH, strukturierte Partitionierung (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/v1/activate akzeptiert jetzt zusätzlich Enrollment-Session-Codes (die Session-id selbst dient als Bearer-Credential, kein neues code-Feld nötig) neben den bestehenden organisationsweiten Aktivierungscodes - fällt bei Nicht-UUID sofort auf den klassischen Pfad zurück, kein Konflikt. Prüft Widerruf/Ablauf/Kontingent, zählt devices_used bei Erfolg hoch. Ungültige Sessions melden denselben invalid_activation_code-Fehlschlag wie ein ungültiger klassischer Code - das bricht den Installer schon beim Server-Handshake ab, lange vor jeder destruktiven Aktion (siehe ADR-0008), kein zusätzlicher Mechanismus nötig. Antwort-templates werden bei Enrollment-Session-Aktivierung auf die eine zur Session gehörende Vorlage gefiltert und mit is_default=true markiert - der bereits in tuxflotte-installer Phase 2 gebaute Auto-Modus (wählt automatisch die als is_default markierte Vorlage) funktioniert dadurch ohne jede Installer-Änderung korrekt. organizations: neuer PATCH-Endpoint (nur Name, weitere Felder bewusst noch offen). bereitstellungsvorlagen.partitioning: von TEXT auf JSONB umgestellt, bestehende "default"-Werte auf {"scheme": "single", "root_filesystem": "ext4"} migriert - das ist der Vertrag, den tuxflotte-installers backend_generate_config() (Phase 2) bereits erwartet. Lokal gegen eine Wegwerf-Postgres mit allen Migrationen 0001-0015 verifiziert: Enrollment-Session-Aktivierung (Vorlagen-Filterung, is_default-Erzwingung, devices_used-Zählung), Kontingent-Erschöpfung, Widerruf, Ablauf, Rückfall auf klassische Aktivierungscodes (Regression), PATCH organizations (Erfolg + 404), /resolve liefert partitioning jetzt korrekt als JSON-Objekt statt String. Co-Authored-By: Claude Sonnet 5 --- app.py | 156 ++++++++++++++++-- ...session_activation_und_partitionierung.sql | 24 +++ 2 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 migrations/0015_enrollment_session_activation_und_partitionierung.sql diff --git a/app.py b/app.py index 9e91ec5..0950989 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,7 @@ import hmac import json import os import secrets -from uuid import uuid4 +from uuid import UUID, uuid4 import psycopg from psycopg.types.json import Jsonb @@ -65,6 +65,10 @@ class CreateOrganizationRequest(BaseModel): name: str +class UpdateOrganizationRequest(BaseModel): + name: str + + class CreateMerkmalRequest(BaseModel): key: str name: str @@ -341,6 +345,67 @@ def find_activation_code(code: str): return {"organization_id": str(row[0]), "organization_name": row[1]} +def find_enrollment_session(code: str): + """ + Enrollment Sessions haben kein eigenes code-Feld - ihre id dient direkt + als Bearer-Credential (unratbare UUID, siehe migrations/0012). Nur + gültige Sessions (nicht widerrufen, nicht abgelaufen, Kontingent nicht + erschöpft) werden zurückgegeben; jede andere Bedingung lässt /activate + denselben "invalid_activation_code"-Fehlschlag melden wie ein + ungültiger klassischer Aktivierungscode - das bricht den Installer + bereits beim Server-Handshake (Schritt 15) ab, lange vor jeder + destruktiven Aktion (siehe ADR-0008). + """ + + try: + session_id = UUID(code) + except ValueError: + return None + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id, + es.max_devices, es.devices_used, es.expires_at, es.revoked_at + FROM enrollment_sessions es + JOIN organizations o ON o.id = es.organization_id + WHERE es.id = %s + """, + (session_id,), + ) + row = cur.fetchone() + + if row is None: + return None + + ( + session_id, organization_id, organization_name, bereitstellungsvorlage_id, + max_devices, devices_used, expires_at, revoked_at, + ) = row + + if revoked_at is not None: + return None + if expires_at <= datetime.now(timezone.utc): + return None + if devices_used >= max_devices: + return None + + return { + "session_id": str(session_id), + "organization_id": str(organization_id), + "organization_name": organization_name, + "bereitstellungsvorlage_id": str(bereitstellungsvorlage_id), + } + +def consume_enrollment_session(session_id: str) -> None: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE enrollment_sessions SET devices_used = devices_used + 1 WHERE id = %s", + (session_id,), + ) + def fetch_templates(organization_id: str) -> list[dict]: with get_database_connection() as conn: with conn.cursor() as cur: @@ -694,6 +759,20 @@ def create_organization(name: str): return {"id": str(organization_id), "name": name} +def update_organization(organization_id: str, name: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE organizations SET name = %s WHERE id = %s RETURNING id, name", + (name, organization_id), + ) + row = cur.fetchone() + + if row is None: + return None + + return {"id": str(row[0]), "name": row[1]} + def fetch_activation_codes(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: @@ -1038,17 +1117,31 @@ def health(): @app.post("/api/v1/activate") def activate(payload: ActivationRequest, request: Request): - activation = find_activation_code(payload.activation_code) + # Enrollment Sessions zuerst versuchen (Auto-Modus-ISOs, siehe + # tuxflotte-installer Phase 2/3) - ihre id ist selbst der Code, kollidiert + # nicht mit klassischen Aktivierungscodes (eigene, meist sprechende + # Strings wie "LAB-2026-START"). Fällt bei Nicht-UUID sofort auf den + # klassischen Pfad zurück. + enrollment_session = find_enrollment_session(payload.activation_code) + activation = None + + if enrollment_session is not None: + organization_id = enrollment_session["organization_id"] + organization_name = enrollment_session["organization_name"] + success = True + else: + activation = find_activation_code(payload.activation_code) + success = activation is not None + + if success: + organization_id = activation["organization_id"] + organization_name = activation["organization_name"] - success = activation is not None device = None device_created = False - hardware_snapshot_id = None if success: - organization_id = activation["organization_id"] - device, device_created = load_or_create_device( organization_id, payload.device_fingerprint, @@ -1059,6 +1152,9 @@ def activate(payload: ActivationRequest, request: Request): payload.hardware, ) + if enrollment_session is not None: + consume_enrollment_session(enrollment_session["session_id"]) + write_log({ "timestamp": datetime.now(timezone.utc).isoformat(), "event": "activate", @@ -1077,6 +1173,21 @@ def activate(payload: ActivationRequest, request: Request): "message": "Der Aktivierungscode ist ungültig." } + if enrollment_session is not None: + # Die Bereitstellungsvorlage steht durch die Enrollment Session schon + # fest - nicht die volle Organisationsliste zurückgeben (20_profile_ + # selection.sh würde im Auto-Modus sonst nichts Eindeutiges zum + # Auswählen haben), sondern nur diese eine, als is_default markiert + # (dieselbe Erkennung, die der Installer schon für den regulären + # Standard-Vorlagen-Fall benutzt - keine Installer-Änderung nötig). + templates = [ + {**template, "is_default": True} + for template in fetch_templates(organization_id) + if template["id"] == enrollment_session["bereitstellungsvorlage_id"] + ] + else: + templates = fetch_templates(organization_id) + return { "success": True, @@ -1093,11 +1204,11 @@ def activate(payload: ActivationRequest, request: Request): }, "customer": { - "id": activation["organization_id"], - "organization_id": activation["organization_id"], - "name": activation["organization_name"], + "id": organization_id, + "organization_id": organization_id, + "name": organization_name, }, - "templates": fetch_templates(activation["organization_id"]), + "templates": templates, } @@ -1371,6 +1482,31 @@ def create_organization_endpoint( return {"success": True, "organization": create_organization(payload.name)} +@app.patch("/api/v1/organizations/{organization_id}") +def update_organization_endpoint( + organization_id: str, + payload: UpdateOrganizationRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return { + "success": False, + "error": "unauthorized", + "message": "Fehlendes oder ungültiges Service-Token.", + } + + organization = update_organization(organization_id, payload.name) + + if organization is None: + return { + "success": False, + "error": "organization_not_found", + "message": "Die Organisation wurde nicht gefunden.", + } + + return {"success": True, "organization": organization} + + @app.get("/api/v1/organizations/{organization_id}/activation-codes") def list_activation_codes( organization_id: str, diff --git a/migrations/0015_enrollment_session_activation_und_partitionierung.sql b/migrations/0015_enrollment_session_activation_und_partitionierung.sql new file mode 100644 index 0000000..97817d6 --- /dev/null +++ b/migrations/0015_enrollment_session_activation_und_partitionierung.sql @@ -0,0 +1,24 @@ +BEGIN; + +-- installation_directives.partitioning ist jetzt ein strukturiertes Objekt +-- statt eines einfachen Strings (siehe tuxflotte-installer Phase 2, +-- backend_generate_config() erwartet {"scheme": "single"|"custom", +-- "root_filesystem": "ext4"|"btrfs", "extra_partitions": [...]}) - +-- bestehende "default"-Werte werden auf die neue single-scheme-Form +-- abgebildet. +-- Bestehender Textdefault muss vor der Typumstellung weg (kann nicht +-- automatisch nach JSONB gecastet werden). +ALTER TABLE bereitstellungsvorlagen + ALTER COLUMN partitioning DROP DEFAULT; + +ALTER TABLE bereitstellungsvorlagen + ALTER COLUMN partitioning TYPE JSONB + USING CASE + WHEN partitioning = 'default' THEN '{"scheme": "single", "root_filesystem": "ext4"}'::jsonb + ELSE to_jsonb(partitioning) + END; + +ALTER TABLE bereitstellungsvorlagen + ALTER COLUMN partitioning SET DEFAULT '{"scheme": "single", "root_filesystem": "ext4"}'::jsonb; + +COMMIT;