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:
parent
5237606f49
commit
896c22c537
156
app.py
156
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):
|
||||
# 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,
|
||||
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user