feat: Auftragskatalog-Auswahl-API und optionen-Passthrough

Neue Migrationen 0008 (device_merkmale.optionen JSONB) und 0009 (Kategorien
app-store/individuelle-systemkonfiguration, Katalogfähigkeit für
guest-session-ephemeral/browser-brave/office-onlyoffice). Check-in-Antwort
gibt optionen je Auftrag mit. Neue admin-token-geschützte Endpoints zum
Auflisten/Auswählen/Abwählen von Aufträgen pro Gerät.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-04 15:28:49 +02:00
parent dc9bc40991
commit 05a80f0998
3 changed files with 225 additions and 2 deletions

198
app.py
View File

@ -19,6 +19,7 @@ LOG_PATH = Path("activation.log")
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300"))
ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "")
ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "")
AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"}
app = FastAPI(title="Provisioning Activation Server", version="0.1.0") app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
@ -55,6 +56,10 @@ class AgentReportRequest(BaseModel):
results: list[AgentReportEntry] results: list[AgentReportEntry]
class AuftragSelectRequest(BaseModel):
optionen: dict = {}
def write_log(entry: dict) -> None: def write_log(entry: dict) -> None:
with LOG_PATH.open("a", encoding="utf-8") as f: with LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n") f.write(json.dumps(entry, ensure_ascii=False) + "\n")
@ -328,6 +333,17 @@ def fetch_templates(organization_id: str) -> list[dict]:
for row in rows for row in rows
] ]
def require_admin_token(authorization: str | None) -> bool:
if not ADMIN_TOKEN:
return False
if authorization is None or not authorization.startswith("Bearer "):
return False
provided_token = authorization.removeprefix("Bearer ")
return hmac.compare_digest(provided_token, ADMIN_TOKEN)
def hash_agent_secret(secret: str) -> str: def hash_agent_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest() return hashlib.sha256(secret.encode("utf-8")).hexdigest()
@ -415,7 +431,8 @@ def fetch_auftragskatalog_state(device_id: str):
cur.execute( cur.execute(
""" """
SELECT m.key, bp.ansible_role, SELECT m.key, bp.ansible_role,
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL) COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL),
COALESCE(dm.optionen, '{}'::jsonb)
FROM merkmale m FROM merkmale m
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
LEFT JOIN device_merkmale dm LEFT JOIN device_merkmale dm
@ -431,10 +448,116 @@ def fetch_auftragskatalog_state(device_id: str):
"merkmal": key, "merkmal": key,
"ansible_role": ansible_role, "ansible_role": ansible_role,
"state": "present" if aktiv else "absent", "state": "present" if aktiv else "absent",
"optionen": optionen,
} }
for key, ansible_role, aktiv in cur.fetchall() for key, ansible_role, aktiv, optionen in cur.fetchall()
] ]
def fetch_auftragskatalog_listing(device_id: str):
"""
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
Anzeige-relevante Felder (Name, Beschreibung, Kategorie) statt nur die
für den Agenten nötigen (Rollenname, State).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
if assignment_row is None:
return None
workspace_id, backend_id = assignment_row
cur.execute(
"""
SELECT m.key, m.name, m.description,
k.key, k.name, k.sort_order,
COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL),
COALESCE(dm.optionen, '{}'::jsonb)
FROM merkmale m
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
LEFT JOIN kategorien k ON k.id = m.kategorie_id
LEFT JOIN device_merkmale dm
ON dm.merkmal_id = m.id AND dm.device_id = %s
LEFT JOIN workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(backend_id, device_id, workspace_id),
)
return [
{
"merkmal": key,
"name": name,
"description": description,
"kategorie": (
{"key": kat_key, "name": kat_name, "sort_order": sort_order}
if kat_key is not None else None
),
"state": "present" if aktiv else "absent",
"optionen": optionen,
}
for (
key, name, description,
kat_key, kat_name, sort_order,
aktiv, optionen,
) in cur.fetchall()
]
def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict):
"""
Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in
device_merkmale) und protokolliert die Änderung als selected/deselected-
Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key
existiert (unbekannter Key oder im_auftragskatalog = false).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog",
(merkmal_key,),
)
merkmal_row = cur.fetchone()
if merkmal_row is None:
return False
merkmal_id = merkmal_row[0]
cur.execute(
"""
INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (device_id, merkmal_id) DO UPDATE
SET aktiv = EXCLUDED.aktiv,
optionen = EXCLUDED.optionen,
updated_at = CURRENT_TIMESTAMP
""",
(uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)),
)
cur.execute(
"""
INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type)
VALUES (%s, %s, %s, %s)
""",
(uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"),
)
return True
@app.get("/health") @app.get("/health")
def health(): def health():
return { return {
@ -723,3 +846,74 @@ def agent_report(
) )
return {"success": True} return {"success": True}
@app.get("/api/v1/devices/{device_id}/auftragskatalog")
def get_device_auftragskatalog(
device_id: str,
authorization: str | None = Header(default=None),
):
if not require_admin_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
katalog = fetch_auftragskatalog_listing(device_id)
if katalog is None:
return {
"success": False,
"error": "device_not_assigned",
"message": "Das Gerät hat keine aktive Bereitstellungsvorlagen-Zuweisung.",
}
return {"success": True, "auftragskatalog": katalog}
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select")
def select_auftrag(
device_id: str,
merkmal_key: str,
payload: AuftragSelectRequest,
authorization: str | None = Header(default=None),
):
if not require_admin_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
if not set_auftrag_selection(device_id, merkmal_key, True, payload.optionen):
return {
"success": False,
"error": "unknown_merkmal",
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
}
return {"success": True}
@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect")
def deselect_auftrag(
device_id: str,
merkmal_key: str,
authorization: str | None = Header(default=None),
):
if not require_admin_token(authorization):
return {
"success": False,
"error": "unauthorized",
"message": "Fehlendes oder ungültiges Admin-Token.",
}
if not set_auftrag_selection(device_id, merkmal_key, False, {}):
return {
"success": False,
"error": "unknown_merkmal",
"message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}",
}
return {"success": True}

View File

@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE device_merkmale ADD COLUMN optionen JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMIT;

View File

@ -0,0 +1,24 @@
BEGIN;
INSERT INTO kategorien (id, key, name, description, sort_order) VALUES
('99999999-9999-4999-8999-999999999999', 'app-store', 'App-Store',
'Installierbare Anwendungen.', 10),
('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'individuelle-systemkonfiguration', 'Individuelle Systemkonfiguration',
'Verhaltensänderungen am System, keine reinen Anwendungsinstallationen.', 20);
UPDATE merkmale SET
im_auftragskatalog = true,
kategorie_id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
WHERE key = 'guest-session-ephemeral';
UPDATE merkmale SET
im_auftragskatalog = true,
kategorie_id = '99999999-9999-4999-8999-999999999999'
WHERE key = 'browser-brave';
UPDATE merkmale SET
im_auftragskatalog = true,
kategorie_id = '99999999-9999-4999-8999-999999999999'
WHERE key = 'office-onlyoffice';
COMMIT;