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 json
import os import os
import secrets import secrets
from uuid import uuid4 from uuid import UUID, uuid4
import psycopg import psycopg
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
@ -65,6 +65,10 @@ class CreateOrganizationRequest(BaseModel):
name: str name: str
class UpdateOrganizationRequest(BaseModel):
name: str
class CreateMerkmalRequest(BaseModel): class CreateMerkmalRequest(BaseModel):
key: str key: str
name: str name: str
@ -341,6 +345,67 @@ def find_activation_code(code: str):
return {"organization_id": str(row[0]), "organization_name": row[1]} 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]: def fetch_templates(organization_id: str) -> list[dict]:
with get_database_connection() as conn: with get_database_connection() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@ -694,6 +759,20 @@ def create_organization(name: str):
return {"id": str(organization_id), "name": name} 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): def fetch_activation_codes(organization_id: str):
with get_database_connection() as conn: with get_database_connection() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@ -1038,17 +1117,31 @@ def health():
@app.post("/api/v1/activate") @app.post("/api/v1/activate")
def activate(payload: ActivationRequest, request: Request): 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 = None
device_created = False device_created = False
hardware_snapshot_id = None hardware_snapshot_id = None
if success: if success:
organization_id = activation["organization_id"]
device, device_created = load_or_create_device( device, device_created = load_or_create_device(
organization_id, organization_id,
payload.device_fingerprint, payload.device_fingerprint,
@ -1059,6 +1152,9 @@ def activate(payload: ActivationRequest, request: Request):
payload.hardware, payload.hardware,
) )
if enrollment_session is not None:
consume_enrollment_session(enrollment_session["session_id"])
write_log({ write_log({
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"event": "activate", "event": "activate",
@ -1077,6 +1173,21 @@ def activate(payload: ActivationRequest, request: Request):
"message": "Der Aktivierungscode ist ungültig." "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 { return {
"success": True, "success": True,
@ -1093,11 +1204,11 @@ def activate(payload: ActivationRequest, request: Request):
}, },
"customer": { "customer": {
"id": activation["organization_id"], "id": organization_id,
"organization_id": activation["organization_id"], "organization_id": organization_id,
"name": activation["organization_name"], "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)} 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") @app.get("/api/v1/organizations/{organization_id}/activation-codes")
def list_activation_codes( def list_activation_codes(
organization_id: str, 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;