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 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):
|
||||||
|
# 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)
|
activation = find_activation_code(payload.activation_code)
|
||||||
|
|
||||||
success = activation is not None
|
success = activation is not None
|
||||||
device = None
|
|
||||||
device_created = False
|
|
||||||
|
|
||||||
hardware_snapshot_id = None
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
organization_id = activation["organization_id"]
|
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(
|
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,
|
||||||
|
|||||||
@ -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