feat: Enrollment-Session-Verbrauch, Organisations-PATCH, strukturierte Partitionierung (Phase 3)

/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 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-06 16:56:46 +02:00
parent 5237606f49
commit 896c22c537
2 changed files with 170 additions and 10 deletions

156
app.py
View File

@ -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):
# 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
device = None
device_created = False
hardware_snapshot_id = None
if success:
organization_id = activation["organization_id"]
organization_name = activation["organization_name"]
device = None
device_created = False
hardware_snapshot_id = None
if success:
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,

View File

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